Browse Source

refactor: ajustes componentes e validacoes

Gustavo Mantovani 1 day ago
parent
commit
410cb40821
53 changed files with 10094 additions and 5847 deletions
  1. 85 0
      src/components/defaults/CustomFileInput.vue
  2. 30 23
      src/components/defaults/DefaultCurrencyInput.vue
  3. 75 59
      src/components/defaults/DefaultFilePicker.vue
  4. 19 5
      src/components/defaults/DefaultForm.vue
  5. 75 0
      src/components/defaults/DefaultImagePicker.vue
  6. 53 69
      src/components/defaults/DefaultInput.vue
  7. 143 62
      src/components/defaults/DefaultSelect.vue
  8. 245 138
      src/components/layout/DefaultHeaderPage.vue
  9. 152 124
      src/components/layout/LeftMenuLayout.vue
  10. 68 56
      src/components/layout/LeftMenuLayoutMobile.vue
  11. 164 85
      src/components/selects/CitySelect.vue
  12. 159 78
      src/components/selects/StateSelect.vue
  13. 5 30
      src/components/shared/ChangeImageDialog.vue
  14. 74 92
      src/composables/useForm.js
  15. 98 44
      src/composables/useInputRules.js
  16. 2 0
      src/composables/useScroll.js
  17. 25 12
      src/composables/useSubmitHandler.js
  18. 10 9
      src/css/app.scss
  19. 301 161
      src/pages/classes/components/AddEditClassDialog.vue
  20. 48 32
      src/pages/classes/components/JustifyAttendanceDialog.vue
  21. 392 226
      src/pages/dashboard/DashboardPage.vue
  22. 396 281
      src/pages/dashboard/components/FeriadosDialog.vue
  23. 181 113
      src/pages/dashboard/components/FeriadosEditDialog.vue
  24. 359 204
      src/pages/financial/AccountsPayablePage.vue
  25. 450 273
      src/pages/financial/AccountsReceivablePage.vue
  26. 409 197
      src/pages/financial/ChartOfAccountsPage.vue
  27. 137 71
      src/pages/financial/components/AddEditTreasuryAccountDialog.vue
  28. 164 71
      src/pages/financial/components/AddTreasuryLaunchDialog.vue
  29. 160 68
      src/pages/kanban/KanbanPage.vue
  30. 501 300
      src/pages/kanban/components/AddEditKanbanDialog.vue
  31. 499 278
      src/pages/packages/components/AddEditPackageDialog.vue
  32. 118 28
      src/pages/permissions/components/AddEditPermissionGroupDialog.vue
  33. 265 53
      src/pages/permissions/components/PermissionGroupDialog.vue
  34. 527 473
      src/pages/students/components/AddEditContractDialog.vue
  35. 596 318
      src/pages/students/components/AddEditStudentDialog.vue
  36. 80 39
      src/pages/students/components/AddStudentMediaDialog.vue
  37. 161 81
      src/pages/students/components/EditStudentDialog.vue
  38. 84 39
      src/pages/students/components/EditStudentMediaDialog.vue
  39. 270 150
      src/pages/students/components/FreezeContractDialog.vue
  40. 367 267
      src/pages/students/components/ResponsibleDialog.vue
  41. 262 97
      src/pages/students/components/ViewContractDialog.vue
  42. 135 59
      src/pages/students/tabs/MediaTab.vue
  43. 125 67
      src/pages/students/tabs/ResponsibleTab.vue
  44. 83 30
      src/pages/support/components/AddEditReplyDialog.vue
  45. 277 172
      src/pages/support/components/AddEditTicketDialog.vue
  46. 63 26
      src/pages/support/components/CloseTicketDialog.vue
  47. 96 56
      src/pages/unit/components/AddEditHistoryDialog.vue
  48. 290 236
      src/pages/unit/components/AddEditPartnerDialog.vue
  49. 61 40
      src/pages/unit/components/AddMediaDialog.vue
  50. 7 7
      src/pages/unit/components/ViewContractDialog.vue
  51. 350 272
      src/pages/unit/tabs/UnitDataTab.vue
  52. 255 88
      src/pages/users/UserActionPage.vue
  53. 143 88
      src/pages/users/components/AddEditUserDialog.vue

+ 85 - 0
src/components/defaults/CustomFileInput.vue

@@ -0,0 +1,85 @@
+<template>
+  <q-file
+    v-model="model"
+    v-bind="attrs"
+    :accept
+    :class="{ 'required-field': required }"
+    :clearable
+    :dense
+    :error="!!error"
+    :error-message="normalizedErrorMessage"
+    :label
+    :outlined
+    :rules
+    color="secondary"
+    hide-bottom-space
+    label-color="secondary"
+    lazy-rules="ondemand"
+    @update:model-value="error = null"
+  >
+    <template #prepend>
+      <slot name="prepend">
+        <q-icon v-if="prependIcon" color="secondary" :name="prependIcon" />
+      </slot>
+    </template>
+
+    <template v-for="(_, slotName) in $slots" #[slotName]="scope">
+      <slot
+        v-if="slotName !== 'prepend'"
+        v-bind="scope ?? {}"
+        :name="slotName"
+      />
+    </template>
+  </q-file>
+</template>
+
+<script setup>
+import { computed, useAttrs } from "vue";
+
+defineOptions({ name: "CustomFileInput", inheritAttrs: false });
+
+const {
+  accept,
+  clearable,
+  dense,
+  errorMessage,
+  label,
+  outlined,
+  prependIcon,
+  rules,
+} = defineProps({
+  accept: { type: String, default: "" },
+  clearable: { type: Boolean, default: false },
+  dense: { type: Boolean, default: false },
+  errorMessage: { type: String, default: undefined },
+  label: { type: String, default: "Arquivo" },
+  outlined: { type: Boolean, default: true },
+  prependIcon: { type: String, default: "mdi-paperclip" },
+  rules: { type: Array, default: () => [] },
+});
+
+const attrs = useAttrs();
+
+const model = defineModel({ type: [File, Array, null] });
+
+const error = defineModel("error", {
+  type: [String, Object, Array, Boolean, null],
+});
+
+const normalizedErrorMessage = computed(() => {
+  if (errorMessage != null) return errorMessage;
+
+  if (error.value == null || typeof error.value === "boolean") return undefined;
+
+  return String(error.value);
+});
+
+const required = computed(() => rules.some((rule) => rule?.$id === "required"));
+</script>
+
+<style scoped>
+:deep(.required-field .q-field__label::after) {
+  color: var(--q-negative);
+  content: " *";
+}
+</style>

+ 30 - 23
src/components/defaults/DefaultCurrencyInput.vue

@@ -3,10 +3,10 @@
     ref="inputRef"
     v-model="formattedValue"
     v-bind="$attrs"
+    :error-message="errorMessage"
+    :input-class="inputClass"
     :label="label"
     :rules="finalRules"
-    :input-class="inputClass"
-    :error-message="errorMessage"
   />
 </template>
 
@@ -22,32 +22,32 @@ const { inputRules } = useInputRules();
 const model = defineModel({ type: Number });
 
 const { options, label, rules, errorMessage } = defineProps({
+  errorMessage: {
+    type: String,
+    default: undefined,
+  },
+  inputClass: {
+    type: String,
+    default: "",
+  },
+  label: {
+    type: String,
+    default: "Valor",
+  },
   options: {
     type: Object,
     default: () => ({
-      locale: "pt-BR",
+      accountingSign: false,
+      autoDecimalDigits: true,
       currency: "BRL",
       currencyDisplay: "symbol",
       hideCurrencySymbolOnFocus: false,
       hideGroupingSeparatorOnFocus: false,
       hideNegligibleDecimalDigitsOnFocus: false,
-      autoDecimalDigits: true,
+      locale: "pt-BR",
       useGrouping: true,
-      accountingSign: false,
     }),
   },
-  label: {
-    type: String,
-    default: "Valor",
-  },
-  errorMessage: {
-    type: String,
-    default: undefined,
-  },
-  inputClass: {
-    type: String,
-    default: "",
-  },
   rules: {
     type: Array,
     default: () => [],
@@ -57,6 +57,19 @@ const { options, label, rules, errorMessage } = defineProps({
 const { inputRef, formattedValue, numberValue, setValue } =
   useCurrencyInput(options);
 
+const minRule = inputRules.minValue(0);
+
+const wrapRule = (rule) => {
+  const wrapped = (value) => rule(numberValue.value, value);
+  if (rule?.$id) wrapped.$id = rule.$id;
+  return wrapped;
+};
+
+const finalRules = computed(() => [
+  ...rules.map(wrapRule),
+  () => minRule(numberValue.value),
+]);
+
 watch(
   () => model.value,
   (newValue) => {
@@ -70,10 +83,4 @@ watch(
     model.value = newValue;
   },
 );
-
-const minRule = inputRules.minValue(0);
-const finalRules = computed(() => [
-  ...rules,
-  () => minRule(numberValue.value),
-]);
 </script>

+ 75 - 59
src/components/defaults/DefaultFilePicker.vue

@@ -11,17 +11,20 @@
       <slot name="label">
         <span>{{ label }}</span>
       </slot>
+
       <span v-if="required" class="text-negative q-ml-xs">*</span>
     </div>
+
     <q-field
       v-model="model"
       v-bind="inputAttrs"
       borderless
+      class="image-preview-container"
       hide-bottom-space
-      :rules="rules"
+      lazy-rules="ondemand"
       :error="error"
       :error-message="errorMessage"
-      class="image-preview-container"
+      :rules="rules"
     >
       <div
         class=""
@@ -39,10 +42,11 @@
                   ? 'mdi-image-plus'
                   : 'mdi-file-plus'
             "
-            size="48px"
-            color="grey-6"
             class="absolute-center"
+            color="grey-6"
+            size="48px"
           />
+
           <div
             class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md"
           >
@@ -58,13 +62,14 @@
 
         <q-img
           v-else-if="type === 'image'"
-          :src="preview"
-          fit="cover"
           class="full-height"
+          fit="cover"
+          :src="preview"
         />
 
         <div v-else class="position-relative column full-height flex-center">
-          <q-icon name="mdi-file-check" size="48px" color="grey-6" />
+          <q-icon color="grey-6" name="mdi-file-check" size="48px" />
+
           <div
             class="absolute-bottom text-caption text-grey-6 text-center q-mb-sm q-px-md"
           >
@@ -74,11 +79,11 @@
 
         <div v-if="preview" class="absolute-top-right q-ma-xs">
           <q-btn
-            flat
-            dense
-            round
             color="negative"
+            dense
+            flat
             icon="mdi-close"
+            round
             @click.stop="clearFile"
           />
         </div>
@@ -96,65 +101,83 @@
 
 <script setup>
 import {
-  ref,
-  watch,
+  computed,
   onUnmounted,
-  useTemplateRef,
+  ref,
   useAttrs,
-  computed,
-  onBeforeMount,
+  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,
+  error,
+  errorMessage,
+  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",
   },
 });
 
 const attrs = useAttrs();
+
 const fileInputRef = useTemplateRef("fileInputRef");
 
-const model = defineModel({ type: [File, String, null], default: null });
+let objectUrl = null;
+
 const base64File = defineModel("base64File", { type: String, default: null });
 
+const model = defineModel({ type: [File, String, null], default: null });
+
 const isDragging = ref(false);
 const preview = ref(initialImage || null);
-const required = ref(false);
 
-let objectUrl = null;
+const inputAttrs = computed(() => {
+  // eslint-disable-next-line
+  const { class: _, style: __, ...rest } = attrs;
+
+  return rest;
+});
+
+const required = computed(() => rules.some((rule) => rule?.$id === "required"));
 
 const cleanupObjectURL = () => {
   if (objectUrl) {
     URL.revokeObjectURL(objectUrl);
+
     objectUrl = null;
   }
 };
@@ -162,30 +185,38 @@ 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);
-};
 
-const pickFile = () => {
-  fileInputRef.value?.pickFiles();
+  reader.readAsDataURL(file);
 };
 
 const clearFile = () => {
   model.value = null;
 };
 
+const pickFile = () => {
+  fileInputRef.value?.pickFiles();
+};
+
 const handleDragOver = (event) => {
   event.preventDefault();
+
   event.dataTransfer.dropEffect = "copy";
+
   isDragging.value = true;
 };
 
@@ -195,15 +226,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;
     }
@@ -219,44 +253,26 @@ const handleDrop = (event) => {
   }
 };
 
-const inputAttrs = computed(() => {
-  // eslint-disable-next-line
-  const { class: _, style: __, ...rest } = attrs;
-  return rest;
-});
-
 watch(model, (newFile) => {
   cleanupObjectURL();
 
   if (newFile && newFile instanceof File) {
     if (type === "image") {
       objectUrl = URL.createObjectURL(newFile);
+
       preview.value = objectUrl;
     } else {
       preview.value = "file_selected";
     }
+
     generateBase64(newFile);
   } else {
     preview.value = initialImage || null;
+
     base64File.value = null;
   }
 });
 
-watch(
-  () => rules,
-  (values) => {
-    values.forEach((r) => {
-      if (r?.$id === "required") return (required.value = true);
-    });
-  },
-);
-
-onBeforeMount(() => {
-  rules.forEach((r) => {
-    if (r?.$id === "required") return (required.value = true);
-  });
-});
-
 onUnmounted(cleanupObjectURL);
 </script>
 

+ 19 - 5
src/components/defaults/DefaultForm.vue

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

+ 75 - 0
src/components/defaults/DefaultImagePicker.vue

@@ -0,0 +1,75 @@
+<template>
+  <div class="default-image-picker">
+    <div class="text-caption text-grey-6 q-mb-xs">{{ label }}</div>
+
+    <CustomFileInput
+      v-model="model"
+      :accept="accept"
+      :clearable="clearable"
+      :dense="dense"
+      :error="error"
+      :error-message="errorMessage"
+      :outlined="outlined"
+      @update:model-value="handleFile"
+    >
+      <template #append>
+        <q-icon name="search" />
+      </template>
+    </CustomFileInput>
+
+    <div class="text-caption text-grey-6 q-mt-md q-mb-xs">
+      Pré - Visualização
+    </div>
+
+    <div class="preview-area flex flex-center">
+      <img v-if="previewUrl" :src="previewUrl" class="preview-image" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onBeforeUnmount, ref } from "vue";
+import CustomFileInput from "./CustomFileInput.vue";
+
+const { accept, clearable, dense, error, errorMessage, label, outlined } =
+  defineProps({
+    accept: { type: String, default: "image/*" },
+    clearable: { type: Boolean, default: true },
+    dense: { type: Boolean, default: true },
+    error: { type: [String, Object, Array, Boolean], default: null },
+    errorMessage: { type: String, default: undefined },
+    label: { type: String, default: "Personalizar" },
+    outlined: { type: Boolean, default: true },
+  });
+
+const model = defineModel({ type: [File, null] });
+const previewModel = defineModel("previewUrl", { type: String, default: null });
+const previewUrl = ref(null);
+
+const handleFile = (file) => {
+  if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
+
+  previewUrl.value = file ? URL.createObjectURL(file) : null;
+
+  previewModel.value = previewUrl.value;
+}
+
+onBeforeUnmount(() => {
+  if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
+});
+</script>
+
+<style scoped>
+.preview-area {
+  min-height: 180px;
+  border: 1px dashed #c7c7c7;
+  border-radius: 8px;
+  background: #fafafa;
+}
+
+.preview-image {
+  max-width: 100%;
+  max-height: 180px;
+  object-fit: contain;
+}
+</style>

+ 53 - 69
src/components/defaults/DefaultInput.vue

@@ -5,22 +5,23 @@
         ref="inputRef"
         v-model="model"
         v-bind="inputAttrs"
+        color="secondary"
         hide-bottom-space
         label-color="secondary"
-        color="secondary"
-        :label
-        :error="!!error"
-        :error-message="normalizedErrorMessage"
-        :rules
-        :outlined
+        lazy-rules="ondemand"
         :bg-color
         :class="[inputClass, { 'required-field': required }]"
+        :error="!!error"
+        :error-message="normalizedErrorMessage"
         :input-class="nativeInputClass"
+        :label
+        :outlined
+        :rules
         @update:model-value="error = null"
       >
         <template #append>
           <slot name="append">
-            <q-icon v-if="icon" :name="icon" size="sm" color="secondary" />
+            <q-icon v-if="icon" color="secondary" size="sm" :name="icon" />
           </slot>
         </template>
       </q-input>
@@ -29,77 +30,70 @@
 </template>
 
 <script setup>
-import {
-  ref,
-  onBeforeMount,
-  useAttrs,
-  computed,
-  watch,
-  useTemplateRef,
-} from "vue";
+import { computed, useAttrs, useTemplateRef } from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
 const {
+  bgColor,
+  errorMessage,
+  icon,
+  inputClass,
   label,
   nativeInputClass,
-  inputClass,
-  rules,
-  icon,
-  bgColor,
   outlined,
-  errorMessage,
+  rules,
 } = defineProps({
-    label: {
-      type: String,
-      default: "",
-    },
-    icon: {
-      type: String,
-      default: "",
-    },
-    rules: {
-      type: Array,
-      default: () => [],
-    },
-    nativeInputClass: {
-      type: String,
-      default: null,
-    },
-    inputClass: {
-      type: String,
-      default: null,
-    },
-    bgColor: {
-      type: String,
-      default: "white",
-    },
-    outlined: {
-      type: Boolean,
-      default: false,
-    },
-    errorMessage: {
-      type: String,
-      default: undefined,
-    },
-  });
+  bgColor: {
+    type: String,
+    default: "white",
+  },
+  errorMessage: {
+    type: String,
+    default: undefined,
+  },
+  icon: {
+    type: String,
+    default: "",
+  },
+  inputClass: {
+    type: String,
+    default: null,
+  },
+  label: {
+    type: String,
+    default: "",
+  },
+  nativeInputClass: {
+    type: String,
+    default: null,
+  },
+  outlined: {
+    type: Boolean,
+    default: false,
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+});
 
 const attrs = useAttrs();
 
 const inputRef = useTemplateRef("inputRef");
 
 const model = defineModel({ type: [String, Object, Array, Boolean, null] });
+
 const error = defineModel("error", {
   type: [String, Object, Array, Boolean, null],
 });
 
-const required = ref(false);
-
 const inputAttrs = computed(() => {
   // eslint-disable-next-line
   const { class: _, style: __, ...rest } = attrs;
+
   return rest;
 });
 
@@ -107,29 +101,19 @@ const normalizedErrorMessage = computed(() => {
   if (errorMessage != null) {
     return errorMessage;
   }
+
   if (error.value == null) {
     return void 0;
   }
+
   if (typeof error.value === "boolean") {
     return void 0;
   }
+
   return String(error.value);
 });
 
-watch(
-  () => rules,
-  (values) => {
-    values.forEach((r) => {
-      if (r?.$id === "required") return (required.value = true);
-    });
-  },
-);
-
-onBeforeMount(() => {
-  rules.forEach((r) => {
-    if (r?.$id === "required") return (required.value = true);
-  });
-});
+const required = computed(() => rules.some((rule) => rule?.$id === "required"));
 
 defineExpose({
   inputRef,

+ 143 - 62
src/components/defaults/DefaultSelect.vue

@@ -1,119 +1,170 @@
 <template>
-  <div class="column" :class="attrs.class" :style="attrs.style">
-    <div class="col">
-      <q-select
-        ref="selectRef"
-        v-model="model"
-        :label
-        label-color="secondary"
-        v-bind="selectAttrs"
-        :error="!!error"
-        :error-message="normalizedErrorMessage"
-        :rules
-        :outlined
-        hide-bottom-space
-        :bg-color
-        :class="[inputClass, { 'required-field': required }]"
-        :popup-content-class="popupContentClass"
-        hide-dropdown-icon
-        @update:model-value="error = null"
+  <div
+    :class="[
+      'default-select',
+      attrs.class,
+    ]"
+    :style="attrs.style"
+  >
+    <q-select
+      ref="selectRef"
+      v-model="model"
+      v-bind="selectAttrs"
+      class="default-select-field"
+      color="secondary"
+      dense
+      hide-bottom-space
+      hide-dropdown-icon
+      input-class="default-select-native"
+      label-color="secondary"
+      lazy-rules="ondemand"
+      :bg-color
+      :class="[
+        inputClass,
+        {
+          'required-field': required,
+        },
+      ]"
+      :error="!!error"
+      :error-message="normalizedErrorMessage"
+      :label
+      :outlined
+      :popup-content-class="popupContentClass"
+      :rules
+      @update:model-value="error = null"
+    >
+      <template
+        v-for="(_, slotName) in $slots"
+        #[slotName]="scope"
       >
-        <template v-for="(_, slotName) in $slots" #[slotName]="scope">
-          <slot :name="slotName" v-bind="scope ?? {}" />
-        </template>
-
-        <template #append>
-          <q-icon :name="dropdownIcon" color="secondary" />
-        </template>
-      </q-select>
-    </div>
+        <slot
+          :name="slotName"
+          v-bind="scope ?? {}"
+        />
+      </template>
+
+      <template #append>
+        <q-icon
+          color="secondary"
+          :name="dropdownIcon"
+        />
+      </template>
+    </q-select>
   </div>
 </template>
 
 <script setup>
-import { ref, onBeforeMount, useAttrs, computed } from "vue";
+import {
+  computed,
+  ref,
+  useAttrs,
+} from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
 const {
-  label,
-  inputClass,
-  popupContentClass,
-  rules,
   bgColor,
-  outlined,
   dropdownIcon,
   errorMessage,
+  inputClass,
+  label,
+  outlined,
+  popupContentClass,
+  rules,
 } = defineProps({
-  label: {
+  bgColor: {
     type: String,
-    default: "",
+    default: "white",
   },
-  rules: {
-    type: Array,
-    default: () => [],
+  dropdownIcon: {
+    type: String,
+    default: "mdi-chevron-down",
   },
-  inputClass: {
+  errorMessage: {
     type: String,
-    default: null,
+    default: undefined,
   },
-  popupContentClass: {
+  inputClass: {
     type: String,
     default: null,
   },
-  bgColor: {
+  label: {
     type: String,
-    default: "white",
+    default: "",
   },
   outlined: {
     type: Boolean,
     default: false,
   },
-  dropdownIcon: {
+  popupContentClass: {
     type: String,
-    default: "mdi-chevron-down",
+    default: null,
   },
-  errorMessage: {
-    type: String,
-    default: undefined,
+  rules: {
+    type: Array,
+    default: () => [],
   },
 });
 
 const attrs = useAttrs();
 
-const model = defineModel({ type: [String, Object, Array, Number, null] });
+const model = defineModel({
+  type: [
+    String,
+    Object,
+    Array,
+    Number,
+    null,
+  ],
+});
+
 const error = defineModel("error", {
-  type: [String, Object, Array, Boolean, null],
+  type: [
+    String,
+    Object,
+    Array,
+    Boolean,
+    null,
+  ],
 });
 
 const selectRef = ref(null);
-const required = ref(false);
-
-const selectAttrs = computed(() => {
-  // eslint-disable-next-line
-  const { class: _, style: __, ...rest } = attrs;
-  return rest;
-});
 
 const normalizedErrorMessage = computed(() => {
   if (errorMessage != null) {
     return errorMessage;
   }
+
   if (error.value == null) {
     return void 0;
   }
+
   if (typeof error.value === "boolean") {
     return void 0;
   }
+
   return String(error.value);
 });
 
-onBeforeMount(() => {
-  rules.forEach((r) => {
-    if (r?.$id === "required") return (required.value = true);
-  });
+const required = computed(() =>
+  rules.some(
+    (rule) => rule?.$id === "required",
+  ),
+);
+
+const selectAttrs = computed(() => {
+  // eslint-disable-next-line
+  const {
+    // eslint-disable-next-line no-unused-vars
+    class: _,
+    // eslint-disable-next-line no-unused-vars
+    style: __,
+    ...rest
+  } = attrs;
+
+  return rest;
 });
 
 defineExpose({
@@ -122,6 +173,36 @@ defineExpose({
 </script>
 
 <style scoped>
+.default-select {
+  min-width: 0;
+}
+
+.default-select[class*="col-"] {
+  flex-grow: 0;
+  flex-shrink: 0;
+}
+
+.default-select-field {
+  min-width: 0;
+  width: 100%;
+}
+
+:deep(.default-select-field .q-field__inner),
+:deep(.default-select-field .q-field__control),
+:deep(.default-select-field .q-field__control-container),
+:deep(.default-select-field .q-field__native),
+:deep(.default-select-field .q-field__input) {
+  min-width: 0;
+  max-width: 100%;
+}
+
+:deep(.default-select-native) {
+  color: var(--q-primary) !important;
+  min-width: 0 !important;
+  max-width: 100% !important;
+  -webkit-text-fill-color: var(--q-primary) !important;
+}
+
 :deep(.q-field--outlined.q-field--rounded .q-field__control) {
   border-radius: 8px;
 }
@@ -130,4 +211,4 @@ defineExpose({
   color: var(--q-negative);
   content: " *";
 }
-</style>
+</style>

+ 245 - 138
src/components/layout/DefaultHeaderPage.vue

@@ -5,9 +5,18 @@
       class="q-mb-xs text-secondary"
       :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
     >
+      <template #separator>
+        <q-icon
+          class="breadcrumb-separator"
+          name="mdi-chevron-right"
+          size="18px"
+        />
+      </template>
+
       <q-breadcrumbs-el
         v-for="crumb in displayBreadcrumbs"
         :key="crumb.name || crumb.label"
+        :icon="crumb.icon"
         :label="crumb.title"
         :to="crumb.name ? { name: crumb.name, params: crumb.params } : null"
       />
@@ -21,16 +30,18 @@
         >
           {{ displayTitle }}
         </span>
+
         <div v-else style="width: 280px">
           <q-skeleton type="text" height="40px" />
         </div>
+
         <q-icon
           v-if="showFilterIcon"
+          class="q-ml-sm cursor-pointer"
           name="mdi-filter-outline"
-          :color="filterOpen ? 'background' : 'primary'"
           size="sm"
-          class="q-ml-sm cursor-pointer"
           :class="filterOpen ? 'bg-primary' : ''"
+          :color="filterOpen ? 'background' : 'primary'"
           :style="
             filterOpen
               ? 'border-radius: 8px; padding: 2px'
@@ -39,10 +50,11 @@
           @click="$emit('show-filter')"
         />
       </div>
+
       <div
         class="flex items-center q-pr-sm"
-        :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
         style="gap: 8px"
+        :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
       >
         <slot name="after" />
 
@@ -51,14 +63,15 @@
           <template v-if="userUnits.length > 1">
             <DefaultSelect
               :model-value="store.selectedUnit"
-              :options="userUnits"
-              option-value="id"
               :option-label="getUnitLabel"
+              :options="userUnits"
               label="Unidade"
+              option-value="id"
               outlined
               style="width: 280px; flex-shrink: 0"
               @update:model-value="store.setSelectedUnit"
             />
+
             <q-spinner
               v-if="isUnitLoading"
               color="primary"
@@ -72,12 +85,13 @@
               class="column"
               style="line-height: 1.2; white-space: nowrap; flex-shrink: 0"
             >
-              <span class="text-caption text-grey-6 text-primary text-center"
-                >Ultimo acesso</span
-              >
-              <span class="text-caption text-primary text-center">{{
-                lastLoginFormatted
-              }}</span>
+              <span class="text-caption text-grey-6 text-primary text-center">
+                Ultimo acesso
+              </span>
+
+              <span class="text-caption text-primary text-center">
+                {{ lastLoginFormatted }}
+              </span>
             </div>
           </template>
 
@@ -94,29 +108,34 @@
                 rounded
                 :label="unreadCount"
               />
+
               <q-menu
                 anchor="bottom right"
+                class="header-menu"
                 self="top right"
                 :offset="[0, 8]"
-                class="header-menu"
                 @before-show="loadNotifications"
               >
                 <div class="notifications-panel">
                   <div class="row items-center justify-between q-px-md q-py-sm">
-                    <span class="text-subtitle2 text-primary text-weight-medium">
+                    <span
+                      class="text-subtitle2 text-primary text-weight-medium"
+                    >
                       Notificações
                     </span>
+
                     <q-btn
-                      flat
+                      color="secondary"
                       dense
+                      flat
+                      label="Marcar tudo como lido"
                       no-caps
                       size="sm"
-                      color="secondary"
-                      label="Marcar tudo como lido"
                       :disable="unreadCount === 0"
                       @click="markAllAsRead"
                     />
                   </div>
+
                   <q-separator />
 
                   <q-scroll-area
@@ -129,17 +148,18 @@
                         v-for="n in notifications"
                         :key="n.id"
                         v-close-popup
-                        clickable
                         :class="!n.is_read ? 'bg-grey-2' : ''"
+                        clickable
                         @click="openNotification(n)"
                       >
                         <q-item-section avatar top>
                           <q-icon
-                            :name="iconFor(n.notification_type)"
                             :color="n.is_read ? 'grey-5' : 'secondary'"
+                            :name="iconFor(n.notification_type)"
                             size="24px"
                           />
                         </q-item-section>
+
                         <q-item-section>
                           <q-item-label
                             class="text-primary"
@@ -147,13 +167,16 @@
                           >
                             {{ n.title }}
                           </q-item-label>
+
                           <q-item-label caption lines="2">
                             {{ n.message }}
                           </q-item-label>
+
                           <q-item-label caption class="text-grey-6 q-mt-xs">
                             {{ formatNotificationDate(n.created_at) }}
                           </q-item-label>
                         </q-item-section>
+
                         <q-item-section v-if="!n.is_read" side top>
                           <q-badge
                             color="negative"
@@ -170,6 +193,7 @@
                     class="column items-center justify-center q-pa-lg text-grey-6"
                   >
                     <q-icon name="mdi-bell-off-outline" size="32px" />
+
                     <span class="text-caption q-mt-sm">Sem notificações</span>
                   </div>
                 </div>
@@ -180,35 +204,41 @@
             <q-btn flat round dense icon="mdi-account" color="secondary">
               <q-menu
                 anchor="bottom right"
+                class="header-menu"
                 self="top right"
                 :offset="[0, 8]"
-                class="header-menu"
               >
                 <div class="profile-panel q-pa-md">
                   <div class="row items-center no-wrap q-gutter-x-md">
                     <q-img
-                      :src="user?.avatar_url || 'icons/user-icon.jpg'"
                       class="avatar-circle"
+                      :src="user?.avatar_url || 'icons/user-icon.jpg'"
                     />
+
                     <div class="column" style="min-width: 0">
-                      <span class="text-body2 text-primary text-weight-medium ellipsis">
+                      <span
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
                         {{ user?.name || "Usuário" }}
                       </span>
+
                       <span class="text-caption text-grey-6 ellipsis">
                         {{ user?.email }}
                       </span>
                     </div>
                   </div>
+
                   <q-separator class="q-my-md" />
+
                   <q-btn
                     v-close-popup
-                    flat
-                    no-caps
                     align="left"
                     class="full-width"
                     color="primary"
+                    flat
                     icon="mdi-account-edit-outline"
                     label="Editar perfil"
+                    no-caps
                     @click="goToEditProfile"
                   />
                 </div>
@@ -216,37 +246,58 @@
             </q-btn>
 
             <!-- Configurações -->
-            <q-btn flat round dense icon="mdi-cog-outline" color="secondary">
+            <q-btn color="secondary" dense flat icon="mdi-cog-outline" round>
               <q-menu
                 anchor="bottom right"
+                class="header-menu"
                 self="top right"
                 :offset="[0, 8]"
-                class="header-menu"
                 @before-show="loadUnitDetails"
               >
                 <div class="settings-panel q-pa-md">
                   <div class="column q-gutter-y-sm">
                     <div>
-                      <span class="text-caption text-grey-6">Nome da Unidade</span>
-                      <div class="text-body2 text-primary text-weight-medium ellipsis">
+                      <span class="text-caption text-grey-6">
+                        Nome da Unidade
+                      </span>
+
+                      <div
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
                         {{ unitInfo.name || "Não informado" }}
                       </div>
                     </div>
+
                     <div>
-                      <span class="text-caption text-grey-6">Nome fantasia</span>
-                      <div class="text-body2 text-primary text-weight-medium ellipsis">
+                      <span class="text-caption text-grey-6">
+                        Nome fantasia
+                      </span>
+
+                      <div
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
                         {{ unitInfo.fantasy_name || "Não informado" }}
                       </div>
                     </div>
+
                     <div>
                       <span class="text-caption text-grey-6">Razão social</span>
-                      <div class="text-body2 text-primary text-weight-medium ellipsis">
+
+                      <div
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
                         {{ unitInfo.social_reason || "Não informado" }}
                       </div>
                     </div>
+
                     <div>
-                      <span class="text-caption text-grey-6">Franqueado Operador</span>
-                      <div class="text-body2 text-primary text-weight-medium ellipsis">
+                      <span class="text-caption text-grey-6">
+                        Franqueado Operador
+                      </span>
+
+                      <div
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
                         {{ unitInfo.name_responsible || "Não informado" }}
                       </div>
                     </div>
@@ -256,24 +307,25 @@
 
                   <q-btn
                     v-close-popup
-                    flat
-                    no-caps
                     align="left"
                     class="full-width"
                     color="primary"
+                    flat
                     icon="mdi-store-edit-outline"
                     label="Editar unidade"
+                    no-caps
                     @click="goToEditUnit"
                   />
+
                   <q-btn
                     v-close-popup
-                    flat
-                    no-caps
                     align="left"
                     class="full-width q-mt-xs"
                     color="negative"
+                    flat
                     icon="mdi-logout"
                     label="Sair"
+                    no-caps
                     @click="logoutFn"
                   />
                 </div>
@@ -283,26 +335,31 @@
         </div>
       </div>
     </div>
+
     <q-separator class="q-my-sm" />
   </div>
 </template>
 
 <script setup>
-import { computed, ref, watch, onMounted, onBeforeUnmount } from "vue";
-import { useRoute, useRouter } from "vue-router";
-import { useI18n } from "vue-i18n";
-import { userStore } from "src/stores/user";
-import { useAuth } from "src/composables/useAuth";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import { getUnitMe } from "src/api/unit";
+import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
 import { formatUnitName } from "src/helpers/utils";
+
 import {
   getMyNotifications,
   getUnreadCount,
   markNotificationRead,
   markAllNotificationsRead,
 } from "src/api/notification";
-import { socket, joinRoom, leaveRoom } from "src/boot/socket.io";
+
+import { getUnitMe } from "src/api/unit";
+import { joinRoom, leaveRoom, socket } from "src/boot/socket.io";
+import { useAuth } from "src/composables/useAuth";
+import { useI18n } from "vue-i18n";
+import { useRoute, useRouter } from "vue-router";
+import { userStore } from "src/stores/user";
+
+
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 const { breadcrumbs, filterOpen, showFilterIcon, title } = defineProps({
   breadcrumbs: {
@@ -331,14 +388,98 @@ const { t } = useI18n();
 const store = userStore();
 const { logout } = useAuth();
 
-const userUnits = computed(() => store.user?.units ?? []);
-const user = computed(() => store.user);
+const isUnitLoading = ref(false);
+
+let loadingTimer = null;
+
 const unitDetails = ref(null);
 const unitInfo = computed(() => unitDetails.value || store.selectedUnit || {});
+
+const user = computed(() => store.user);
+const userUnits = computed(() => store.user?.units ?? []);
+
+const displayBreadcrumbs = computed(() => {
+  const items = breadcrumbs?.length ? breadcrumbs : route.meta?.breadcrumbs;
+
+  return (items || []).map((b) => ({
+    ...b,
+    title: b.translate ? t(b.title) : b.title,
+  }));
+});
+
+const displayTitle = computed(() => {
+  if (title) {
+    if (typeof title === "string") return title;
+
+    if (title.translate) return t(title.value);
+
+    return title.value;
+  } else if (route.meta?.title) {
+    const metaTitle = route.meta.title;
+
+    if (typeof metaTitle === "string") return metaTitle;
+
+    if (metaTitle.translate) return t(metaTitle.value);
+
+    return metaTitle.value;
+  }
+
+  return null;
+});
+
+const lastLoginFormatted = computed(() => {
+  const raw = store.user?.last_login_at;
+
+  if (!raw) return null;
+
+  const d = new Date(raw.replace(" ", "T"));
+
+  return new Intl.DateTimeFormat("pt-BR", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+    timeZone: "America/Fortaleza",
+  }).format(d);
+});
+
+const userRoom = computed(() =>
+  store.user?.id ? `user.${store.user.id}` : null,
+);
+
 const getUnitLabel = (unit) => formatUnitName(unit);
 
+const goToEditProfile = () => {
+  if (!user.value?.id) return;
+
+  router.push({ name: "UserEditPage", params: { id: user.value.id } });
+};
+
+const goToEditUnit = () => {
+  router.push({ name: "UnitDataPage" });
+};
+
+const loadUnitDetails = async () => {
+  try {
+    unitDetails.value = await getUnitMe();
+  } catch (e) {
+    unitDetails.value = store.selectedUnit;
+
+    console.error("Falha ao carregar dados da unidade:", e);
+  }
+};
+
+const logoutFn = async () => {
+  await logout();
+
+  router.push({ name: "LoginPage" });
+};
+
 // ------------------- Notificações (sino) -------------------
+
 const notifications = ref([]);
+
 const unreadCount = ref(0);
 
 const NOTIFICATION_ICONS = {
@@ -352,28 +493,44 @@ const NOTIFICATION_ICONS = {
   payment_credited: "mdi-cash-check",
 };
 
+const formatNotificationDate = (raw) => {
+  if (!raw) return "";
+
+  const d = new Date(String(raw).replace(" ", "T"));
+
+  return new Intl.DateTimeFormat("pt-BR", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+  }).format(d);
+};
+
 const iconFor = (type) => NOTIFICATION_ICONS[type] || "mdi-bell-outline";
 
-const loadUnreadCount = async () => {
+const loadNotifications = async () => {
   try {
-    unreadCount.value = await getUnreadCount();
+    notifications.value = await getMyNotifications();
   } catch (e) {
-    console.error("Falha ao carregar contador de notificações:", e);
+    console.error("Falha ao carregar notificações:", e);
   }
 };
 
-const loadNotifications = async () => {
+const loadUnreadCount = async () => {
   try {
-    notifications.value = await getMyNotifications();
+    unreadCount.value = await getUnreadCount();
   } catch (e) {
-    console.error("Falha ao carregar notificações:", e);
+    console.error("Falha ao carregar contador de notificações:", e);
   }
 };
 
 const markAllAsRead = async () => {
   try {
     await markAllNotificationsRead();
+
     notifications.value.forEach((n) => (n.is_read = true));
+
     unreadCount.value = 0;
   } catch (e) {
     console.error("Falha ao marcar todas como lidas:", e);
@@ -384,7 +541,9 @@ const openNotification = async (n) => {
   if (!n.is_read) {
     try {
       await markNotificationRead(n.id);
+
       n.is_read = true;
+
       unreadCount.value = Math.max(0, unreadCount.value - 1);
     } catch (e) {
       console.error("Falha ao marcar como lida:", e);
@@ -393,117 +552,44 @@ const openNotification = async (n) => {
   if (n.url) router.push(n.url);
 };
 
-const formatNotificationDate = (raw) => {
-  if (!raw) return "";
-  const d = new Date(String(raw).replace(" ", "T"));
-  return new Intl.DateTimeFormat("pt-BR", {
-    day: "2-digit",
-    month: "2-digit",
-    year: "numeric",
-    hour: "2-digit",
-    minute: "2-digit",
-  }).format(d);
-};
-
-const userRoom = computed(() =>
-  store.user?.id ? `user.${store.user.id}` : null,
-);
-
 const onRealtimeNotification = () => {
   loadUnreadCount();
+
   loadNotifications();
 };
 
-onMounted(() => {
-  loadUnreadCount();
-  if (userRoom.value) {
-    joinRoom(userRoom.value);
-    socket.on("notification", onRealtimeNotification);
-  }
-});
-
 onBeforeUnmount(() => {
   if (userRoom.value) {
     socket.off("notification", onRealtimeNotification);
+
     leaveRoom(userRoom.value);
   }
 });
 
-const isUnitLoading = ref(false);
-let loadingTimer = null;
+onMounted(() => {
+  loadUnreadCount();
+
+  if (userRoom.value) {
+    joinRoom(userRoom.value);
+
+    socket.on("notification", onRealtimeNotification);
+  }
+});
 
 watch(
   () => store.selectedUnit?.id,
   (newId, oldId) => {
     if (newId && oldId !== undefined && newId !== oldId) {
       isUnitLoading.value = true;
+
       clearTimeout(loadingTimer);
+
       loadingTimer = setTimeout(() => {
         isUnitLoading.value = false;
       }, 1500);
     }
   },
 );
-
-const lastLoginFormatted = computed(() => {
-  const raw = store.user?.last_login_at;
-  if (!raw) return null;
-  const d = new Date(raw.replace(" ", "T"));
-  return new Intl.DateTimeFormat("pt-BR", {
-    day: "2-digit",
-    month: "2-digit",
-    year: "numeric",
-    hour: "2-digit",
-    minute: "2-digit",
-    timeZone: "America/Fortaleza",
-  }).format(d);
-});
-
-const loadUnitDetails = async () => {
-  try {
-    unitDetails.value = await getUnitMe();
-  } catch (e) {
-    unitDetails.value = store.selectedUnit;
-    console.error("Falha ao carregar dados da unidade:", e);
-  }
-};
-
-const goToEditProfile = () => {
-  if (!user.value?.id) return;
-  router.push({ name: "UserEditPage", params: { id: user.value.id } });
-};
-
-const goToEditUnit = () => {
-  router.push({ name: "UnitDataPage" });
-};
-
-const logoutFn = async () => {
-  await logout();
-  router.push({ name: "LoginPage" });
-};
-
-const displayTitle = computed(() => {
-  if (title) {
-    if (typeof title === "string") return title;
-    if (title.translate) return t(title.value);
-    return title.value;
-  } else if (route.meta?.title) {
-    const metaTitle = route.meta.title;
-    if (typeof metaTitle === "string") return metaTitle;
-    if (metaTitle.translate) return t(metaTitle.value);
-    return metaTitle.value;
-  }
-  return null;
-});
-
-const displayBreadcrumbs = computed(() => {
-  const items = breadcrumbs?.length ? breadcrumbs : route.meta?.breadcrumbs;
-
-  return (items || []).map((b) => ({
-    ...b,
-    title: b.translate ? t(b.title) : b.title,
-  }));
-});
 </script>
 
 <style scoped>
@@ -513,6 +599,7 @@ const displayBreadcrumbs = computed(() => {
 
 .default-header-page :deep(.q-breadcrumbs) {
   display: inline-flex;
+  align-items: center;
   max-width: 100%;
 }
 
@@ -520,12 +607,32 @@ const displayBreadcrumbs = computed(() => {
 .default-header-page :deep(.q-breadcrumbs__separator) {
   flex: 0 0 auto;
   width: auto;
+  display: inline-flex;
+  align-items: center;
+  height: 24px;
+  line-height: 24px;
 }
 
 .default-header-page :deep(.q-breadcrumbs__el) {
   max-width: max-content;
 }
 
+.default-header-page :deep(.q-breadcrumbs__el-icon) {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  margin-top: 0;
+  line-height: 1;
+  vertical-align: middle;
+}
+
+.breadcrumb-separator {
+  align-self: center;
+  line-height: 1;
+}
+
 .header-menu {
   border-radius: 10px;
 }

+ 152 - 124
src/components/layout/LeftMenuLayout.vue

@@ -2,15 +2,15 @@
   <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"
+    :width="214"
     class="detached-container"
+    no-swipe-close
+    no-swipe-open
+    show-if-above
   >
     <div class="column full-height no-wrap q-drawer-container">
       <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
@@ -31,9 +31,12 @@
         <div
           v-if="!$q.screen.lt.md"
           class="toggle-button-wrapper absolute"
-          style="top: 10px; right: -32px; z-index: 1"
+          style="top: 4px; right: -32px; z-index: 1; height: 24px"
         >
-          <div @click="miniState = !miniState">
+          <div
+            class="flex items-center justify-center full-height"
+            @click="miniState = !miniState"
+          >
             <q-icon
               size="sm"
               :name="
@@ -55,38 +58,28 @@
             >
           </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="secondary"
-                  />
-                </q-item-section>
-                <q-item-section>{{ menuLabel(item.title) }}</q-item-section>
-                <q-tooltip
-                  v-if="miniState"
-                  anchor="center right"
-                  self="center left"
-                  :offset="[10, 10]"
-                  >{{ menuLabel(item.title) }}</q-tooltip
+        <q-scroll-area class="menu-scroll col" bar-color="secondary">
+          <q-list class="column no-wrap">
+            <template v-for="(item, index) in navigation_store.navigationItems">
+              <template v-if="item.permission">
+                <q-item
+                  v-if="item.type === 'single'"
+                  :key="item.name"
+                  v-ripple
+                  clickable
+                  :exact="item.name == 'HomePage'"
+                  exact-active-class="menu-selected"
+                  active-class="menu-selected"
+                  :to="{ name: item.name }"
                 >
-              </q-item>
-              <!-- Expansive Menu with children -->
-              <div v-else :key="item.title">
-                <template v-if="!miniState">
+                  <q-item-section avatar>
+                    <q-icon
+                      :name="item.icon"
+                      style="font-size: 20px"
+                      color="secondary"
+                    />
+                  </q-item-section>
+                  <q-item-section>{{ menuLabel(item.title) }}</q-item-section>
                   <q-tooltip
                     v-if="miniState"
                     anchor="center right"
@@ -94,70 +87,10 @@
                     :offset="[10, 10]"
                     >{{ menuLabel(item.title) }}</q-tooltip
                   >
-                  <q-expansion-item
-                    v-model="isExpasionItemExpanded[index]"
-                    :class="{
-                      'menu-selected':
-                        childrenAreActive(item.childrens) &&
-                        !isExpasionItemExpanded[index],
-                    }"
-                  >
-                    <template #header>
-                      <q-item-section avatar>
-                        <q-icon
-                          :name="item.icon"
-                          style="font-size: 20px"
-                          color="secondary"
-                        />
-                      </q-item-section>
-                      <q-item-section>{{ menuLabel(item.title) }}</q-item-section>
-                    </template>
-                    <div
-                      v-for="child in item.childrens"
-                      :key="child.name"
-                      :class="{ 'financial-submenu': item.title === 'Financeiro' }"
-                    >
-                      <q-item
-                        v-ripple
-                        clickable
-                        :to="{ name: child.name }"
-                        exact
-                        exact-active-class="menu-selected"
-                        class="q-pl-lg"
-                      >
-                        <q-item-section avatar>
-                          <q-icon :name="child.icon" style="font-size: 20px" />
-                        </q-item-section>
-                        <q-item-section>{{ menuLabel(child.title) }}</q-item-section>
-                        <q-tooltip
-                          v-if="miniState"
-                          anchor="center right"
-                          self="center left"
-                          :offset="[10, 10]"
-                          >{{ menuLabel(child.title) }}</q-tooltip
-                        >
-                      </q-item>
-                    </div>
-                  </q-expansion-item>
-                </template>
-                <template v-else>
-                  <q-item
-                    v-ripple
-                    clickable
-                    exact
-                    exact-active-class="menu-selected"
-                    :class="{
-                      'menu-selected': childrenAreActive(item.childrens),
-                    }"
-                  >
-                    <q-item-section avatar>
-                      <q-icon
-                        :name="item.icon"
-                        style="font-size: 20px"
-                        color="secondary"
-                      />
-                    </q-item-section>
-                    <q-item-section>{{ menuLabel(item.title) }}</q-item-section>
+                </q-item>
+                <!-- Expansive Menu with children -->
+                <div v-else :key="item.title">
+                  <template v-if="!miniState">
                     <q-tooltip
                       v-if="miniState"
                       anchor="center right"
@@ -165,22 +98,40 @@
                       :offset="[10, 10]"
                       >{{ menuLabel(item.title) }}</q-tooltip
                     >
-                    <q-menu
-                      class="menu-drawer"
-                      anchor="center right"
-                      self="top start"
+                    <q-expansion-item
+                      v-model="isExpasionItemExpanded[index]"
+                      :class="{
+                        'menu-selected':
+                          childrenAreActive(item.childrens) &&
+                          !isExpasionItemExpanded[index],
+                      }"
                     >
-                      <q-list>
+                      <template #header>
+                        <q-item-section avatar>
+                          <q-icon
+                            :name="item.icon"
+                            style="font-size: 20px"
+                            color="secondary"
+                          />
+                        </q-item-section>
+                        <q-item-section>{{
+                          menuLabel(item.title)
+                        }}</q-item-section>
+                      </template>
+                      <div
+                        v-for="child in item.childrens"
+                        :key="child.name"
+                        :class="{
+                          'financial-submenu': item.title === 'Financeiro',
+                        }"
+                      >
                         <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"
+                          class="q-pl-lg"
                         >
                           <q-item-section avatar>
                             <q-icon
@@ -188,16 +139,83 @@
                               style="font-size: 20px"
                             />
                           </q-item-section>
-                          <q-item-section>{{ menuLabel(child.title) }}</q-item-section>
+                          <q-item-section>{{
+                            menuLabel(child.title)
+                          }}</q-item-section>
+                          <q-tooltip
+                            v-if="miniState"
+                            anchor="center right"
+                            self="center left"
+                            :offset="[10, 10]"
+                            >{{ menuLabel(child.title) }}</q-tooltip
+                          >
                         </q-item>
-                      </q-list>
-                    </q-menu>
-                  </q-item>
-                </template>
-              </div>
+                      </div>
+                    </q-expansion-item>
+                  </template>
+                  <template v-else>
+                    <q-item
+                      v-ripple
+                      clickable
+                      exact
+                      exact-active-class="menu-selected"
+                      :class="{
+                        'menu-selected': childrenAreActive(item.childrens),
+                      }"
+                    >
+                      <q-item-section avatar>
+                        <q-icon
+                          :name="item.icon"
+                          style="font-size: 20px"
+                          color="secondary"
+                        />
+                      </q-item-section>
+                      <q-item-section>{{
+                        menuLabel(item.title)
+                      }}</q-item-section>
+                      <q-tooltip
+                        v-if="miniState"
+                        anchor="center right"
+                        self="center left"
+                        :offset="[10, 10]"
+                        >{{ menuLabel(item.title) }}</q-tooltip
+                      >
+                      <q-menu
+                        class="menu-drawer"
+                        anchor="center right"
+                        self="top start"
+                      >
+                        <q-list>
+                          <q-item
+                            v-for="child in item.childrens"
+                            :key="child.name"
+                            v-ripple
+                            v-close-popup
+                            clickable
+                            :to="{ name: child.name }"
+                            exact
+                            exact-active-class="menu-selected"
+                            class="menu-drawer"
+                          >
+                            <q-item-section avatar>
+                              <q-icon
+                                :name="child.icon"
+                                style="font-size: 20px"
+                              />
+                            </q-item-section>
+                            <q-item-section>{{
+                              menuLabel(child.title)
+                            }}</q-item-section>
+                          </q-item>
+                        </q-list>
+                      </q-menu>
+                    </q-item>
+                  </template>
+                </div>
+              </template>
             </template>
-          </template>
-        </q-list>
+          </q-list>
+        </q-scroll-area>
         <q-list class="column q-mb-md no-wrap" style="border-radius: 6px">
         </q-list>
         <q-list class="q-mt-auto">
@@ -257,13 +275,13 @@
   </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 { navigationStore } from "src/stores/navigation";
+import { onMounted, ref, watch, watchEffect } from "vue";
+import { useAuth } from "src/composables/useAuth";
+import { useI18n } from "vue-i18n";
 import { useQuasar, Cookies } from "quasar";
+import { useRouter, useRoute } from "vue-router";
 import { version } from "src/../package.json";
-import { useI18n } from "vue-i18n";
 
 import Logo from "src/assets/images/logo.svg";
 import MiniLogo from "src/assets/images/mini-logo.svg";
@@ -355,4 +373,14 @@ onMounted(() => {
   background-image: url("/images/background-opacity.png");
   background-position: 15%;
 }
+
+.menu-scroll {
+  min-height: 0;
+}
+
+.menu-scroll :deep(.q-scrollarea__bar--v) {
+  width: 6px;
+  right: 2px;
+  border-radius: 6px;
+}
 </style>

+ 68 - 56
src/components/layout/LeftMenuLayoutMobile.vue

@@ -10,73 +10,75 @@
   >
     <div class="column full-height no-wrap">
       <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
-        <div
-          class="flex flex-center full-width q-pa-sm"
-          style="height: 50px"
-        >
+        <div class="flex flex-center full-width q-pa-sm" style="height: 50px">
           <q-img :src="Logo" style="max-width: 92px" />
         </div>
       </div>
 
       <div class="column full-height no-wrap">
-        <q-list class="column no-wrap">
-          <template v-for="item in navigation_store.navigationItems">
-            <template v-if="item.permission">
-              <!-- Single Menu -->
-              <q-item
-                v-if="item.type === 'single'"
-                :key="item.name"
-                v-ripple
-                clickable
-                exact-active-class="menu-selected"
-                active-class="menu-selected"
-                :exact="item.name == 'HomePage'"
-                :to="{ name: item.name }"
-              >
-                <q-item-section avatar>
-                  <q-icon :name="item.icon" style="font-size: 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>
+        <q-scroll-area class="menu-scroll col" bar-color="secondary">
+          <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>
-                </template>
-                <div
-                  v-for="child in item.childrens"
-                  :key="child.name"
-                  :class="{ 'financial-submenu': item.title === 'Financeiro' }"
+                </q-item>
+                <!-- Expansive Menu with children -->
+                <q-expansion-item
+                  v-else
+                  :key="item.icon"
+                  v-model="isExpasionItemExpanded"
+                  :class="{
+                    'menu-selected':
+                      childrenAreActive(item.children) &&
+                      !isExpasionItemExpanded,
+                  }"
                 >
-                  <q-item
-                    v-ripple
-                    clickable
-                    :to="{ name: child.name }"
-                    exact
-                    exact-active-class="menu-selected"
-                    class="q-pl-lg"
-                  >
+                  <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"
+                    :class="{
+                      'financial-submenu': item.title === 'Financeiro',
+                    }"
+                  >
+                    <q-item
+                      v-ripple
+                      clickable
+                      :to="{ name: child.name }"
+                      exact
+                      exact-active-class="menu-selected"
+                      class="q-pl-lg"
+                    >
+                      <q-item-section avatar>
+                        <q-icon :name="child.icon" style="font-size: 20px" />
+                      </q-item-section>
+                      <q-item-section>{{ $t(child.title) }}</q-item-section>
+                    </q-item>
+                  </div>
+                </q-expansion-item>
+              </template>
             </template>
-          </template>
-        </q-list>
+          </q-list>
+        </q-scroll-area>
 
         <q-list class="q-mt-auto">
           <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
@@ -102,9 +104,9 @@
 </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";
@@ -162,4 +164,14 @@ onMounted(() => {
   background-color: rgba($secondary, 0.14);
   color: $secondary;
 }
+
+.menu-scroll {
+  min-height: 0;
+}
+
+.menu-scroll :deep(.q-scrollarea__bar--v) {
+  width: 6px;
+  right: 2px;
+  border-radius: 6px;
+}
 </style>

+ 164 - 85
src/components/selects/CitySelect.vue

@@ -1,160 +1,239 @@
 <template>
-  <DefaultSelect
-    v-model="selectedCity"
-    v-bind="$attrs"
-    use-input
-    hide-selected
-    fill-input
-    clearable
-    :options="cityOptions"
-    :label
-    :loading
-    :placeholder
-    @filter="filterFn"
+  <div
+    :class="attrs.class"
+    :style="attrs.style"
   >
-    <template #no-option>
-      <q-item>
-        <q-item-section class="text-grey">
-          {{ $t("http.errors.no_records_found") }}
-        </q-item-section>
-      </q-item>
-    </template>
-  </DefaultSelect>
+    <DefaultSelect
+      v-model="selectedCity"
+      v-bind="selectAttrs"
+      class="full-width"
+      clearable
+      fill-input
+      hide-selected
+      stack-label
+      use-input
+      :label="label"
+      :loading="loading"
+      :options="cityOptions"
+      :placeholder="placeholder"
+      @filter="filterFn"
+    >
+      <template #no-option>
+        <q-item>
+          <q-item-section class="text-grey">
+            {{ $t("http.errors.no_records_found") }}
+          </q-item-section>
+        </q-item>
+      </template>
+    </DefaultSelect>
+  </div>
 </template>
 
 <script setup>
+import {
+  computed,
+  onMounted,
+  ref,
+  useAttrs,
+  watch,
+} from "vue";
+
 import { getCities } from "src/api/city";
-import { ref, onMounted, watch } from "vue";
 import { normalizeString } from "src/helpers/utils";
 import { useI18n } from "vue-i18n";
+
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const emit = defineEmits(["selectedStateId"]);
+defineOptions({
+  inheritAttrs: false,
+});
+
+const emit = defineEmits([
+  "selectedStateId",
+]);
 
-const { state, label, initialId, country, placeholder } = defineProps({
-  // This country prop is here for future use, maybe
+const {
+  country,
+  initialId,
+  label,
+  placeholder,
+  state,
+} = defineProps({
   country: {
     type: Object,
-    required: false,
-    default: () => {
-      return {
-        label: "Brasil",
-        value: 1,
-      };
-    },
+    default: () => ({
+      label: "Brasil",
+      value: 1,
+    }),
   },
-  state: {
-    type: Object,
-    required: false,
+  initialId: {
+    type: Number,
     default: null,
   },
   label: {
     type: String,
-    default: () => useI18n().t("ui.navigation.city"),
+    default: () =>
+      useI18n().t("ui.navigation.city"),
   },
   placeholder: {
     type: String,
     default: () =>
-      useI18n().t("common.actions.search") +
-      " " +
-      useI18n().t("ui.navigation.city"),
+      `${useI18n().t("common.actions.search")} ${useI18n().t(
+        "ui.navigation.city",
+      )}`,
   },
-  initialId: {
-    type: Number,
-    required: false,
+  state: {
+    type: Object,
     default: null,
   },
 });
 
-const selectedCity = defineModel({ type: Object });
+const attrs = useAttrs();
+
+const selectedCity = defineModel({
+  type: Object,
+});
 
-const loading = ref(true);
 const baseOptions = ref([]);
 const cityOptions = ref([]);
+const loading = ref(true);
 
-const filterFn = async (val, update) => {
-  ensureOnlyPossibleOptions(country?.value, state?.value);
-  const needle = normalizeString(val);
-  cityOptions.value = cityOptions.value.filter((v) => {
-    return (
-      normalizeString(v.label).includes(needle) ||
-      normalizeString(v.code).includes(needle)
-    );
-  });
-  update();
-};
+const selectAttrs = computed(() => {
+  const {
+    // eslint-disable-next-line no-unused-vars
+    class: _,
+    // eslint-disable-next-line no-unused-vars
+    style: __,
+    ...rest
+  } = attrs;
+
+  return rest;
+});
+
+const ensureOnlyPossibleOptions = (
+  countryId,
+  stateId,
+) => {
+  if (!stateId) {
+    cityOptions.value = baseOptions.value;
 
-const selectCityByName = (name) => {
-  if (selectedCity.value?.label === name) {
     return;
   }
-  selectedCity.value = baseOptions.value.find((city) => city.label === name);
+
+  cityOptions.value = baseOptions.value.filter(
+    (city) =>
+      city.state_id === stateId &&
+      (!countryId || city.country_id === countryId),
+  );
+};
+
+const filterFn = (value, update) => {
+  update(() => {
+    ensureOnlyPossibleOptions(
+      country?.value,
+      state?.value,
+    );
+
+    const needle = normalizeString(value);
+
+    cityOptions.value = cityOptions.value.filter(
+      (city) =>
+        normalizeString(city.label).includes(needle) ||
+        normalizeString(city.code ?? "").includes(needle),
+    );
+  });
 };
 
 const selectCityById = (id) => {
   if (selectedCity.value?.value === id) {
     return;
   }
-  selectedCity.value = baseOptions.value.find((city) => city.value === id);
+
+  selectedCity.value =
+    baseOptions.value.find(
+      (city) => city.value === id,
+    ) ?? null;
 };
 
-const ensureOnlyPossibleOptions = (country_id, state_id) => {
-  if (state_id) {
-    cityOptions.value = baseOptions.value.filter((city) => {
-      if (country_id) {
-        return city.country_id === country_id && city.state_id === state_id;
-      }
-      return city.state_id === state_id;
-    });
-  }
-  if (!!state_id && !country_id) {
-    cityOptions.value = baseOptions.value;
+const selectCityByName = (name) => {
+  if (selectedCity.value?.label === name) {
+    return;
   }
+
+  selectedCity.value =
+    baseOptions.value.find(
+      (city) => city.label === name,
+    ) ?? null;
 };
 
 watch(
   () => state,
   (value, oldValue) => {
     if (
-      value?.value != oldValue?.value &&
-      value?.value != selectedCity.value?.state_id
+      value?.value !== oldValue?.value &&
+      value?.value !== selectedCity.value?.state_id
     ) {
       selectedCity.value = null;
     }
-    if (value) {
-      ensureOnlyPossibleOptions(country?.value, value.value);
-    }
+
+    ensureOnlyPossibleOptions(
+      country?.value,
+      value?.value,
+    );
+  },
+  {
+    immediate: true,
   },
-  { immediate: true },
 );
 
-watch(selectedCity, () => {
-  if (selectedCity.value?.state_id) {
-    emit("selectedStateId", selectedCity.value.state_id);
+watch(selectedCity, (city) => {
+  if (city?.state_id) {
+    emit(
+      "selectedStateId",
+      city.state_id,
+    );
   }
 });
 
 onMounted(async () => {
   try {
-    const baseCities = await getCities();
-    baseOptions.value = baseCities.map((city) => ({
+    const cities = await getCities();
+
+    baseOptions.value = cities.map((city) => ({
+      country_id: city.country_id,
       label: city.name,
-      value: city.id,
       state_id: city.state_id,
+      value: city.id,
     }));
-    cityOptions.value = baseOptions.value;
+
+    ensureOnlyPossibleOptions(
+      country?.value,
+      state?.value,
+    );
+
     if (initialId) {
       selectCityById(initialId);
     }
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   } finally {
     loading.value = false;
   }
 });
 
 defineExpose({
-  selectCityByName,
   selectCityById,
+  selectCityByName,
 });
 </script>
+
+<style scoped>
+div {
+  min-width: 0;
+}
+
+:deep(.q-field) {
+  min-width: 0;
+  width: 100%;
+}
+</style>

+ 159 - 78
src/components/selects/StateSelect.vue

@@ -1,151 +1,232 @@
 <template>
-  <DefaultSelect
-    v-model="selectedState"
-    v-bind="$attrs"
-    use-input
-    hide-selected
-    fill-input
-    clearable
-    :options="stateOptions"
-    :label
-    :loading
-    :placeholder
-    @filter="filterFn"
+  <div
+    :class="attrs.class"
+    :style="attrs.style"
   >
-    <template #no-option>
-      <q-item>
-        <q-item-section class="text-grey">
-          {{ $t("http.errors.no_records_found") }}
-        </q-item-section>
-      </q-item>
-    </template>
-  </DefaultSelect>
+    <DefaultSelect
+      v-model="selectedState"
+      v-bind="selectAttrs"
+      class="full-width"
+      clearable
+      fill-input
+      hide-selected
+      stack-label
+      use-input
+      :label="label"
+      :loading="loading"
+      :options="stateOptions"
+      :placeholder="placeholder"
+      @filter="filterFn"
+    >
+      <template #no-option>
+        <q-item>
+          <q-item-section class="text-grey">
+            {{ $t("http.errors.no_records_found") }}
+          </q-item-section>
+        </q-item>
+      </template>
+    </DefaultSelect>
+  </div>
 </template>
 
 <script setup>
+import {
+  computed,
+  onMounted,
+  ref,
+  useAttrs,
+  watch,
+} from "vue";
+
 import { getStates } from "src/api/state";
-import { ref, onMounted, watch } from "vue";
 import { normalizeString } from "src/helpers/utils";
 import { useI18n } from "vue-i18n";
+
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const emit = defineEmits(["selectedCountryId"]);
+defineOptions({
+  inheritAttrs: false,
+});
+
+const emit = defineEmits([
+  "selectedCountryId",
+]);
 
-const { country, initialId, placeholder } = defineProps({
+const {
+  country,
+  initialId,
+  label,
+  placeholder,
+} = defineProps({
   country: {
     type: Object,
-    required: false,
     default: () => ({
       label: "Brasil",
       value: 1,
     }),
   },
-  placeholder: {
+  initialId: {
+    type: Number,
+    default: null,
+  },
+  label: {
     type: String,
     default: () =>
-      useI18n().t("common.actions.search") +
-      " " +
       useI18n().t("ui.navigation.state"),
   },
-  label: {
+  placeholder: {
     type: String,
-    default: () => useI18n().t("ui.navigation.state"),
-  },
-  initialId: {
-    type: Number,
-    required: false,
-    default: null,
+    default: () =>
+      `${useI18n().t("common.actions.search")} ${useI18n().t(
+        "ui.navigation.state",
+      )}`,
   },
 });
 
-const selectedState = defineModel({ type: Object });
+const attrs = useAttrs();
+
+const selectedState = defineModel({
+  type: Object,
+});
 
-const loading = ref(true);
 const baseOptions = ref([]);
+const loading = ref(true);
 const stateOptions = ref([]);
 
-const filterFn = (val, update) => {
-  ensureOnlyPossibleOptions(country?.value);
-  const needle = normalizeString(val);
-  stateOptions.value = stateOptions.value.filter((v) => {
-    return (
-      normalizeString(v.label).includes(needle) ||
-      normalizeString(v.code).includes(needle)
+const selectAttrs = computed(() => {
+  const {
+    // eslint-disable-next-line no-unused-vars
+    class: _,
+    // eslint-disable-next-line no-unused-vars
+    style: __,
+    ...rest
+  } = attrs;
+
+  return rest;
+});
+
+const ensureOnlyPossibleOptions = (countryId) => {
+  if (!countryId) {
+    stateOptions.value = baseOptions.value;
+
+    return;
+  }
+
+  stateOptions.value = baseOptions.value.filter(
+    (state) => state.country_id === countryId,
+  );
+};
+
+const filterFn = (value, update) => {
+  update(() => {
+    ensureOnlyPossibleOptions(country?.value);
+
+    const needle = normalizeString(value);
+
+    stateOptions.value = stateOptions.value.filter(
+      (state) =>
+        normalizeString(state.label).includes(needle) ||
+        normalizeString(state.code ?? "").includes(needle),
     );
   });
-  update();
 };
 
-const selectStateById = async (id) => {
-  if (selectedState.value?.value === id) return;
-  selectedState.value = baseOptions.value.find((state) => state.value === id);
-};
+const selectStateByCode = (code) => {
+  if (selectedState.value?.code === code) {
+    return;
+  }
 
-const selectStateByName = (name) => {
-  if (selectedState.value?.label === name) return;
-  selectedState.value = baseOptions.value.find((state) => state.label === name);
+  selectedState.value =
+    baseOptions.value.find(
+      (state) => state.code === code,
+    ) ?? null;
 };
 
-const selectStateByCode = (code) => {
-  if (selectedState.value?.code === code) return;
-  selectedState.value = baseOptions.value.find((state) => state.code === code);
+const selectStateById = (id) => {
+  if (selectedState.value?.value === id) {
+    return;
+  }
+
+  selectedState.value =
+    baseOptions.value.find(
+      (state) => state.value === id,
+    ) ?? null;
 };
 
-const ensureOnlyPossibleOptions = (country_id) => {
-  if (country_id) {
-    stateOptions.value = baseOptions.value.filter(
-      (state) => state.country_id === country_id,
-    );
-  } else {
-    stateOptions.value = baseOptions.value;
+const selectStateByName = (name) => {
+  if (selectedState.value?.label === name) {
+    return;
   }
+
+  selectedState.value =
+    baseOptions.value.find(
+      (state) => state.label === name,
+    ) ?? null;
 };
 
 watch(
   () => country,
   (value, oldValue) => {
     if (
-      value?.value != oldValue?.value &&
-      value?.value != selectedState.value?.country_id
+      value?.value !== oldValue?.value &&
+      value?.value !== selectedState.value?.country_id
     ) {
       selectedState.value = null;
     }
-    if (value) {
-      ensureOnlyPossibleOptions(value.value);
-    }
+
+    ensureOnlyPossibleOptions(value?.value);
+  },
+  {
+    immediate: true,
   },
-  { immediate: true },
 );
 
-watch(selectedState, () => {
-  if (selectedState.value?.country_id) {
-    emit("selectedCountryId", selectedState.value.country_id);
+watch(selectedState, (state) => {
+  if (state?.country_id) {
+    emit(
+      "selectedCountryId",
+      state.country_id,
+    );
   }
 });
 
 onMounted(async () => {
   try {
-    const baseStates = await getStates();
-    baseOptions.value = baseStates.map((state) => ({
-      label: state.name,
-      value: state.id,
+    const states = await getStates();
+
+    baseOptions.value = states.map((state) => ({
       code: state.code,
       country_id: state.country_id,
+      label: state.name,
+      value: state.id,
     }));
-    stateOptions.value = baseOptions.value;
+
+    ensureOnlyPossibleOptions(country?.value);
+
     if (initialId) {
       selectStateById(initialId);
     }
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   } finally {
     loading.value = false;
   }
 });
 
 defineExpose({
+  selectStateByCode,
   selectStateById,
   selectStateByName,
-  selectStateByCode,
 });
 </script>
+
+<style scoped>
+div {
+  min-width: 0;
+}
+
+:deep(.q-field) {
+  min-width: 0;
+  width: 100%;
+}
+</style>

+ 5 - 30
src/components/shared/ChangeImageDialog.vue

@@ -6,25 +6,10 @@
       <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
         <q-card-section class="q-pt-none q-pb-sm">
           <div class="text-caption text-grey-6 q-mb-xs">Personalizar</div>
-          <q-file
+          <DefaultImagePicker
             v-model="selectedFile"
-            accept="image/*"
-            outlined
-            dense
-            placeholder="Buscar no Desktop"
-            @update:model-value="onFileSelected"
-          >
-            <template #append>
-              <q-icon name="search" />
-            </template>
-          </q-file>
-        </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>
+            v-model:preview-url="previewUrl"
+          />
         </q-card-section>
       </q-scroll-area>
 
@@ -49,7 +34,9 @@
 <script setup>
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultImagePicker from "src/components/defaults/DefaultImagePicker.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -59,18 +46,6 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
 const selectedFile = ref(null);
 const previewUrl = 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 });
 }

+ 74 - 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,78 +37,40 @@ 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;
   }
@@ -153,6 +100,7 @@ function buildFormData(formData, data, parentKey = "") {
   }
 
   let valueToAppend = data;
+
   if (data instanceof Date) {
     valueToAppend = data.toISOString().slice(0, 19).replace("T", " ");
   }
@@ -160,16 +108,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 +122,44 @@ 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;
+}

+ 98 - 44
src/composables/useInputRules.js

@@ -3,110 +3,164 @@ import { useI18n } from "vue-i18n";
 export const useInputRules = () => {
   const { t } = useI18n();
 
+  const hasValue = (value) => {
+    if (value === null || value === undefined) return false;
+    if (typeof value === "string") return value.trim().length > 0;
+    if (Array.isArray(value)) return value.length > 0;
+
+    return true;
+  };
+
+  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) => hasValue(value) || t("validation.rules.required"),
+    requiredHideMessage: (value) => !!value,
+    requiredNumber: (value) => !isNaN(value) || t("validation.rules.required"),
   };
 
-  inputRules.required.$id = 'required'
+  inputRules.required.$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,

+ 10 - 9
src/css/app.scss

@@ -1,6 +1,6 @@
 @use "sass:map";
 @use "src/css/quasar.variables.scss";
-@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Rethink+Sans:ital,wght@0,400..800;1,400..800&display=swap');
+@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Rethink+Sans:ital,wght@0,400..800;1,400..800&display=swap");
 
 body {
   font-family: "Inter", sans-serif;
@@ -73,7 +73,6 @@ body.body--light {
   }
 }
 
-
 .q-card__actions .q-btn {
   padding: 10px 16px;
 }
@@ -101,10 +100,7 @@ body.body--light {
 .dialog-form-card > .dialog-form-scroll,
 .dialog-form-card > .q-form > .dialog-form-scroll {
   flex: 1 1 auto;
-  height: min(
-    var(--dialog-form-scroll-height, 65vh),
-    calc(100vh - 160px)
-  );
+  height: min(var(--dialog-form-scroll-height, 65vh), calc(100vh - 160px));
   min-height: 0;
   overflow: hidden;
 }
@@ -121,9 +117,15 @@ body.body--light {
   --dialog-form-scroll-height: 440px;
 }
 
-.dialog-form-card > .q-card__actions,
-.dialog-form-card > .q-form > .q-card__actions {
+.q-dialog .q-card__actions {
   flex: 0 0 auto;
+  padding: 16px !important;
+  border-top: 0 !important;
+  box-shadow: none !important;
+}
+
+.q-dialog .q-card__section {
+  padding: 16px !important;
 }
 
 input[type="number"]::-webkit-inner-spin-button,
@@ -156,7 +158,6 @@ input[type="number"]::-webkit-outer-spin-button {
   background: #fff !important;
 }
 
-
 .q-field--standout.q-field--rounded .q-field__control {
   border-radius: 8px;
   box-shadow: 0 0 0 1px #c0c0c0c0;

+ 301 - 161
src/pages/classes/components/AddEditClassDialog.vue

@@ -8,117 +8,155 @@
         />
 
         <DefaultForm ref="formRef" @submit="onSubmit">
-          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--md">
+          <q-scroll-area
+            ref="scrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--md"
+          >
             <q-card-section class="q-pt-sm">
-            <div class="row q-col-gutter-sm">
-              <!-- Nome da Atividade -->
-              <DefaultInput
-                v-model="form.title"
-                label="Nome da Atividade"
-                class="col-12"
-                :rules="[(v) => !!v || 'Campo obrigatório']"
-              />
-
-              <!-- Pacote vinculado -->
-              <DefaultSelect
-                v-model="form.class_package_unit_id"
-                label="Pacote relacionado"
-                :options="packageOptions"
-                option-value="id"
-                option-label="name"
-                emit-value
-                map-options
-                :loading="loadingPackages"
-                class="col-12"
-                :rules="[(v) => !!v || 'Campo obrigatório']"
-              />
-
-              <!-- Horários -->
-              <DefaultInput
-                v-model="form.start_time"
-                label="Hora de Início"
-                mask="##:##"
-                placeholder="08:40"
-                class="col-6"
-                :rules="[(v) => !!v || 'Campo obrigatório']"
-              >
-                <template #append>
-                  <q-icon name="mdi-clock-outline" class="cursor-pointer" color="secondary">
-                    <q-popup-proxy transition-show="scale" transition-hide="scale">
-                      <q-time v-model="form.start_time" format24h mask="HH:mm">
-                        <div class="row items-center justify-end">
-                          <q-btn v-close-popup label="OK" color="primary" flat />
-                        </div>
-                      </q-time>
-                    </q-popup-proxy>
-                  </q-icon>
-                </template>
-              </DefaultInput>
-
-              <DefaultInput
-                v-model="form.end_time"
-                label="Hora de Fim"
-                mask="##:##"
-                placeholder="09:40"
-                class="col-6"
-                :rules="[
-                  (v) => !!v || 'Campo obrigatório',
-                  (v) => !form.start_time || v > form.start_time || 'Fim deve ser após o início',
-                ]"
-              >
-                <template #append>
-                  <q-icon name="mdi-clock-outline" class="cursor-pointer" color="secondary">
-                    <q-popup-proxy transition-show="scale" transition-hide="scale">
-                      <q-time v-model="form.end_time" format24h mask="HH:mm">
-                        <div class="row items-center justify-end">
-                          <q-btn v-close-popup label="OK" color="primary" flat />
-                        </div>
-                      </q-time>
-                    </q-popup-proxy>
-                  </q-icon>
-                </template>
-              </DefaultInput>
-
-              <!-- Instrutor (Neurotrainer da unidade) -->
-              <DefaultSelect
-                v-model="form.instructor"
-                label="Neurotrainer"
-                :options="instructorOptions"
-                option-value="name"
-                option-label="name"
-                emit-value
-                map-options
-                clearable
-                :loading="loadingInstructors"
-                class="col-6"
-              />
-
-              <!-- Sala -->
-              <DefaultInput
-                v-model="form.room"
-                label="Número da Sala"
-                class="col-6"
-              />
-
-              <!-- Data de início da aula -->
-              <DefaultInputDatePicker
-                v-model="form.date_display"
-                v-model:untreated-date="form.date"
-                label="Data de Início"
-                class="col-12"
-                :rules="[(v) => !!v || 'Campo obrigatório']"
-              />
-            </div>
+              <div class="row q-col-gutter-sm">
+                <DefaultInput
+                  v-model="form.title"
+                  v-model:error="validationErrors.title"
+                  class="col-12"
+                  label="Nome da Atividade"
+                  :rules="titleRules"
+                />
+
+                <DefaultSelect
+                  v-model="form.class_package_unit_id"
+                  v-model:error="validationErrors.class_package_unit_id"
+                  label="Pacote relacionado"
+                  class="col-12"
+                  emit-value
+                  map-options
+                  option-label="name"
+                  option-value="id"
+                  :loading="loadingPackages"
+                  :options="packageOptions"
+                  :rules="packageRules"
+                />
+
+                <DefaultInput
+                  v-model="form.start_time"
+                  v-model:error="validationErrors.date_time_start"
+                  class="col-6"
+                  label="Hora de Início"
+                  mask="##:##"
+                  placeholder="08:40"
+                  :rules="startTimeRules"
+                >
+                  <template #append>
+                    <q-icon
+                      class="cursor-pointer"
+                      color="secondary"
+                      name="mdi-clock-outline"
+                    >
+                      <q-popup-proxy
+                        transition-hide="scale"
+                        transition-show="scale"
+                      >
+                        <q-time
+                          v-model="form.start_time"
+                          format24h
+                          mask="HH:mm"
+                        >
+                          <div class="row items-center justify-end">
+                            <q-btn
+                              v-close-popup
+                              color="primary"
+                              label="OK"
+                              flat
+                            />
+                          </div>
+                        </q-time>
+                      </q-popup-proxy>
+                    </q-icon>
+                  </template>
+                </DefaultInput>
+
+                <DefaultInput
+                  v-model="form.end_time"
+                  v-model:error="validationErrors.date_time_end"
+                  class="col-6"
+                  label="Hora de Fim"
+                  mask="##:##"
+                  placeholder="09:40"
+                  :rules="endTimeRules"
+                >
+                  <template #append>
+                    <q-icon
+                      class="cursor-pointer"
+                      color="secondary"
+                      name="mdi-clock-outline"
+                    >
+                      <q-popup-proxy
+                        transition-hide="scale"
+                        transition-show="scale"
+                      >
+                        <q-time v-model="form.end_time" format24h mask="HH:mm">
+                          <div class="row items-center justify-end">
+                            <q-btn
+                              v-close-popup
+                              color="primary"
+                              flat
+                              label="OK"
+                            />
+                          </div>
+                        </q-time>
+                      </q-popup-proxy>
+                    </q-icon>
+                  </template>
+                </DefaultInput>
+
+                <DefaultSelect
+                  v-model="form.instructor"
+                  v-model:error="validationErrors.instructor"
+                  class="col-6"
+                  clearable
+                  emit-value
+                  label="Neurotrainer"
+                  map-options
+                  option-label="name"
+                  option-value="name"
+                  :loading="loadingInstructors"
+                  :options="instructorOptions"
+                  :rules="[optionalMaxLengthRule(255)]"
+                />
+
+                <DefaultInput
+                  v-model="form.room"
+                  v-model:error="validationErrors.room"
+                  class="col-6"
+                  label="Número da Sala"
+                  :rules="[optionalMaxLengthRule(255)]"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="form.date_display"
+                  v-model:error="validationErrors.date_time_start"
+                  v-model:untreated-date="form.date"
+                  class="col-12"
+                  label="Data de Início"
+                  :rules="dateRules"
+                />
+              </div>
             </q-card-section>
           </q-scroll-area>
 
           <q-card-actions align="right" class="q-px-md q-pb-md">
-            <q-btn outline color="primary" label="Cancelar" no-caps @click="onDialogCancel" />
             <q-btn
-              type="submit"
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="onDialogCancel"
+            />
+
+            <q-btn
               color="primary"
               label="Salvar"
               no-caps
+              type="submit"
               :loading="saving"
             />
           </q-card-actions>
@@ -129,18 +167,22 @@
 </template>
 
 <script setup>
-import { ref, reactive, onMounted, watch } from "vue";
+import { createClass, updateClass } from "src/api/class";
+import { getUnitPackagesForSelect } from "src/api/package";
+import { getUsersByUnit } from "src/api/user";
+import { format, isValid, parse } from "date-fns";
+import { onMounted, ref, useTemplateRef, watch } from "vue";
 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 DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
 
-import { createClass, updateClass } from "src/api/class";
-import { getUnitPackagesForSelect } from "src/api/package";
-import { getUsersByUnit } from "src/api/user";
-
 defineEmits([...useDialogPluginComponent.emits]);
 
 const props = defineProps({
@@ -153,9 +195,12 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
 const $q = useQuasar();
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
 
-const formRef = ref(null);
-const saving = ref(false);
 const loadingPackages = ref(false);
 const packageOptions = ref([]);
 const loadingInstructors = ref(false);
@@ -164,84 +209,123 @@ const instructorOptions = ref([]);
 const toDisplay = (isoDate) =>
   isoDate ? isoDate.split("-").reverse().join("/") : null;
 
-const form = reactive({
+const { form, getUpdatedFields } = useForm({
   title: props.classData?.title ?? "",
   class_package_unit_id: props.classData?.class_package_unit_id ?? null,
   instructor: props.classData?.instructor ?? "",
   room: props.classData?.room ?? "",
   start_time: props.classData?.date_time_start?.slice(11, 16) ?? "",
   end_time: props.classData?.date_time_end?.slice(11, 16) ?? "",
+
   date: props.classData?.date_time_start?.slice(0, 10) ?? props.initialDate ?? null,
+
   date_display: toDisplay(
     props.classData?.date_time_start?.slice(0, 10) ?? props.initialDate,
   ),
 });
 
-const trimTime = (time) => (time ? time.slice(0, 5) : "");
+const emptyOptionalValue = (value) => value == null || value === "";
 
-function calculateEndTime(startTime, durationMinutes = 120) {
-  if (!/^\d{2}:\d{2}$/.test(startTime ?? "")) return "";
-  const [hours, minutes] = startTime.split(":").map(Number);
-  if (hours > 23 || minutes > 59) return "";
+const endAfterStartRule = (value) =>
+  !form.start_time ||
+  !value ||
+  value > form.start_time ||
+  "Fim deve ser após o início.";
 
-  const endMinutes = (hours * 60 + minutes + durationMinutes) % (24 * 60);
-  return `${String(Math.floor(endMinutes / 60)).padStart(2, "0")}:${String(
-    endMinutes % 60,
-  ).padStart(2, "0")}`;
-}
+const integerRule = (value) =>
+  emptyOptionalValue(value) ||
+  Number.isInteger(Number(value)) ||
+  "Informe um número inteiro válido.";
 
-function selectedPackage() {
-  return packageOptions.value.find(
-    (pkg) => pkg.id === form.class_package_unit_id,
+const optionalMaxLengthRule = (max) => (value) =>
+  emptyOptionalValue(value) ||
+  String(value).length <= max ||
+  `Informe no máximo ${max} caracteres.`;
+
+const validDateRule = (value) => {
+  if (emptyOptionalValue(value)) return true;
+
+  const parsedDate = parse(value, "dd/MM/yyyy", new Date());
+
+  return (
+    (isValid(parsedDate) && format(parsedDate, "dd/MM/yyyy") === value) ||
+    "Informe uma data válida."
   );
-}
+};
 
-function applyPackageSchedule() {
+const validTimeRule = (value) => {
+  if (emptyOptionalValue(value)) return true;
+
+  const match = /^(\d{2}):(\d{2})$/.exec(value);
+
+  return (
+    (!!match && Number(match[1]) <= 23 && Number(match[2]) <= 59) ||
+    "Informe um horário válido."
+  );
+};
+
+const titleRules = [inputRules.required, optionalMaxLengthRule(255)];
+const packageRules = [inputRules.required, integerRule];
+const startTimeRules = [inputRules.required, validTimeRule];
+const endTimeRules = [inputRules.required, validTimeRule, endAfterStartRule];
+const dateRules = [inputRules.required, validDateRule];
+
+const trimTime = (time) => (time ? time.slice(0, 5) : "");
+
+const applyPackageSchedule = () => {
   if (props.classData) return;
+
   const pkg = selectedPackage();
+
   if (!pkg) return;
 
   const selectedWeekday = form.date
     ? new Date(`${form.date}T00:00:00`).getDay()
     : null;
+
   const startTime =
     selectedWeekday === pkg.second_weekday
       ? pkg.second_start_time
       : pkg.start_time;
 
+  if (!startTime) return;
+
   form.start_time = trimTime(startTime);
+
   form.end_time = calculateEndTime(
     form.start_time,
     pkg.class_duration_minutes ?? 120,
   );
 }
 
-watch(() => form.class_package_unit_id, applyPackageSchedule);
-watch(() => form.date, applyPackageSchedule);
-watch(
-  () => form.start_time,
-  (startTime) => {
-    const endTime = calculateEndTime(
-      startTime,
-      selectedPackage()?.class_duration_minutes ?? 120,
-    );
-    if (endTime) form.end_time = endTime;
-  },
-);
+const calculateEndTime = (startTime, durationMinutes = 120) => {
+  if (!/^\d{2}:\d{2}$/.test(startTime ?? "")) return "";
 
-const loadPackages = async () => {
-  loadingPackages.value = true;
-  try {
-    packageOptions.value = await getUnitPackagesForSelect();
-  } finally {
-    loadingPackages.value = false;
-  }
-};
+  const [hours, minutes] = startTime.split(":").map(Number);
+
+  if (hours > 23 || minutes > 59) return "";
+
+  const endMinutes = (hours * 60 + minutes + durationMinutes) % (24 * 60);
+
+  return `${String(Math.floor(endMinutes / 60)).padStart(2, "0")}:${String(
+    endMinutes % 60,
+  ).padStart(2, "0")}`;
+}
+
+const selectedPackage = () => {
+  return packageOptions.value.find(
+    (pkg) => pkg.id === form.class_package_unit_id,
+  );
+}
+
+//
 
 const loadInstructors = async () => {
   loadingInstructors.value = true;
+
   try {
     const users = await getUsersByUnit();
+
     instructorOptions.value = (users ?? []).filter(
       (u) =>
         u.user_type_system_key === "NEUROTRAINER" ||
@@ -255,10 +339,33 @@ const loadInstructors = async () => {
   }
 };
 
-const onSubmit = async () => {
-  const valid = await formRef.value.validate();
-  if (!valid) return;
+const loadPackages = async () => {
+  loadingPackages.value = true;
+
+  try {
+    packageOptions.value = await getUnitPackagesForSelect();
+  } finally {
+    loadingPackages.value = false;
+  }
+};
+
+//
+
+const {
+  loading: saving,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
+  onSuccess: () => {
+    $q.notify({ type: "positive", message: "Aula salva com sucesso." });
+    onDialogOK(true);
+  },
+});
 
+const onSubmit = async () => {
   const payload = {
     title: form.title,
     class_package_unit_id: form.class_package_unit_id,
@@ -268,22 +375,55 @@ const onSubmit = async () => {
     date_time_end: `${form.date}T${form.end_time}:00`,
   };
 
-  saving.value = true;
-  try {
-    if (props.classData?.id) {
-      await updateClass(payload, props.classData.id);
-    } else {
-      await createClass(payload);
+  await execute(() => {
+    if (!props.classData?.id) return createClass(payload);
+
+    const changed = getUpdatedFields.value;
+
+    const updatePayload = {};
+
+    for (const key of [
+      "title",
+      "class_package_unit_id",
+      "instructor",
+      "room",
+    ]) {
+      if (key in changed) updatePayload[key] = payload[key];
     }
-    $q.notify({ type: "positive", message: "Aula salva com sucesso." });
-    onDialogOK(true);
-  } catch {
-    // O interceptor do axios já notifica os erros de validação/servidor
-  } finally {
-    saving.value = false;
-  }
+
+    if ("date" in changed || "start_time" in changed) {
+      updatePayload.date_time_start = payload.date_time_start;
+    }
+
+    if ("date" in changed || "end_time" in changed) {
+      updatePayload.date_time_end = payload.date_time_end;
+    }
+
+    return updateClass(updatePayload, props.classData.id);
+  });
 };
 
+watch(() => form.class_package_unit_id, applyPackageSchedule);
+
+watch(
+  () => form.date,
+  () => {
+    if (!form.start_time) applyPackageSchedule();
+  },
+);
+
+watch(
+  () => form.start_time,
+  (startTime) => {
+    const endTime = calculateEndTime(
+      startTime,
+      selectedPackage()?.class_duration_minutes ?? 120,
+    );
+
+    if (endTime) form.end_time = endTime;
+  },
+);
+
 onMounted(() => {
   loadPackages();
   loadInstructors();

+ 48 - 32
src/pages/classes/components/JustifyAttendanceDialog.vue

@@ -1,38 +1,53 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <div style="width: 100%; max-width: 520px">
+    <div style="width: 100%; max-width: 560px">
       <q-card class="dialog-form-card">
-        <DefaultDialogHeader title="Justifica Registrar Presença" @close="onDialogCancel" />
+        <DefaultDialogHeader
+          title="Justificar Registrar Presença"
+          @close="onDialogCancel"
+        />
 
-        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
-          <q-card-section class="q-pt-sm">
-          <q-input
-            v-model="text"
-            type="textarea"
-            outlined
-            autogrow
-            autofocus
-            bg-color="white"
-            :placeholder="placeholder"
-            input-style="min-height: 110px"
-          >
-            <template #append>
-              <q-icon name="mdi-pencil-outline" color="secondary" />
-            </template>
-          </q-input>
-          </q-card-section>
-        </q-scroll-area>
+        <DefaultForm @submit="onConfirm">
+          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
+            <q-card-section class="q-pt-sm">
+              <DefaultInput
+                v-model="text"
+                autofocus
+                autogrow
+                outlined
+                bg-color="white"
+                input-style="min-height: 110px"
+                :placeholder="placeholder"
+                type="textarea"
+              >
+                <template #append>
+                  <q-icon
+                    color="secondary"
+                    name="mdi-pencil-outline"
+                  />
+                </template>
+              </DefaultInput>
+            </q-card-section>
+          </q-scroll-area>
 
-        <q-card-actions align="right" class="q-px-md q-pb-md">
-          <q-btn outline color="primary" label="Cancelar" no-caps @click="onDialogCancel" />
-          <q-btn
-            color="primary"
-            label="Justificar"
-            no-caps
-            :disable="!text.trim()"
-            @click="onConfirm"
-          />
-        </q-card-actions>
+          <q-card-actions align="right" class="q-px-md q-pb-md">
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="onDialogCancel"
+            />
+
+            <q-btn
+              color="primary"
+              label="Justificar"
+              no-caps
+              type="submit"
+              :disable="!text.trim()"
+            />
+          </q-card-actions>
+        </DefaultForm>
       </q-card>
     </div>
   </q-dialog>
@@ -43,11 +58,12 @@ import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const props = defineProps({
-  // Justificativa já existente (ao reabrir)
   notes: { type: String, default: "" },
 });
 
@@ -65,4 +81,4 @@ const text = ref(props.notes ?? "");
 const onConfirm = () => {
   onDialogOK(text.value.trim());
 };
-</script>
+</script>

+ 392 - 226
src/pages/dashboard/DashboardPage.vue

@@ -1,60 +1,80 @@
 <template>
   <div>
-    <DefaultHeaderPage class="q-pa-sm" />
+    <DefaultHeaderPage />
 
     <div class="q-pa-sm">
       <div class="stat-cards-row q-mb-md">
         <DashboardStatCard
-          title="Total alunos (contratos ativos)"
-          icon="mdi-account-multiple-outline"
-          :value="String(totalAlunos)"
           :badge="`${totalAlunos} ativos`"
+          :value="String(totalAlunos)"
+          icon="mdi-account-multiple-outline"
+          title="Total alunos (contratos ativos)"
         />
+
         <!-- TODO: usar dados reais de receita (zerado por enquanto). -->
+
         <DashboardStatCard
-          title="Receita Total"
           icon="mdi-currency-usd"
-          value="R$ 0,00"
           subtitle="0 pagamentos pendentes"
+          title="Receita Total"
+          value="R$ 0,00"
         />
+
         <!-- TODO: usar dados reais de ticket médio (zerado por enquanto). -->
+
         <DashboardStatCard
-          title="Ticket Médio"
           icon="mdi-calendar-blank"
-          value="R$ 0,00"
           subtitle="Estável"
+          title="Ticket Médio"
+          value="R$ 0,00"
         />
+
         <DashboardStatCard
-          title="Aniversariantes"
-          icon="mdi-emoticon-happy-outline"
           :value="String(aniversariantes.length)"
+          icon="mdi-emoticon-happy-outline"
           subtitle="Fortaleça seus relacionamentos"
+          title="Aniversariantes"
         />
       </div>
 
       <div class="row q-col-gutter-md q-mb-md items-stretch">
         <div class="col-12 col-md-5">
-          <DashboardChartCard title="Faturamento Serviço / Materiais" style="height: 100%">
+          <DashboardChartCard
+            style="height: 100%"
+            title="Faturamento Serviço / Materiais"
+          >
             <GroupedBarChart
-              :labels="faturamentoChart.labels"
               :datasets="faturamentoChart.datasets"
-              label-y="R$"
+              :labels="faturamentoChart.labels"
               :tick-formatter="formatCurrencyTick"
               :tooltip-formatter="formatCurrencyTooltip"
               class="full-width full-height"
+              label-y="R$"
             />
           </DashboardChartCard>
         </div>
 
         <div class="col-12 col-md-4">
-          <q-card flat bordered class="full-height">
-            <q-card-section class="row justify-between items-center q-pb-xs">
-              <span class="text-subtitle2 text-weight-medium"
-                >Contratos Ativos</span
-              >
-              <q-icon name="mdi-trending-up" color="grey-5" />
+          <q-card
+            bordered
+            class="full-height"
+            flat
+          >
+            <q-card-section
+              class="row justify-between items-center q-pb-xs"
+            >
+              <span class="text-subtitle2 text-weight-medium">
+                Contratos Ativos
+              </span>
+
+              <q-icon
+                color="grey-5"
+                name="mdi-trending-up"
+              />
             </q-card-section>
+
             <q-separator />
+
             <q-card-section
               class="flex flex-center q-pt-sm"
               style="height: calc(100% - 57px); position: relative"
@@ -66,51 +86,75 @@
                   :plugins="[gaugeNeedlePlugin]"
                 />
               </div>
+
               <div class="gauge-label">
-                <div class="text-h5 text-bold">{{ activeContracts }}</div>
-                <div class="text-caption text-grey-6">Ativos</div>
+                <div class="text-h5 text-bold">
+                  {{ activeContracts }}
+                </div>
+
+                <div class="text-caption text-grey-6">
+                  Ativos
+                </div>
               </div>
             </q-card-section>
           </q-card>
         </div>
 
         <div class="col-12 col-md-3">
-          <q-card flat bordered class="full-height">
-            <q-card-section class="row justify-between items-center q-pb-xs">
-              <span class="text-subtitle2 text-weight-medium"
-                >Atalhos rápidos</span
-              >
-              <q-icon name="mdi-apps" color="grey-5" />
+          <q-card
+            bordered
+            class="full-height"
+            flat
+          >
+            <q-card-section
+              class="row justify-between items-center q-pb-xs"
+            >
+              <span class="text-subtitle2 text-weight-medium">
+                Atalhos rápidos
+              </span>
+
+              <q-icon
+                color="grey-5"
+                name="mdi-apps"
+              />
             </q-card-section>
+
             <q-separator />
+
             <q-card-section class="q-pt-md column q-gutter-sm">
               <q-btn
                 v-if="canAdd"
-                unelevated
+                class="full-width"
                 color="primary"
                 label="Criar contrato"
                 no-caps
-                class="full-width"
+                unelevated
                 @click="onCriarContrato"
               />
+
               <q-btn
                 v-if="canEditClasses"
-                unelevated
+                class="full-width"
                 color="primary"
                 label="Registrar presença"
                 no-caps
-                class="full-width"
+                unelevated
                 @click="onRegistrarPresenca"
               />
-              <div v-if="canAddOrders" class="full-width cursor-not-allowed">
+
+              <div
+                v-if="canAddOrders"
+                class="full-width cursor-not-allowed"
+              >
                 <q-btn
-                  unelevated
+                  class="full-width"
                   color="primary"
+                  disable
                   label="Novo pedido"
                   no-caps
-                  disable
-                  class="full-width"
+                  unelevated
                 />
+
                 <q-tooltip>
                   Esta funcionalidade ainda não foi desenvolvida.
                 </q-tooltip>
@@ -120,39 +164,55 @@
         </div>
       </div>
 
-      <!-- Row 3: Bottom -->
       <div class="row q-col-gutter-md items-stretch">
         <div class="col-12 col-md-5">
-          <q-card flat class="card-ring full-height feriados-card">
+          <q-card class="card-ring full-height feriados-card" flat>
             <div class="flex justify-between items-center no-wrap q-mb-sm">
-              <span class="text-subtitle2 text-weight-regular">Feriados do mês</span>
+              <span class="text-subtitle2 text-weight-regular">
+                Feriados do mês
+              </span>
+
               <q-btn
-                flat
-                round
+                color="grey-5"
                 dense
+                flat
                 icon="mdi-calendar-star"
-                color="grey-5"
+                round
                 @click="openFeriadosDialog"
               />
             </div>
 
-              <q-btn
-                v-if="canAdd"
-                unelevated
+            <q-btn
+              v-if="canAdd"
+              class="full-width q-mb-md"
               color="primary"
               label="Nova data"
               no-caps
-              class="full-width q-mb-md"
+              unelevated
               @click="openFeriadosDialog"
             />
 
-            <div v-if="feriadosLoading" class="flex flex-center q-py-md">
-              <q-spinner color="primary" size="24px" />
+            <div
+              v-if="feriadosLoading"
+              class="flex flex-center q-py-md"
+            >
+              <q-spinner
+                color="primary"
+                size="24px"
+              />
             </div>
-            <div v-else-if="feriadosMes.length === 0" class="text-caption text-grey-5 text-center q-mt-sm">
+
+            <div
+              v-else-if="feriadosMes.length === 0"
+              class="text-caption text-grey-5 text-center q-mt-sm"
+            >
               Nenhum feriado neste mês.
             </div>
-            <div v-else class="row q-gutter-sm">
+
+            <div
+              v-else
+              class="row q-gutter-sm"
+            >
               <div
                 v-for="feriado in feriadosMes"
                 :key="feriado.id"
@@ -161,12 +221,13 @@
                 @click="openEditFromDashboard(feriado)"
               >
                 <q-badge
-                  color="deep-orange"
                   class="text-subtitle1 text-bold q-pa-sm"
+                  color="deep-orange"
                   style="min-width: 40px; justify-content: center"
                 >
                   {{ feriado.dia }}
                 </q-badge>
+
                 <div class="text-caption q-mt-xs text-center">
                   {{ feriado.nome }}
                 </div>
@@ -176,22 +237,28 @@
         </div>
 
         <div class="col-12 col-md-4">
-          <DashboardChartCard title="Matrículas por Período" style="height: 100%">
+          <DashboardChartCard
+            style="height: 100%"
+            title="Matrículas por Período"
+          >
             <GroupedBarChart
-              :labels="matriculasChart.labels"
-              :datasets="matriculasChart.datasets"
+              :bar-percentage="0.85"
               :bar-radius="50"
-              :show-datalabels="true"
-              :max-bar-thickness="44"
               :category-percentage="0.6"
-              :bar-percentage="0.85"
+              :datasets="matriculasChart.datasets"
+              :labels="matriculasChart.labels"
+              :max-bar-thickness="44"
+              :show-datalabels="true"
               class="full-width full-height"
             />
           </DashboardChartCard>
         </div>
 
         <div class="col-12 col-md-3">
-          <AniversariantesCard :people="aniversariantes" style="height: 100%" />
+          <AniversariantesCard
+            :people="aniversariantes"
+            style="height: 100%"
+          />
         </div>
       </div>
     </div>
@@ -199,60 +266,39 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch } from "vue";
-import { useRouter } from "vue-router";
+import { ArcElement, Chart as ChartJS, Legend, Tooltip } from "chart.js";
+import { computed, onMounted, ref, watch } from "vue";
 import { Doughnut } from "vue-chartjs";
-import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
+import { getDashboardSummary } from "src/api/franchisee_dashboard";
+import { getHolidays } from "src/api/holiday";
+import { permissionStore } from "src/stores/permission";
 import { useQuasar } from "quasar";
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
+import { useRouter } from "vue-router";
+import { userStore } from "src/stores/user";
+
+import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
 import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
+import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
 import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
-import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
 import FeriadosDialog from "./components/FeriadosDialog.vue";
 import FeriadosEditDialog from "./components/FeriadosEditDialog.vue";
-import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
-import { getHolidays } from "src/api/holiday";
-import { getDashboardSummary } from "src/api/franchisee_dashboard";
-import { userStore } from "src/stores/user";
-import { permissionStore } from "src/stores/permission";
 
 ChartJS.register(ArcElement, Tooltip, Legend);
 
 const $q = useQuasar();
 const router = useRouter();
-const store = userStore();
-const permissions = permissionStore();
-const canAdd = computed(() =>
-  permissions.getAccess("franchisee_dashboard", "add"),
-);
-const canEditClasses = computed(() =>
-  permissions.getAccess("franchisee_classes", "edit"),
-);
-const canAddOrders = computed(() =>
-  permissions.getAccess("franchisee_orders", "add"),
-);
 
-// Faturamento Serviço / Materiais — zerado (sem fluxo de dados ainda),
-// igual ao Franqueador.
-// TODO: alimentar com dados reais de faturamento de serviço/materiais.
-const faturamentoChart = {
-  labels: [],
-  datasets: [
-    { label: "Serviço", data: [], color: "#a274f1" },
-    { label: "Materiais", data: [], color: "#ff9999" },
-  ],
-};
-
-const formatCurrencyTick = (value) => {
-  if (value >= 1000) return `R$ ${(value / 1000).toFixed(0)}k`;
-  return `R$ ${value}`;
-};
+const permissions = permissionStore();
+const store = userStore();
 
-const formatCurrencyTooltip = (context) => {
-  const value = context.parsed.y;
-  return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
-};
+const activeContracts = ref(0);
+const allHolidays = ref([]);
+const aniversariantes = ref([]);
+const feriadosLoading = ref(false);
+const students = ref([]);
+const totalAlunos = ref(0);
 
 const gaugeData = ref({
   datasets: [
@@ -269,110 +315,232 @@ const gaugeData = ref({
         "#D01616",
         "#8A0000",
       ],
+      borderColor: "transparent",
       data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
       needleValue: 0,
-      borderColor: "transparent",
     },
   ],
 });
 
 const gaugeOptions = ref({
-  rotation: 270,
   circumference: 180,
   cutout: "50%",
-  responsive: true,
   maintainAspectRatio: false,
   plugins: {
-    tooltip: { enabled: false },
-    legend: { display: false },
     datalabels: {
       color: "black",
-      font: { size: 14, weight: "bold" },
-      formatter: (_value, ctx) => ctx.dataIndex,
+      font: {
+        size: 14,
+        weight: "bold",
+      },
+      formatter: (_value, context) => context.dataIndex,
+    },
+    legend: {
+      display: false,
+    },
+    tooltip: {
+      enabled: false,
     },
   },
+  responsive: true,
+  rotation: 270,
 });
 
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_dashboard", "add"),
+);
+
+const canAddOrders = computed(() =>
+  permissions.getAccess("franchisee_orders", "add"),
+);
+
+const canEditClasses = computed(() =>
+  permissions.getAccess("franchisee_classes", "edit"),
+);
+
+const feriadosMes = computed(() => {
+  const now = new Date();
+  const month = now.getMonth() + 1;
+  const year = now.getFullYear();
+
+  return allHolidays.value
+    .filter((holiday) => {
+      const date = new Date(`${holiday.holiday_date}T00:00:00`);
+
+      return (
+        date.getMonth() + 1 === month &&
+        date.getFullYear() === year
+      );
+    })
+    .sort(
+      (firstHoliday, secondHoliday) =>
+        new Date(firstHoliday.holiday_date) -
+        new Date(secondHoliday.holiday_date),
+    )
+    .map((holiday) => ({
+      base_holiday_id: holiday.base_holiday_id ?? null,
+      description: holiday.description,
+      dia: new Date(`${holiday.holiday_date}T00:00:00`).getDate(),
+      holiday_date: holiday.holiday_date,
+      id: holiday.id,
+      nome: holiday.description,
+      type: holiday.type,
+    }));
+});
+
+const matriculasChart = computed(() => {
+  const now = new Date();
+  const start = new Date(now.getFullYear(), now.getMonth() - 5, 1);
+
+  const counts = {};
+
+  for (const student of students.value) {
+    if (!student.created_at) continue;
+
+    const date = new Date(student.created_at.replace(" ", "T"));
+
+    if (date < start) continue;
+
+    const key = `${date.getFullYear()}-${date.getMonth()}`;
+
+    counts[key] = (counts[key] ?? 0) + 1;
+  }
+
+  const data = [];
+  const labels = [];
+
+  for (let index = 0; index < 6; index++) {
+    const month = new Date(
+      start.getFullYear(),
+      start.getMonth() + index,
+      1,
+    );
+
+    const key = `${month.getFullYear()}-${month.getMonth()}`;
+
+    labels.push(MONTHS_PT[month.getMonth()]);
+    data.push(counts[key] ?? 0);
+  }
+
+  return {
+    datasets: [
+      {
+        color: MATRICULA_COLORS,
+        data,
+        label: "Matrículas",
+      },
+    ],
+    labels,
+  };
+});
+
+// Faturamento Serviço / Materiais — zerado (sem fluxo de dados ainda),
+// igual ao Franqueador.
+// TODO: alimentar com dados reais de faturamento de serviço/materiais.
+const faturamentoChart = {
+  datasets: [
+    {
+      color: "#a274f1",
+      data: [],
+      label: "Serviço",
+    },
+    {
+      color: "#ff9999",
+      data: [],
+      label: "Materiais",
+    },
+  ],
+  labels: [],
+};
+
 const gaugeNeedlePlugin = {
   id: "gaugeNeedle",
+
   afterDatasetsDraw(chart) {
     const { ctx, data } = chart;
-    ctx.save();
-    const needleValue = data.datasets[0].needleValue;
+
     const meta = chart.getDatasetMeta(0).data[0];
+    const needleValue = data.datasets[0].needleValue;
+    const outerRadius = meta.outerRadius - 20;
     const xCenter = meta.x;
     const yCenter = meta.y;
-    const outerRadius = meta.outerRadius - 20;
+
     const circumference =
-      (meta.circumference / Math.PI / data.datasets[0].data[0]) * needleValue;
+      (meta.circumference / Math.PI / data.datasets[0].data[0]) *
+      needleValue;
+
     const angle = Math.PI;
+
+    ctx.save();
     ctx.translate(xCenter, yCenter);
     ctx.rotate(angle * (circumference + 1.5));
+
     ctx.beginPath();
-    ctx.strokeStyle = "grey";
     ctx.fillStyle = "grey";
+    ctx.strokeStyle = "grey";
     ctx.moveTo(-3, 0);
     ctx.lineTo(0, -outerRadius);
     ctx.lineTo(3, 0);
     ctx.stroke();
     ctx.fill();
+
     ctx.beginPath();
     ctx.arc(0, 0, 6, 0, 2 * Math.PI);
     ctx.fillStyle = "grey";
     ctx.fill();
+
     ctx.restore();
   },
 };
 
-const MONTHS_PT = ["JAN", "FEV", "MAR", "ABR", "MAI", "JUN", "JUL", "AGO", "SET", "OUT", "NOV", "DEZ"];
-const MATRICULA_COLORS = ["#3B82F6", "#EF4444", "#A855F7", "#374151", "#EAB308", "#06B6D4"];
-
-// Alunos / Contratos
-const totalAlunos = ref(0);
-const activeContracts = ref(0);
-
-// Lista de alunos crua, usada para derivar aniversariantes e matrículas.
-const students = ref([]);
-
-// Aniversariantes do mês corrente (dia + nome), ordenados por dia.
-const aniversariantes = ref([]);
-
-// Matrículas por Período — alunos cadastrados (created_at) nos últimos 6 meses,
-// terminando no mês atual.
-const matriculasChart = computed(() => {
-  const now = new Date();
-  const start = new Date(now.getFullYear(), now.getMonth() - 5, 1);
-
-  const counts = {};
-  for (const s of students.value) {
-    if (!s.created_at) continue;
-    const d = new Date(s.created_at.replace(" ", "T"));
-    if (d < start) continue;
-    const key = `${d.getFullYear()}-${d.getMonth()}`;
-    counts[key] = (counts[key] ?? 0) + 1;
-  }
+const MATRICULA_COLORS = [
+  "#3B82F6",
+  "#EF4444",
+  "#A855F7",
+  "#374151",
+  "#EAB308",
+  "#06B6D4",
+];
+
+const MONTHS_PT = [
+  "JAN",
+  "FEV",
+  "MAR",
+  "ABR",
+  "MAI",
+  "JUN",
+  "JUL",
+  "AGO",
+  "SET",
+  "OUT",
+  "NOV",
+  "DEZ",
+];
+
+const fetchHolidays = async () => {
+  feriadosLoading.value = true;
 
-  const labels = [];
-  const data = [];
-  for (let i = 0; i < 6; i++) {
-    const month = new Date(start.getFullYear(), start.getMonth() + i, 1);
-    labels.push(MONTHS_PT[month.getMonth()]);
-    data.push(counts[`${month.getFullYear()}-${month.getMonth()}`] ?? 0);
+  try {
+    allHolidays.value = await getHolidays();
+  } catch {
+    $q.notify({
+      message: "Erro ao carregar feriados.",
+      type: "negative",
+    });
+  } finally {
+    feriadosLoading.value = false;
   }
+};
 
-  return {
-    labels,
-    datasets: [{ label: "Matrículas", data, color: MATRICULA_COLORS }],
-  };
-});
-
-async function fetchSummary() {
+const fetchSummary = async () => {
   try {
     const summary = await getDashboardSummary();
-    students.value = summary.enrollments;
-    totalAlunos.value = summary.students_count;
+
     activeContracts.value = summary.contracts.active;
     aniversariantes.value = summary.birthdays;
+    students.value = summary.enrollments;
+    totalAlunos.value = summary.students_count;
+
     gaugeData.value.datasets[0].needleValue = Math.min(
       activeContracts.value,
       10,
@@ -380,83 +548,80 @@ async function fetchSummary() {
   } catch {
     // silencioso
   }
-}
+};
 
-function onCriarContrato() {
-  $q.dialog({
-    component: AddEditContractDialog,
-    componentProps: { selectStudent: true },
-  }).onOk(fetchSummary);
-}
+const formatCurrencyTick = (value) => {
+  if (value >= 1000) {
+    return `R$ ${(value / 1000).toFixed(0)}k`;
+  }
 
-function onRegistrarPresenca() {
-  router.push({ name: "ClassPage" });
-}
+  return `R$ ${value}`;
+};
 
-// Feriados
-const allHolidays = ref([]);
-const feriadosLoading = ref(false);
+const formatCurrencyTooltip = (context) => {
+  const value = context.parsed.y;
 
-const feriadosMes = computed(() => {
-  const now = new Date();
-  const month = now.getMonth() + 1;
-  const year = now.getFullYear();
-  return allHolidays.value
-    .filter((h) => {
-      const d = new Date(h.holiday_date + "T00:00:00");
-      return d.getMonth() + 1 === month && d.getFullYear() === year;
-    })
-    .sort((a, b) => new Date(a.holiday_date) - new Date(b.holiday_date))
-    .map((h) => ({
-      id: h.id,
-      dia: new Date(h.holiday_date + "T00:00:00").getDate(),
-      nome: h.description,
-      holiday_date: h.holiday_date,
-      description: h.description,
-      type: h.type,
-      base_holiday_id: h.base_holiday_id ?? null,
-    }));
-});
+  return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", {
+    minimumFractionDigits: 2,
+  })}`;
+};
 
-async function fetchHolidays() {
-  feriadosLoading.value = true;
-  try {
-    allHolidays.value = await getHolidays();
-  } catch {
-    $q.notify({ type: "negative", message: "Erro ao carregar feriados." });
-  } finally {
-    feriadosLoading.value = false;
-  }
-}
+const onCriarContrato = () => {
+  $q.dialog({
+    component: AddEditContractDialog,
+    componentProps: {
+      selectStudent: true,
+    },
+  }).onOk(fetchSummary);
+};
 
-function openFeriadosDialog() {
-  $q.dialog({ component: FeriadosDialog }).onOk(() => {
-    fetchHolidays();
-  });
-}
+const onRegistrarPresenca = () => {
+  router.push({ name: "ClassPage" });
+};
 
-function openEditFromDashboard(feriado) {
+const openEditFromDashboard = (feriado) => {
   $q.dialog({
     component: FeriadosEditDialog,
-    componentProps: { holiday: feriado },
+    componentProps: {
+      holiday: feriado,
+    },
   }).onOk(({ action, holiday: updated, id }) => {
     if (action === "update") {
-      const idx = allHolidays.value.findIndex((h) => h.id === updated.id);
-      if (idx !== -1) allHolidays.value[idx] = { ...allHolidays.value[idx], ...updated };
-    } else if (action === "delete") {
-      allHolidays.value = allHolidays.value.filter((h) => h.id !== id);
+      const index = allHolidays.value.findIndex(
+        (holiday) => holiday.id === updated.id,
+      );
+
+      if (index !== -1) {
+        allHolidays.value[index] = {
+          ...allHolidays.value[index],
+          ...updated,
+        };
+      }
+
+      return;
+    }
+
+    if (action === "delete") {
+      allHolidays.value = allHolidays.value.filter(
+        (holiday) => holiday.id !== id,
+      );
     }
   });
-}
+};
+
+const openFeriadosDialog = () => {
+  $q.dialog({
+    component: FeriadosDialog,
+  }).onOk(fetchHolidays);
+};
 
-// Recarrega dados do dashboard quando a unidade ativa mudar
 watch(
   () => store.selectedUnit?.id,
   (newId, oldId) => {
-    if (newId && newId !== oldId) {
-      fetchSummary();
-      fetchHolidays();
-    }
+    if (!newId || newId === oldId) return;
+
+    fetchSummary();
+    fetchHolidays();
   },
 );
 
@@ -478,28 +643,29 @@ onMounted(() => {
   min-width: 0;
 }
 
-@media (max-width: 599px) {
-  .stat-cards-row {
-    flex-wrap: wrap;
-  }
-  .stat-cards-row > * {
-    flex: 1 1 calc(50% - 8px);
-  }
-}
-
 .gauge-label {
   position: absolute;
   bottom: 28%;
   left: 50%;
-  transform: translateX(-50%);
-  text-align: center;
   pointer-events: none;
+  text-align: center;
+  transform: translateX(-50%);
 }
 
 .feriados-card {
-  border-radius: 12px;
-  padding: 20px 24px;
   display: flex;
   flex-direction: column;
+  padding: 20px 24px;
+  border-radius: 12px;
+}
+
+@media (max-width: 599px) {
+  .stat-cards-row {
+    flex-wrap: wrap;
+  }
+
+  .stat-cards-row > * {
+    flex: 1 1 calc(50% - 8px);
+  }
 }
-</style>
+</style>

+ 396 - 281
src/pages/dashboard/components/FeriadosDialog.vue

@@ -2,147 +2,183 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card class="q-dialog-plugin dialog-form-card feriados-dialog">
       <template v-if="view === 'calendar'">
-        <DefaultDialogHeader title="Feriados" @close="onClose" />
+        <DefaultDialogHeader
+          title="Feriados"
+          @close="onClose"
+        />
 
         <q-scroll-area class="dialog-form-scroll">
           <q-card-section class="row q-col-gutter-md q-pt-none">
-          <div class="col-12 col-sm-7">
-            <div class="row items-center justify-between q-mb-md">
-              <q-btn
-                flat
-                round
-                dense
-                icon="mdi-chevron-left"
-                color="grey-7"
-                @click="prevMonth"
-              />
-              <div class="row items-center q-gutter-xs">
-                <q-select
-                  v-model="currentMonth"
-                  :options="monthOptions"
-                  emit-value
-                  map-options
+            <div class="col-12 col-sm-7">
+              <div class="row items-center justify-between q-mb-md">
+                <q-btn
+                  color="grey-7"
                   dense
-                  outlined
-                  style="min-width: 100px"
+                  flat
+                  icon="mdi-chevron-left"
+                  round
+                  @click="prevMonth"
                 />
-                <q-select
-                  v-model="currentYear"
-                  :options="yearOptions"
-                  emit-value
-                  map-options
+
+                <div class="row items-center q-gutter-xs">
+                  <DefaultSelect
+                    v-model="currentMonth"
+                    dense
+                    emit-value
+                    map-options
+                    outlined
+                    style="min-width: 100px"
+                    :options="monthOptions"
+                  />
+
+                  <DefaultSelect
+                    v-model="currentYear"
+                    dense
+                    emit-value
+                    map-options
+                    outlined
+                    style="min-width: 80px"
+                    :options="yearOptions"
+                  />
+                </div>
+
+                <q-btn
+                  color="grey-7"
                   dense
-                  outlined
-                  style="min-width: 80px"
+                  flat
+                  icon="mdi-chevron-right"
+                  round
+                  @click="nextMonth"
                 />
               </div>
-              <q-btn
-                flat
-                round
-                dense
-                icon="mdi-chevron-right"
-                color="grey-7"
-                @click="nextMonth"
-              />
+
+              <div class="calendar-grid q-mb-xs">
+                <div
+                  v-for="day in weekDays"
+                  :key="day"
+                  class="cal-header text-caption text-grey-6 text-center text-weight-medium"
+                >
+                  {{ day }}
+                </div>
+              </div>
+
+              <div class="calendar-grid">
+                <div
+                  v-for="(cell, index) in calendarCells"
+                  :key="index"
+                  :class="{
+                    'cal-cell--empty': !cell.day,
+                    'cal-cell--holiday': cell.isHoliday,
+                  }"
+                  class="cal-cell"
+                  @click="
+                    canAdd &&
+                    cell.day &&
+                    !cell.isHoliday &&
+                    openNewRecord(cell.day)
+                  "
+                >
+                  <span
+                    v-if="cell.day"
+                    class="cal-cell__number"
+                  >
+                    {{ cell.day }}
+                  </span>
+                </div>
+              </div>
             </div>
 
-            <div class="calendar-grid q-mb-xs">
+            <div class="col-12 col-sm-5">
+              <div class="text-subtitle2 q-mb-sm">
+                Resumo
+              </div>
+
+              <q-separator class="q-mb-sm" />
+
               <div
-                v-for="day in weekDays"
-                :key="day"
-                class="cal-header text-caption text-grey-6 text-center text-weight-medium"
+                v-if="loadingHolidays"
+                class="flex flex-center q-py-lg"
               >
-                {{ day }}
+                <q-spinner
+                  color="primary"
+                  size="24px"
+                />
               </div>
-            </div>
 
-            <div class="calendar-grid">
               <div
-                v-for="(cell, index) in calendarCells"
-                :key="index"
-                class="cal-cell"
-                :class="{
-                  'cal-cell--empty': !cell.day,
-                  'cal-cell--holiday': cell.isHoliday,
-                }"
-                @click="canAdd && cell.day && !cell.isHoliday && openNewRecord(cell.day)"
+                v-else-if="monthHolidays.length === 0"
+                class="text-caption text-grey-5 text-center q-mt-lg q-px-sm"
               >
-                <span v-if="cell.day" class="cal-cell__number">{{
-                  cell.day
-                }}</span>
+                Clique em um dia no calendário para adicionar um registro.
               </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 v-if="loadingHolidays" class="flex flex-center q-py-lg">
-              <q-spinner color="primary" size="24px" />
-            </div>
-
-            <div
-              v-else-if="monthHolidays.length === 0"
-              class="text-caption text-grey-5 text-center q-mt-lg q-px-sm"
-            >
-              Clique em um dia no calendário para adicionar um registro.
-            </div>
+              <q-list
+                v-else
+                separator
+              >
+                <q-item
+                  v-for="holiday in monthHolidays"
+                  :key="holiday.id"
+                  class="q-pa-sm cursor-pointer"
+                  clickable
+                  @click="openEdit(holiday)"
+                >
+                  <q-item-section avatar>
+                    <q-avatar
+                      class="text-weight-bold"
+                      color="deep-orange"
+                      size="36px"
+                      text-color="white"
+                    >
+                      {{ 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-section>
+
+                  <q-item-section side>
+                    <q-icon
+                      :color="
+                        holiday.base_holiday_id ? 'grey-4' : 'grey-5'
+                      "
+                      :name="
+                        holiday.base_holiday_id
+                          ? 'mdi-lock-outline'
+                          : 'mdi-pencil-outline'
+                      "
+                      size="xs"
+                    />
+                  </q-item-section>
+                </q-item>
+              </q-list>
 
-            <q-list v-else separator>
-              <q-item
-                v-for="holiday in monthHolidays"
-                :key="holiday.id"
-                class="q-pa-sm cursor-pointer"
-                clickable
-                @click="openEdit(holiday)"
+              <div
+                v-if="monthHolidays.length > 0"
+                class="text-caption text-grey-5 text-center q-mt-md q-px-sm"
               >
-                <q-item-section avatar>
-                  <q-avatar
-                    size="36px"
-                    color="deep-orange"
-                    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-section>
-                <q-item-section side>
-                  <q-icon
-                    :name="holiday.base_holiday_id ? 'mdi-lock-outline' : 'mdi-pencil-outline'"
-                    :color="holiday.base_holiday_id ? 'grey-4' : 'grey-5'"
-                    size="xs"
-                  />
-                </q-item-section>
-              </q-item>
-            </q-list>
-
-            <div
-              v-if="monthHolidays.length > 0"
-              class="text-caption text-grey-5 text-center q-mt-md q-px-sm"
-            >
-              Clique em um dia no calendário para adicionar um registro.
+                Clique em um dia no calendário para adicionar um registro.
+              </div>
             </div>
-          </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-separator />
-
-        <q-card-actions align="right">
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
           <q-btn
-            outline
             color="primary"
-            label="FECHAR"
+            label="Fechar"
             no-caps
+            outline
             @click="onClose"
           />
         </q-card-actions>
@@ -154,86 +190,175 @@
           @close="cancelNewRecord"
         />
 
-        <q-scroll-area class="dialog-form-scroll">
-          <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-scroll-area>
-
-        <q-separator />
+        <DefaultForm @submit="saveNewRecord">
+          <q-scroll-area class="dialog-form-scroll">
+            <q-card-section class="column q-gutter-md q-pt-sm">
+              <DefaultInput
+                v-model="newDescription"
+                autofocus
+                icon="mdi-pencil-outline"
+                label="Nome do Evento"
+                outlined
+                placeholder="Ex: Ponto facultativo, Natal..."
+                :rules="[inputRules.required]"
+              />
 
-        <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
-            :loading="saving"
-            :disable="!newDescription.trim()"
-            @click="saveNewRecord"
-          />
-        </q-card-actions>
+              <DefaultSelect
+                v-model="newType"
+                emit-value
+                label="Selecione o Tipo."
+                map-options
+                outlined
+                :options="typeOptions"
+              />
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="cancelNewRecord"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              :disable="!newDescription.trim()"
+              :loading="saving"
+            />
+          </q-card-actions>
+        </DefaultForm>
       </template>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
+import { createHoliday, getHolidays } from "src/api/holiday";
+import { computed, onMounted, ref } from "vue";
 import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+import { permissionStore } from "src/stores/permission";
+
 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 { getHolidays, createHoliday } from "src/api/holiday";
 import FeriadosEditDialog from "src/pages/dashboard/components/FeriadosEditDialog.vue";
-import { permissionStore } from "src/stores/permission";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
+const { dialogRef, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
+
 const $q = useQuasar();
+const { inputRules } = useInputRules();
+
 const permissions = permissionStore();
+
+const currentMonth = ref(new Date().getMonth() + 1);
+const currentYear = ref(new Date().getFullYear());
+const existingHolidays = ref([]);
+const loadingHolidays = ref(false);
+const newDescription = ref("");
+const newType = ref("feriado");
+const saving = ref(false);
+const selectedDay = ref(null);
+const view = ref("calendar");
+
 const canAdd = computed(() =>
   permissions.getAccess("franchisee_dashboard", "add"),
 );
 
-const now = new Date();
-const currentMonth = ref(now.getMonth() + 1);
-const currentYear = ref(now.getFullYear());
+const calendarCells = computed(() => {
+  const daysInMonth = new Date(
+    currentYear.value,
+    currentMonth.value,
+    0,
+  ).getDate();
+
+  const firstDay = new Date(
+    currentYear.value,
+    currentMonth.value - 1,
+    1,
+  ).getDay();
+
+  const holidayDays = new Set(
+    monthHolidays.value.map((holiday) => holiday.day),
+  );
 
-const view = ref("calendar");
-const selectedDay = ref(null);
-const newDescription = ref("");
-const newType = ref("feriado");
+  const cells = [];
 
-const existingHolidays = ref([]);
-const loadingHolidays = ref(false);
-const saving = ref(false);
+  for (let index = 0; index < firstDay; index++) {
+    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(() =>
+  existingHolidays.value
+    .filter((holiday) => {
+      const date = new Date(`${holiday.holiday_date}T00:00:00`);
 
-const weekDays = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
+      return (
+        date.getMonth() + 1 === currentMonth.value &&
+        date.getFullYear() === currentYear.value
+      );
+    })
+    .map((holiday) => {
+      const date = new Date(`${holiday.holiday_date}T00:00:00`);
+
+      return {
+        ...holiday,
+        day: date.getDate(),
+      };
+    })
+    .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 },
@@ -255,142 +380,132 @@ 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 monthHolidays = computed(() =>
-  existingHolidays.value
-    .filter((h) => {
-      const d = new Date(h.holiday_date + "T00:00:00");
-      return (
-        d.getMonth() + 1 === currentMonth.value &&
-        d.getFullYear() === currentYear.value
-      );
-    })
-    .map((h) => {
-      const d = new Date(h.holiday_date + "T00:00:00");
-      return { ...h, day: d.getDate() };
-    })
-    .sort((a, b) => a.day - b.day),
-);
-
-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 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;
-});
+const weekDays = [
+  "Dom",
+  "Seg",
+  "Ter",
+  "Qua",
+  "Qui",
+  "Sex",
+  "Sáb",
+];
 
-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}`;
-});
+const cancelNewRecord = () => {
+  selectedDay.value = null;
+  view.value = "calendar";
+};
 
-function typeLabel(type) {
-  return type === "facultativo" ? "Ponto Facultativo" : "Feriado";
-}
+const loadHolidays = async () => {
+  loadingHolidays.value = true;
 
-function prevMonth() {
-  if (currentMonth.value === 1) {
-    currentMonth.value = 12;
-    currentYear.value -= 1;
-  } else {
-    currentMonth.value -= 1;
+  try {
+    existingHolidays.value = await getHolidays();
+  } catch {
+    $q.notify({
+      message: "Erro ao carregar feriados.",
+      type: "negative",
+    });
+  } finally {
+    loadingHolidays.value = false;
   }
-}
+};
 
-function nextMonth() {
+const nextMonth = () => {
   if (currentMonth.value === 12) {
     currentMonth.value = 1;
     currentYear.value += 1;
-  } else {
-    currentMonth.value += 1;
+
+    return;
   }
-}
 
-function openNewRecord(day) {
-  selectedDay.value = day;
+  currentMonth.value += 1;
+};
+
+const onClose = () => {
+  onDialogOK(true);
+};
+
+const openEdit = (holiday) => {
+  $q.dialog({
+    component: FeriadosEditDialog,
+    componentProps: {
+      holiday,
+    },
+  }).onOk(({ action, holiday: updatedHoliday, id }) => {
+    if (action === "update") {
+      const index = existingHolidays.value.findIndex(
+        (item) => item.id === updatedHoliday.id,
+      );
+
+      if (index !== -1) {
+        existingHolidays.value[index] = {
+          ...existingHolidays.value[index],
+          ...updatedHoliday,
+        };
+      }
+
+      return;
+    }
+
+    if (action === "delete") {
+      existingHolidays.value = existingHolidays.value.filter(
+        (item) => item.id !== id,
+      );
+    }
+  });
+};
+
+const openNewRecord = (day) => {
   newDescription.value = "";
   newType.value = "feriado";
+  selectedDay.value = day;
   view.value = "new-record";
-}
+};
 
-function cancelNewRecord() {
-  view.value = "calendar";
-  selectedDay.value = null;
-}
+const prevMonth = () => {
+  if (currentMonth.value === 1) {
+    currentMonth.value = 12;
+    currentYear.value -= 1;
+
+    return;
+  }
+
+  currentMonth.value -= 1;
+};
 
-async function saveNewRecord() {
-  const desc = newDescription.value.trim();
-  if (!desc || selectedDay.value === null) return;
+const saveNewRecord = async () => {
+  const description = newDescription.value.trim();
 
-  const mm = String(currentMonth.value).padStart(2, "0");
-  const dd = String(selectedDay.value).padStart(2, "0");
-  const dateStr = `${currentYear.value}-${mm}-${dd}`;
+  if (!description || selectedDay.value === null) return;
+
+  const day = String(selectedDay.value).padStart(2, "0");
+  const month = String(currentMonth.value).padStart(2, "0");
 
   saving.value = true;
+
   try {
     await createHoliday({
-      holiday_date: dateStr,
-      description: desc,
+      description,
+      holiday_date: `${currentYear.value}-${month}-${day}`,
       type: newType.value,
     });
+
     await loadHolidays();
-    view.value = "calendar";
+
     selectedDay.value = null;
+    view.value = "calendar";
   } catch {
     $q.notify({
-      type: "negative",
       message: "Erro ao salvar feriado. Tente novamente.",
+      type: "negative",
     });
   } finally {
     saving.value = false;
   }
-}
-
-function openEdit(holiday) {
-  $q.dialog({
-    component: FeriadosEditDialog,
-    componentProps: { holiday },
-  }).onOk(({ action, holiday: updated, id }) => {
-    if (action === "update") {
-      const idx = existingHolidays.value.findIndex((h) => h.id === updated.id);
-      if (idx !== -1) existingHolidays.value[idx] = { ...existingHolidays.value[idx], ...updated };
-    } else if (action === "delete") {
-      existingHolidays.value = existingHolidays.value.filter((h) => h.id !== id);
-    }
-  });
-}
-
-async function loadHolidays() {
-  loadingHolidays.value = true;
-  try {
-    existingHolidays.value = await getHolidays();
-  } catch {
-    $q.notify({ type: "negative", message: "Erro ao carregar feriados." });
-  } finally {
-    loadingHolidays.value = false;
-  }
-}
+};
 
-function onClose() {
-  onDialogOK(true);
-}
+const typeLabel = (type) =>
+  type === "facultativo" ? "Ponto Facultativo" : "Feriado";
 
 onMounted(loadHolidays);
 </script>
@@ -414,10 +529,10 @@ onMounted(loadHolidays);
 }
 
 .cal-cell {
-  aspect-ratio: 1;
   display: flex;
   align-items: center;
   justify-content: center;
+  aspect-ratio: 1;
   border-radius: 50%;
   cursor: pointer;
   transition: background 0.15s;
@@ -432,6 +547,10 @@ onMounted(loadHolidays);
   pointer-events: none;
 }
 
+.cal-cell--holiday {
+  cursor: default;
+}
+
 .cal-cell__number {
   display: flex;
   align-items: center;
@@ -445,12 +564,8 @@ onMounted(loadHolidays);
 }
 
 .cal-cell--holiday .cal-cell__number {
-  background: #e64a19;
   color: #fff;
+  background: #e64a19;
   font-weight: 700;
 }
-
-.cal-cell--holiday {
-  cursor: default;
-}
-</style>
+</style>

+ 181 - 113
src/pages/dashboard/components/FeriadosEditDialog.vue

@@ -1,100 +1,116 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card class="q-dialog-plugin dialog-form-card feriados-edit-dialog">
-      <DefaultDialogHeader :title="dialogTitle" @close="onDialogCancel" />
-
-      <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
-        <q-card-section class="column q-gutter-md q-pt-sm">
-        <q-banner
-          v-if="isReadOnly"
-          dense
-          rounded
-          class="bg-grey-2 text-grey-7"
-          icon="mdi-lock-outline"
-        >
-          Este feriado foi criado pela franqueadora e não pode ser editado.
-        </q-banner>
-
-        <DefaultInput
-          v-model="description"
-          label="Nome do Evento"
-          placeholder="Ex: Ponto facultativo, Natal..."
-          icon="mdi-pencil-outline"
-          outlined
-          :disable="isReadOnly || !canEdit"
-          autofocus
-        />
-
-        <DefaultSelect
-          v-model="type"
-          label="Selecione o Tipo."
-          :options="typeOptions"
-          emit-value
-          map-options
-          outlined
-          :disable="isReadOnly || !canEdit"
-        />
-        </q-card-section>
-      </q-scroll-area>
-
-      <q-separator />
-
-      <template v-if="isReadOnly || !canEdit">
-        <q-card-actions align="right">
-          <q-btn outline color="primary" label="FECHAR" no-caps @click="onDialogCancel" />
-        </q-card-actions>
-      </template>
-
-      <template v-else>
-        <q-card-actions align="between">
-          <q-btn
-            v-if="canDelete"
-            outline
-            color="negative"
-            label="EXCLUIR"
-            no-caps
-            :loading="deleting"
-            @click="confirmDelete"
-          />
-          <div class="row q-gutter-sm">
-            <q-btn outline color="primary" label="CANCELAR" no-caps @click="onDialogCancel" />
+      <DefaultDialogHeader
+        :title="dialogTitle"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm @submit="save">
+        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+          <q-card-section class="column q-gutter-md q-pt-sm">
+            <q-banner
+              v-if="isReadOnly"
+              class="bg-grey-2 text-grey-7"
+              dense
+              icon="mdi-lock-outline"
+              rounded
+            >
+              Este feriado foi criado pela franqueadora e não pode ser editado.
+            </q-banner>
+
+            <DefaultInput
+              v-model="description"
+              autofocus
+              icon="mdi-pencil-outline"
+              label="Nome do Evento"
+              outlined
+              placeholder="Ex: Ponto facultativo, Natal..."
+              :disable="isReadOnly || !canEdit"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-model="type"
+              emit-value
+              label="Selecione o Tipo."
+              map-options
+              outlined
+              :disable="isReadOnly || !canEdit"
+              :options="typeOptions"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <template v-if="isReadOnly || !canEdit">
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
             <q-btn
-              unelevated
               color="primary"
-              label="SALVAR"
+              label="Fechar"
+              no-caps
+              outline
+              @click="onDialogCancel"
+            />
+          </q-card-actions>
+        </template>
+
+        <template v-else>
+          <q-card-actions
+            align="between"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              v-if="canDelete"
+              color="negative"
+              label="Excluir"
               no-caps
-              :disable="!description.trim()"
-              :loading="saving"
-              @click="save"
+              outline
+              :loading="deleting"
+              @click="confirmDelete"
             />
-          </div>
-        </q-card-actions>
-      </template>
+
+            <div class="row q-gutter-sm">
+              <q-btn
+                color="primary"
+                label="Cancelar"
+                no-caps
+                outline
+                @click="onDialogCancel"
+              />
+
+              <q-btn
+                color="primary"
+                label="Salvar"
+                no-caps
+                type="submit"
+                :disable="!description.trim()"
+                :loading="saving"
+              />
+            </div>
+          </q-card-actions>
+        </template>
+      </DefaultForm>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
+import { computed, ref } from "vue";
+import { deleteHoliday, updateHoliday } from "src/api/holiday";
+import { permissionStore } from "src/stores/permission";
 import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+
 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 { updateHoliday, deleteHoliday } from "src/api/holiday";
-import { permissionStore } from "src/stores/permission";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
-const $q = useQuasar();
-const permissions = permissionStore();
-const canEdit = computed(() =>
-  permissions.getAccess("franchisee_dashboard", "edit"),
-);
-const canDelete = computed(() =>
-  permissions.getAccess("franchisee_dashboard", "delete"),
-);
-
 const props = defineProps({
   holiday: {
     type: Object,
@@ -102,63 +118,115 @@ const props = defineProps({
   },
 });
 
-const isReadOnly = computed(() => !!props.holiday.base_holiday_id);
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
 
+const $q = useQuasar();
+const { inputRules } = useInputRules();
+
+const permissions = permissionStore();
+
+const deleting = ref(false);
 const description = ref(props.holiday.description);
-const type = ref(props.holiday.type ?? "feriado");
 const saving = ref(false);
-const deleting = ref(false);
+const type = ref(props.holiday.type ?? "feriado");
 
-const typeOptions = [
-  { label: "Feriado", value: "feriado" },
-  { label: "Ponto Facultativo", value: "facultativo" },
-];
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_dashboard", "delete"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_dashboard", "edit"),
+);
 
 const dialogTitle = computed(() => {
   const [year, month, day] = props.holiday.holiday_date.split("-");
+
   return `${day}/${month}/${year}`;
 });
 
-async function save() {
-  const desc = description.value.trim();
-  if (!desc) return;
-
-  saving.value = true;
-  try {
-    const updated = await updateHoliday(
-      { description: desc, holiday_date: props.holiday.holiday_date, type: type.value },
-      props.holiday.id,
-    );
+const isReadOnly = computed(() => !!props.holiday.base_holiday_id);
 
-    onDialogOK({
-      action: "update",
-      holiday: { ...props.holiday, description: updated.description, type: updated.type },
-    });
-  } catch {
-    $q.notify({ type: "negative", message: "Erro ao salvar feriado." });
-  } finally {
-    saving.value = false;
-  }
-}
+const typeOptions = [
+  {
+    label: "Feriado",
+    value: "feriado",
+  },
+  {
+    label: "Ponto Facultativo",
+    value: "facultativo",
+  },
+];
 
-function confirmDelete() {
+const confirmDelete = () => {
   $q.dialog({
-    title: "Excluir feriado",
+    cancel: {
+      color: "primary",
+      flat: true,
+      label: "Cancelar",
+    },
     message: `Deseja excluir "${props.holiday.description}"?`,
-    cancel: { label: "Cancelar", flat: true, color: "primary" },
-    ok: { label: "Excluir", unelevated: true, color: "negative" },
+    ok: {
+      color: "negative",
+      label: "Excluir",
+      unelevated: true,
+    },
+    title: "Excluir feriado",
   }).onOk(async () => {
     deleting.value = true;
+
     try {
       await deleteHoliday(props.holiday.id);
-      onDialogOK({ action: "delete", id: props.holiday.id });
+
+      onDialogOK({
+        action: "delete",
+        id: props.holiday.id,
+      });
     } catch {
-      $q.notify({ type: "negative", message: "Erro ao excluir feriado." });
+      $q.notify({
+        message: "Erro ao excluir feriado.",
+        type: "negative",
+      });
     } finally {
       deleting.value = false;
     }
   });
-}
+};
+
+const save = async () => {
+  const trimmedDescription = description.value.trim();
+
+  if (!trimmedDescription) return;
+
+  saving.value = true;
+
+  try {
+    const updatedHoliday = await updateHoliday(
+      {
+        description: trimmedDescription,
+        holiday_date: props.holiday.holiday_date,
+        type: type.value,
+      },
+      props.holiday.id,
+    );
+
+    onDialogOK({
+      action: "update",
+      holiday: {
+        ...props.holiday,
+        description: updatedHoliday.description,
+        type: updatedHoliday.type,
+      },
+    });
+  } catch {
+    $q.notify({
+      message: "Erro ao salvar feriado.",
+      type: "negative",
+    });
+  } finally {
+    saving.value = false;
+  }
+};
 </script>
 
 <style scoped>
@@ -166,4 +234,4 @@ function confirmDelete() {
   width: 420px;
   max-width: 95vw;
 }
-</style>
+</style>

+ 359 - 204
src/pages/financial/AccountsPayablePage.vue

@@ -1,188 +1,259 @@
 <template>
   <div>
-    <DefaultHeaderPage title="Contas a Pagar" :show-filter-icon="false" />
+    <DefaultHeaderPage
+      :show-filter-icon="false"
+      title="Contas a Pagar"
+    />
 
     <div class="row q-pa-md q-gutter-md">
       <FinancialCard
-        title="Pago"
-        icon="mdi-check-circle-outline"
         :financial-value="totals.paid"
         :integer="totals.paidCount"
+        icon="mdi-check-circle-outline"
         integer-label="pagamentos"
+        title="Pago"
       />
+
       <FinancialCard
-        title="A Pagar"
-        icon="mdi-cash-minus"
         :financial-value="totals.pending"
         :integer="totals.pendingCount"
+        icon="mdi-cash-minus"
         integer-label="pendentes"
+        title="A Pagar"
       />
+
       <FinancialCard
-        title="Vencidas"
-        icon="mdi-alert-circle-outline"
         :financial-value="totals.overdue"
         :integer="totals.overdueCount"
+        icon="mdi-alert-circle-outline"
         integer-label="em atraso"
+        title="Vencidas"
       />
     </div>
 
     <div class="q-px-md">
       <DefaultTable
         v-model:rows="rows"
-        no-api-call
         :add-item="canAdd"
-        title="Contas a Pagar"
-        description="contas"
-        :female="true"
         :columns="columns"
+        :female="true"
+        description="contas"
+        no-api-call
+        title="Contas a Pagar"
         @on-add-item="openCreate"
       >
         <template #body-cell-origin="{ row }">
           <q-td class="text-left">
             <q-chip
-              :color="row.origin === 'tbr' ? 'deep-purple-5' : 'blue-grey-5'"
-              text-color="white"
+              :color="
+                row.origin === 'tbr'
+                  ? 'deep-purple-5'
+                  : 'blue-grey-5'
+              "
+              :label="row.origin === 'tbr' ? 'TBR' : 'Manual'"
               dense
               square
-              :label="row.origin === 'tbr' ? 'TBR' : 'Manual'"
+              text-color="white"
             />
           </q-td>
         </template>
 
         <template #body-cell-value="{ row }">
-          <q-td class="text-left">{{ formatCurrency(row.value) }}</q-td>
+          <q-td class="text-left">
+            {{ formatCurrency(row.value) }}
+          </q-td>
         </template>
 
         <template #body-cell-status="{ row }">
           <q-td class="text-left">
             <q-btn
               :color="row.status === 'paid' ? 'positive' : 'secondary'"
+              :disable="!canEdit || row.status === 'paid'"
               :label="row.status === 'paid' ? 'Pago' : 'Não pago'"
               dense
               no-caps
               unelevated
-              :disable="!canEdit || row.status === 'paid'"
               @click.stop="changeStatus(row)"
             >
-              <q-tooltip v-if="canEdit && row.status !== 'paid'">Dar baixa</q-tooltip>
+              <q-tooltip v-if="canEdit && row.status !== 'paid'">
+                Dar baixa
+              </q-tooltip>
             </q-btn>
           </q-td>
         </template>
+
         <template #body-cell-actions="{ row }">
           <q-td auto-width>
             <q-btn
               v-if="row.invoice_url"
-              round
+              aria-label="Abrir cobrança"
+              color="teal-7"
               dense
               flat
-              color="teal-7"
               icon="mdi-open-in-new"
-              aria-label="Abrir cobrança"
+              round
               @click="openInvoice(row)"
             >
-              <q-tooltip>Abrir cobrança no Asaas</q-tooltip>
+              <q-tooltip>
+                Abrir cobrança no Asaas
+              </q-tooltip>
             </q-btn>
           </q-td>
         </template>
       </DefaultTable>
     </div>
 
-    <!-- Diálogo de baixa manual -->
     <q-dialog v-model="settleDialog">
-      <q-card class="dialog-form-card" style="min-width: 360px; max-width: 460px">
-        <DefaultDialogHeader title="Dar baixa" @close="settleDialog = false" />
-
-        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
-          <q-card-section class="q-gutter-sm">
-          <div class="text-body2 text-grey-8">{{ selected?.history }}</div>
-
-          <DefaultInputDatePicker
-            v-model="settleForm.payment_date"
-            v-model:untreated-date="settleForm.payment_date_iso"
-            label="Data do pagamento"
-          />
-
-          <div class="row q-col-gutter-sm">
-            <DefaultCurrencyInput
-              v-model="settleForm.discount"
-              label="Desconto"
-              class="col-6"
-              outlined
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 360px; max-width: 460px"
+      >
+        <DefaultDialogHeader
+          title="Dar baixa"
+          @close="settleDialog = false"
+        />
+
+        <DefaultForm @submit="onSettle">
+          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+            <q-card-section class="q-gutter-sm">
+              <div class="text-body2 text-grey-8">
+                {{ selected?.history }}
+              </div>
+
+              <DefaultInputDatePicker
+                v-model="settleForm.payment_date"
+                v-model:untreated-date="settleForm.payment_date_iso"
+                label="Data do pagamento"
+              />
+
+              <div class="row q-col-gutter-sm">
+                <DefaultCurrencyInput
+                  v-model="settleForm.discount"
+                  class="col-6"
+                  label="Desconto"
+                  outlined
+                />
+
+                <DefaultCurrencyInput
+                  v-model="settleForm.fine"
+                  class="col-6"
+                  label="Multa/Juros"
+                  outlined
+                />
+              </div>
+
+              <div class="text-caption text-grey-7">
+                Valor da conta: {{ formatCurrency(selected?.value) }} ·
+
+                <span class="text-weight-medium">
+                  Pago: {{ formatCurrency(settleNetValue) }}
+                </span>
+              </div>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="settleDialog = false"
             />
-            <DefaultCurrencyInput
-              v-model="settleForm.fine"
-              label="Multa/Juros"
-              class="col-6"
-              outlined
+
+            <q-btn
+              color="primary"
+              label="Confirmar baixa"
+              no-caps
+              type="submit"
+              :loading="settling"
             />
-          </div>
-
-          <div class="text-caption text-grey-7">
-            Valor da conta: {{ formatCurrency(selected?.value) }} ·
-            <span class="text-weight-medium">
-              Pago: {{ formatCurrency(settleNetValue) }}
-            </span>
-          </div>
-          </q-card-section>
-        </q-scroll-area>
-
-        <q-card-actions align="right" class="q-pa-md">
-          <q-btn flat label="Cancelar" color="grey-7" @click="settleDialog = false" />
-          <q-btn
-            color="primary-2"
-            label="Confirmar baixa"
-            :loading="settling"
-            @click="onSettle"
-          />
-        </q-card-actions>
+          </q-card-actions>
+        </DefaultForm>
       </q-card>
     </q-dialog>
 
-    <!-- Diálogo de nova conta manual -->
     <q-dialog v-model="createDialog">
-      <q-card class="dialog-form-card" style="min-width: 360px; max-width: 460px">
-        <DefaultDialogHeader title="Nova conta a pagar" @close="createDialog = false" />
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 360px; max-width: 460px"
+      >
+        <DefaultDialogHeader
+          title="Nova conta a pagar"
+          @close="createDialog = false"
+        />
 
-        <DefaultForm ref="createFormRef">
-          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+        <DefaultForm
+          ref="createFormRef"
+          @submit="onCreate"
+        >
+          <q-scroll-area
+            ref="createScrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--sm"
+          >
             <q-card-section class="q-gutter-sm">
-            <DefaultInput
-              v-model="createForm.history"
-              label="Descrição"
-              outlined
-              :rules="[(v) => !!v || 'Informe a descrição']"
-            />
-            <div class="row q-col-gutter-sm">
-              <DefaultCurrencyInput
-                v-model="createForm.value"
-                label="Valor"
-                class="col-6"
+              <DefaultInput
+                v-model="createForm.history"
+                v-model:error="createValidationErrors.history"
+                label="Descrição"
                 outlined
-                :rules="[() => Number(createForm.value) > 0 || 'Informe o valor']"
+                :rules="[inputRules.required]"
               />
-              <DefaultInputDatePicker
-                v-model="createForm.due_date"
-                v-model:untreated-date="createForm.due_date_iso"
-                label="Vencimento"
-                class="col-6"
+
+              <div
+                style="
+                  display: grid;
+                  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
+                  gap: 8px;
+                "
+              >
+                <DefaultCurrencyInput
+                  v-model="createForm.value"
+                  v-model:error="createValidationErrors.value"
+                  label="Valor"
+                  outlined
+                  :rules="[inputRules.required, positiveAmountRule]"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="createForm.due_date"
+                  v-model:error="createValidationErrors.due_date"
+                  v-model:untreated-date="createForm.due_date_iso"
+                  label="Vencimento"
+                  :rules="[inputRules.required]"
+                />
+              </div>
+
+              <DefaultInput
+                v-model="createForm.obs"
+                label="Observação (opcional)"
+                outlined
+                type="textarea"
               />
-            </div>
-            <DefaultInput
-              v-model="createForm.obs"
-              label="Observação (opcional)"
-              type="textarea"
-              outlined
-            />
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-pa-md">
-            <q-btn flat label="Cancelar" color="grey-7" @click="createDialog = false" />
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="createDialog = false"
+            />
+
             <q-btn
               color="primary"
               label="Salvar"
+              no-caps
+              type="submit"
               :loading="creating"
-              @click="onCreate"
             />
           </q-card-actions>
         </DefaultForm>
@@ -192,164 +263,248 @@
 </template>
 
 <script setup>
-import { computed, onMounted, ref } from "vue";
 import {
-  getPayablesMe,
   createPayableMe,
+  getPayablesMe,
   settlePayableMe,
 } from "src/api/unit_payable";
 
+import { computed, onMounted, ref, useTemplateRef } 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 DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import FinancialCard from "src/components/financial/FinancialCard.vue";
-import { permissionStore } from "src/stores/permission";
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
 
 const permissions = permissionStore();
-const canAdd = computed(() =>
-  permissions.getAccess("franchisee_financial", "add"),
-);
-const canEdit = computed(() =>
-  permissions.getAccess("franchisee_financial", "edit"),
-);
 
-const rows = ref([]);
+const createFormRef = useTemplateRef("createFormRef");
+const createScrollAreaRef = useTemplateRef("createScrollAreaRef");
 
-const settleDialog = ref(false);
+const createDialog = ref(false);
+const rows = ref([]);
 const selected = ref(null);
-const settling = ref(false);
-const settleForm = ref({ payment_date: null, payment_date_iso: null, discount: 0, fine: 0 });
+const settleDialog = ref(false);
 
-const createDialog = ref(false);
-const createFormRef = ref(null);
-const creating = ref(false);
-const createForm = ref({ history: "", value: 0, due_date: null, due_date_iso: null, obs: "" });
+const settleForm = ref({
+  discount: 0,
+  fine: 0,
+  payment_date: null,
+  payment_date_iso: null,
+});
 
-const columns = [
-  { name: "origin", label: "Origem", field: "origin", align: "left" },
-  { name: "history", label: "Descrição", field: "history", align: "left" },
-  { name: "value", label: "Valor", field: "value", align: "left" },
-  { name: "due_date", label: "Vencimento", field: "due_date", align: "left" },
-  { name: "status", label: "Status", field: "status", align: "left" },
-  { name: "actions", label: "Ações", field: "actions", align: "right" },
-];
+const settling = ref(false);
 
-function formatCurrency(value) {
-  return new Intl.NumberFormat("pt-BR", {
-    style: "currency",
-    currency: "BRL",
-  }).format(Number(value ?? 0));
-}
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_financial", "add"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_financial", "edit"),
+);
 
 const settleNetValue = computed(() => {
-  const base = Number(selected.value?.value ?? 0);
-  return base - Number(settleForm.value.discount ?? 0) + Number(settleForm.value.fine ?? 0);
+  const baseValue = Number(selected.value?.value ?? 0);
+  const discount = Number(settleForm.value.discount ?? 0);
+  const fine = Number(settleForm.value.fine ?? 0);
+
+  return baseValue - discount + fine;
 });
 
 const totals = computed(() => {
-  const acc = {
+  const accumulatedTotals = {
+    overdue: 0,
+    overdueCount: 0,
     paid: 0,
     paidCount: 0,
     pending: 0,
     pendingCount: 0,
-    overdue: 0,
-    overdueCount: 0,
   };
-  for (const r of rows.value) {
-    if (r.status === "paid") {
-      acc.paid += Number(r.paid_value ?? 0);
-      acc.paidCount++;
-    } else if (r.status === "overdue") {
-      acc.overdue += Number(r.value ?? 0);
-      acc.overdueCount++;
-    } else if (r.status === "pending") {
-      acc.pending += Number(r.value ?? 0);
-      acc.pendingCount++;
+
+  for (const row of rows.value) {
+    if (row.status === "paid") {
+      accumulatedTotals.paid += Number(row.paid_value ?? 0);
+      accumulatedTotals.paidCount += 1;
+
+      continue;
+    }
+
+    if (row.status === "overdue") {
+      accumulatedTotals.overdue += Number(row.value ?? 0);
+      accumulatedTotals.overdueCount += 1;
+
+      continue;
+    }
+
+    if (row.status === "pending") {
+      accumulatedTotals.pending += Number(row.value ?? 0);
+      accumulatedTotals.pendingCount += 1;
     }
   }
-  return acc;
+
+  return accumulatedTotals;
 });
 
-async function loadData() {
-  try {
-    rows.value = await getPayablesMe();
-  } catch (e) {
-    console.error("Erro ao buscar contas a pagar:", e);
-  }
-}
+const columns = [
+  {
+    align: "left",
+    field: "origin",
+    label: "Origem",
+    name: "origin",
+  },
+  {
+    align: "left",
+    field: "history",
+    label: "Descrição",
+    name: "history",
+  },
+  {
+    align: "left",
+    field: "value",
+    label: "Valor",
+    name: "value",
+  },
+  {
+    align: "left",
+    field: "due_date",
+    label: "Vencimento",
+    name: "due_date",
+  },
+  {
+    align: "left",
+    field: "status",
+    label: "Status",
+    name: "status",
+  },
+  {
+    align: "right",
+    field: "actions",
+    label: "Ações",
+    name: "actions",
+  },
+];
 
-function openSettle(row) {
+const defaultCreateForm = () => {
   const today = new Date();
-  selected.value = row;
-  settleForm.value = {
-    payment_date: today.toLocaleDateString("pt-BR"),
-    payment_date_iso: today.toISOString().slice(0, 10),
-    discount: Number(row.discount ?? 0),
-    fine: Number(row.fine ?? 0),
+
+  return {
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: today.toISOString().slice(0, 10),
+    history: "",
+    obs: "",
+    value: 0,
   };
-  settleDialog.value = true;
-}
+};
 
-function changeStatus(row) {
-  if (row.status !== "paid") openSettle(row);
-}
+const { form: createForm } = useForm(defaultCreateForm());
 
-function openInvoice(row) {
-  window.open(row.invoice_url, "_blank");
-}
+const {
+  loading: creating,
+  validationErrors: createValidationErrors,
+  execute: executeCreate,
+} = useSubmitHandler({
+  containerRef: createScrollAreaRef,
+  formRef: createFormRef,
+  onSuccess: async () => {
+    createDialog.value = false;
+
+    await loadData();
+  },
+  scrollFn: scrollToComponent,
+});
+
+const changeStatus = (row) => {
+  if (row.status === "paid") return;
+
+  openSettle(row);
+};
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", {
+    currency: "BRL",
+    style: "currency",
+  }).format(Number(value ?? 0));
+
+const loadData = async () => {
+  try {
+    rows.value = await getPayablesMe();
+  } catch (error) {
+    console.error("Erro ao buscar contas a pagar:", error);
+  }
+};
+
+const onCreate = async () => {
+  await executeCreate(() =>
+    createPayableMe({
+      due_date: createForm.due_date_iso,
+      history: createForm.history,
+      obs: createForm.obs || null,
+      value: createForm.value,
+    }),
+  );
+};
 
-async function onSettle() {
+const onSettle = async () => {
   if (!selected.value) return;
+
   settling.value = true;
+
   try {
     await settlePayableMe(selected.value.id, {
-      payment_date: settleForm.value.payment_date_iso,
       discount: settleForm.value.discount,
       fine: settleForm.value.fine,
+      payment_date: settleForm.value.payment_date_iso,
     });
+
     settleDialog.value = false;
+
     await loadData();
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   } finally {
     settling.value = false;
   }
-}
+};
+
+const openCreate = () => {
+  Object.assign(createForm, defaultCreateForm());
+
+  createDialog.value = true;
+};
 
-function openCreate() {
+const openInvoice = (row) => {
+  window.open(row.invoice_url, "_blank");
+};
+
+const openSettle = (row) => {
   const today = new Date();
-  createForm.value = {
-    history: "",
-    value: 0,
-    due_date: today.toLocaleDateString("pt-BR"),
-    due_date_iso: today.toISOString().slice(0, 10),
-    obs: "",
+
+  selected.value = row;
+
+  settleForm.value = {
+    discount: Number(row.discount ?? 0),
+    fine: Number(row.fine ?? 0),
+    payment_date: today.toLocaleDateString("pt-BR"),
+    payment_date_iso: today.toISOString().slice(0, 10),
   };
-  createDialog.value = true;
-}
 
-async function onCreate() {
-  const valid = await createFormRef.value?.validate();
-  if (!valid) return;
-  creating.value = true;
-  try {
-    await createPayableMe({
-      history: createForm.value.history,
-      value: createForm.value.value,
-      due_date: createForm.value.due_date_iso,
-      obs: createForm.value.obs || null,
-    });
-    createDialog.value = false;
-    await loadData();
-  } catch (e) {
-    console.error(e);
-  } finally {
-    creating.value = false;
-  }
-}
+  settleDialog.value = true;
+};
+
+const positiveAmountRule = (value) =>
+  Number(value) > 0 || "Informe um valor maior que zero.";
 
 onMounted(loadData);
-</script>
+</script>

+ 450 - 273
src/pages/financial/AccountsReceivablePage.vue

@@ -1,76 +1,89 @@
 <template>
   <div>
-    <DefaultHeaderPage title="Contas a Receber" :show-filter-icon="false" />
+    <DefaultHeaderPage
+      :show-filter-icon="false"
+      title="Contas a Receber"
+    />
 
     <div class="row q-pa-md q-gutter-md">
       <FinancialCard
-        title="Recebido"
-        icon="mdi-check-circle-outline"
         :financial-value="totals.received"
         :integer="totals.receivedCount"
+        icon="mdi-check-circle-outline"
         integer-label="recebimentos"
+        title="Recebido"
       />
+
       <FinancialCard
-        title="A Receber"
-        icon="mdi-cash-plus"
         :financial-value="totals.pending"
         :integer="totals.pendingCount"
+        icon="mdi-cash-plus"
         integer-label="pendentes"
+        title="A Receber"
       />
+
       <FinancialCard
-        title="Vencidas"
-        icon="mdi-alert-circle-outline"
         :financial-value="totals.overdue"
         :integer="totals.overdueCount"
+        icon="mdi-alert-circle-outline"
         integer-label="em atraso"
+        title="Vencidas"
       />
     </div>
 
     <div class="q-px-md">
       <DefaultTable
         v-model:rows="rows"
-        no-api-call
         :add-item="canAdd"
-        title="Contas a Receber"
-        description="contas"
-        :female="true"
         :columns="columns"
+        :female="true"
+        description="contas"
+        no-api-call
+        title="Contas a Receber"
         @on-add-item="openCreate"
       >
         <template #body-cell-value="{ row }">
-          <q-td class="text-left">{{ formatCurrency(row.value) }}</q-td>
+          <q-td class="text-left">
+            {{ formatCurrency(row.value) }}
+          </q-td>
         </template>
 
         <template #body-cell-status="{ row }">
           <q-td class="text-left">
             <q-btn
               :color="row.status === 'paid' ? 'positive' : 'secondary'"
+              :disable="!canEdit || row.status === 'paid'"
               :label="row.status === 'paid' ? 'Pago' : 'Não pago'"
               dense
               no-caps
               unelevated
-              :disable="!canEdit || row.status === 'paid'"
               @click.stop="changeStatus(row)"
             >
-              <q-tooltip v-if="canEdit && row.status !== 'paid'">Dar baixa</q-tooltip>
+              <q-tooltip v-if="canEdit && row.status !== 'paid'">
+                Dar baixa
+              </q-tooltip>
             </q-btn>
           </q-td>
         </template>
 
         <template #body-cell-actions="{ row }">
           <q-td auto-width>
-            <div class="row no-wrap justify-end items-center" style="gap: 6px">
+            <div
+              class="row no-wrap justify-end items-center"
+              style="gap: 6px"
+            >
               <q-btn
                 v-if="row.invoice_url"
+                class="q-px-sm"
                 color="teal-7"
-                label="Ver boleto"
-                icon="mdi-barcode"
                 dense
+                icon="mdi-barcode"
+                label="Ver boleto"
                 no-caps
                 outline
-                class="q-px-sm"
                 @click="openInvoice(row)"
               />
+
               <q-btn
                 v-else-if="
                   canEdit &&
@@ -78,16 +91,18 @@
                   row.status !== 'paid' &&
                   row.status !== 'cancelled'
                 "
+                aria-label="Gerar cobrança"
                 color="deep-purple-5"
-                icon="mdi-cash-fast"
-                round
                 dense
+                icon="mdi-cash-fast"
                 outline
-                aria-label="Gerar cobrança"
+                round
                 :loading="chargingId === row.id"
                 @click="onCharge(row)"
               >
-                <q-tooltip>Gerar cobrança no Asaas</q-tooltip>
+                <q-tooltip>
+                  Gerar cobrança no Asaas
+                </q-tooltip>
               </q-btn>
             </div>
           </q-td>
@@ -95,364 +110,526 @@
       </DefaultTable>
     </div>
 
-    <!-- Diálogo de novo recebível avulso -->
     <q-dialog v-model="createDialog">
-      <q-card class="dialog-form-card" style="min-width: 380px; max-width: 480px">
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 380px; max-width: 480px"
+      >
         <DefaultDialogHeader
           title="Nova conta a receber"
           @close="createDialog = false"
         />
 
-        <DefaultForm ref="createFormRef">
-          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--md">
+        <DefaultForm
+          ref="createFormRef"
+          @submit="onCreate"
+        >
+          <q-scroll-area
+            ref="createScrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--md"
+          >
             <q-card-section class="q-gutter-sm">
-            <DefaultInput
-              v-model="createForm.history"
-              label="Descrição"
-              outlined
-              :rules="[(v) => !!v || 'Informe a descrição']"
-            />
-            <div class="row q-col-gutter-sm">
-              <DefaultCurrencyInput
-                v-model="createForm.value"
-                label="Valor"
-                class="col-6"
+              <DefaultInput
+                v-model="createForm.history"
+                v-model:error="createValidationErrors.history"
+                label="Descrição"
                 outlined
-                :rules="[() => Number(createForm.value) > 0 || 'Informe o valor']"
+                :rules="[inputRules.required]"
               />
-              <DefaultInputDatePicker
-                v-model="createForm.due_date"
-                v-model:untreated-date="createForm.due_date_iso"
-                label="Vencimento"
-                class="col-6"
-                :rules="[(v) => !!v || 'Informe o vencimento']"
+
+              <div
+                style="
+                  display: grid;
+                  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
+                  gap: 8px;
+                "
+              >
+                <DefaultCurrencyInput
+                  v-model="createForm.value"
+                  v-model:error="createValidationErrors.value"
+                  label="Valor"
+                  outlined
+                  :rules="[inputRules.required, positiveAmountRule]"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="createForm.due_date"
+                  v-model:error="createValidationErrors.due_date"
+                  v-model:untreated-date="createForm.due_date_iso"
+                  label="Vencimento"
+                  :rules="[inputRules.required]"
+                />
+              </div>
+
+              <DefaultSelect
+                v-if="canViewStudents"
+                v-model="createForm.student_id"
+                clearable
+                emit-value
+                label="Aluno (opcional)"
+                map-options
+                outlined
+                :loading="loadingAux"
+                :options="studentOptions"
+              />
+
+              <DefaultSelect
+                v-model="createForm.financial_plan_account_id"
+                clearable
+                emit-value
+                label="Plano de contas (opcional)"
+                map-options
+                outlined
+                :loading="loadingAux"
+                :options="planAccountOptions"
+              />
+
+              <DefaultInput
+                v-model="createForm.obs"
+                autogrow
+                label="Observação (opcional)"
+                outlined
+                type="textarea"
               />
-            </div>
-            <DefaultSelect
-              v-if="canViewStudents"
-              v-model="createForm.student_id"
-              label="Aluno (opcional)"
-              :options="studentOptions"
-              outlined
-              clearable
-              emit-value
-              map-options
-              :loading="loadingAux"
-            />
-            <DefaultSelect
-              v-model="createForm.financial_plan_account_id"
-              label="Plano de contas (opcional)"
-              :options="planAccountOptions"
-              outlined
-              clearable
-              emit-value
-              map-options
-              :loading="loadingAux"
-            />
-            <DefaultInput
-              v-model="createForm.obs"
-              label="Observação (opcional)"
-              type="textarea"
-              autogrow
-              outlined
-            />
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-pa-md">
-            <q-btn flat label="Cancelar" color="grey-7" @click="createDialog = false" />
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="createDialog = false"
+            />
+
             <q-btn
               color="primary"
               label="Salvar"
+              no-caps
+              type="submit"
               :loading="creating"
-              @click="onCreate"
             />
           </q-card-actions>
         </DefaultForm>
       </q-card>
     </q-dialog>
 
-    <!-- Diálogo de baixa manual -->
     <q-dialog v-model="settleDialog">
-      <q-card class="dialog-form-card" style="min-width: 360px; max-width: 460px">
-        <DefaultDialogHeader title="Dar baixa" @close="settleDialog = false" />
-
-        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
-          <q-card-section class="q-gutter-sm">
-          <div class="text-body2 text-grey-8">
-            {{ selected?.student_name }} — {{ selected?.history }}
-            ({{ selected?.order }})
-          </div>
-
-          <DefaultInputDatePicker
-            v-model="settleForm.payment_date"
-            v-model:untreated-date="settleForm.payment_date_iso"
-            label="Data do pagamento"
-          />
-
-          <div class="row q-col-gutter-sm">
-            <DefaultCurrencyInput
-              v-model="settleForm.discount"
-              label="Desconto"
-              class="col-6"
-              outlined
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 360px; max-width: 460px"
+      >
+        <DefaultDialogHeader
+          title="Dar baixa"
+          @close="settleDialog = false"
+        />
+
+        <DefaultForm @submit="onSettle">
+          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+            <q-card-section class="q-gutter-sm">
+              <div class="text-body2 text-grey-8">
+                {{ selected?.student_name }} — {{ selected?.history }}
+                ({{ selected?.order }})
+              </div>
+
+              <DefaultInputDatePicker
+                v-model="settleForm.payment_date"
+                v-model:untreated-date="settleForm.payment_date_iso"
+                label="Data do pagamento"
+              />
+
+              <div class="row q-col-gutter-sm">
+                <DefaultCurrencyInput
+                  v-model="settleForm.discount"
+                  class="col-6"
+                  label="Desconto"
+                  outlined
+                />
+
+                <DefaultCurrencyInput
+                  v-model="settleForm.fine"
+                  class="col-6"
+                  label="Multa/Juros"
+                  outlined
+                />
+              </div>
+
+              <div class="text-caption text-grey-7">
+                Valor da parcela: {{ formatCurrency(selected?.value) }} ·
+
+                <span class="text-weight-medium">
+                  Recebido: {{ formatCurrency(settleNetValue) }}
+                </span>
+              </div>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="settleDialog = false"
             />
-            <DefaultCurrencyInput
-              v-model="settleForm.fine"
-              label="Multa/Juros"
-              class="col-6"
-              outlined
+
+            <q-btn
+              color="primary"
+              label="Confirmar baixa"
+              no-caps
+              type="submit"
+              :loading="settling"
             />
-          </div>
-
-          <div class="text-caption text-grey-7">
-            Valor da parcela: {{ formatCurrency(selected?.value) }} ·
-            <span class="text-weight-medium">
-              Recebido: {{ formatCurrency(settleNetValue) }}
-            </span>
-          </div>
-          </q-card-section>
-        </q-scroll-area>
-
-        <q-card-actions align="right" class="q-pa-md">
-          <q-btn flat label="Cancelar" color="grey-7" @click="settleDialog = false" />
-          <q-btn
-            color="primary-2"
-            label="Confirmar baixa"
-            :loading="settling"
-            @click="onSettle"
-          />
-        </q-card-actions>
+          </q-card-actions>
+        </DefaultForm>
       </q-card>
     </q-dialog>
   </div>
 </template>
 
 <script setup>
-import { computed, onMounted, ref } from "vue";
 import {
-  getReceivablesMe,
-  settleReceivableMe,
   chargeReceivableMe,
   createManualReceivableMe,
+  getReceivablesMe,
   settleManualReceivableMe,
+  settleReceivableMe,
 } from "src/api/unit_receivable";
-import { getStudentsForSelect } from "src/api/student";
+
+import { computed, onMounted, ref, useTemplateRef } from "vue";
 import { getPlanAccountsForSelect } from "src/api/financial_plan_account";
+import { getStudentsForSelect } from "src/api/student";
 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 DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import FinancialCard from "src/components/financial/FinancialCard.vue";
 
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
 const permissions = permissionStore();
+
+const createFormRef = useTemplateRef("createFormRef");
+const createScrollAreaRef = useTemplateRef("createScrollAreaRef");
+
+const chargingId = ref(null);
+const createDialog = ref(false);
+const loadingAux = ref(false);
+const planAccountOptions = ref([]);
+const rows = ref([]);
+const selected = ref(null);
+const settleDialog = ref(false);
+
+const settleForm = ref({
+  discount: 0,
+  fine: 0,
+  payment_date: null,
+  payment_date_iso: null,
+});
+
+const settling = ref(false);
+const studentOptions = ref([]);
+
 const canAdd = computed(() =>
   permissions.getAccess("franchisee_financial", "add"),
 );
+
 const canEdit = computed(() =>
   permissions.getAccess("franchisee_financial", "edit"),
 );
 
-const rows = ref([]);
-const settleDialog = ref(false);
-const selected = ref(null);
-const settling = ref(false);
-const chargingId = ref(null);
+const settleNetValue = computed(() => {
+  const baseValue = Number(selected.value?.value ?? 0);
+  const discount = Number(settleForm.value.discount ?? 0);
+  const fine = Number(settleForm.value.fine ?? 0);
 
-const settleForm = ref({ payment_date: null, payment_date_iso: null, discount: 0, fine: 0 });
+  return baseValue - discount + fine;
+});
+
+const totals = computed(() => {
+  const accumulatedTotals = {
+    overdue: 0,
+    overdueCount: 0,
+    pending: 0,
+    pendingCount: 0,
+    received: 0,
+    receivedCount: 0,
+  };
+
+  for (const row of rows.value) {
+    if (row.status === "paid") {
+      accumulatedTotals.received += Number(row.paid_value ?? 0);
+      accumulatedTotals.receivedCount += 1;
+
+      continue;
+    }
+
+    if (row.status === "overdue") {
+      accumulatedTotals.overdue += Number(row.value ?? 0);
+      accumulatedTotals.overdueCount += 1;
+
+      continue;
+    }
+
+    if (row.status === "pending") {
+      accumulatedTotals.pending += Number(row.value ?? 0);
+      accumulatedTotals.pendingCount += 1;
+    }
+  }
+
+  return accumulatedTotals;
+});
+
+const columns = [
+  {
+    align: "left",
+    field: "student_name",
+    label: "Aluno",
+    name: "student_name",
+  },
+  {
+    align: "left",
+    field: "history",
+    label: "Descrição",
+    name: "history",
+  },
+  {
+    align: "left",
+    field: "order",
+    label: "Parcela",
+    name: "order",
+  },
+  {
+    align: "left",
+    field: "value",
+    label: "Valor",
+    name: "value",
+  },
+  {
+    align: "left",
+    field: "due_date",
+    label: "Vencimento",
+    name: "due_date",
+  },
+  {
+    align: "left",
+    field: "status",
+    label: "Status",
+    name: "status",
+  },
+  {
+    align: "right",
+    field: "actions",
+    label: "Ações",
+    name: "actions",
+  },
+];
 
-// Novo recebível avulso
-const createDialog = ref(false);
-const creating = ref(false);
-const createFormRef = ref(null);
-const loadingAux = ref(false);
-const studentOptions = ref([]);
-const planAccountOptions = ref([]);
 const defaultCreateForm = () => {
   const today = new Date();
+
   return {
-    history: "",
-    value: 0,
     due_date: today.toLocaleDateString("pt-BR"),
     due_date_iso: today.toISOString().slice(0, 10),
-    student_id: null,
     financial_plan_account_id: null,
+    history: "",
     obs: "",
+    student_id: null,
+    value: 0,
   };
 };
-const createForm = ref(defaultCreateForm());
 
-const columns = [
-  { name: "student_name", label: "Aluno", field: "student_name", align: "left" },
-  { name: "history", label: "Descrição", field: "history", align: "left" },
-  { name: "order", label: "Parcela", field: "order", align: "left" },
-  { name: "value", label: "Valor", field: "value", align: "left" },
-  { name: "due_date", label: "Vencimento", field: "due_date", align: "left" },
-  { name: "status", label: "Status", field: "status", align: "left" },
-  { name: "actions", label: "Ações", field: "actions", align: "right" },
-];
+const { form: createForm } = useForm(defaultCreateForm());
 
-function formatCurrency(value) {
-  return new Intl.NumberFormat("pt-BR", {
-    style: "currency",
-    currency: "BRL",
-  }).format(Number(value ?? 0));
-}
+const {
+  loading: creating,
+  validationErrors: createValidationErrors,
+  execute: executeCreate,
+} = useSubmitHandler({
+  containerRef: createScrollAreaRef,
+  formRef: createFormRef,
+  onSuccess: async () => {
+    createDialog.value = false;
 
-const settleNetValue = computed(() => {
-  const base = Number(selected.value?.value ?? 0);
-  return base - Number(settleForm.value.discount ?? 0) + Number(settleForm.value.fine ?? 0);
+    await loadData();
+  },
+  scrollFn: scrollToComponent,
 });
 
-const totals = computed(() => {
-  const acc = {
-    received: 0,
-    receivedCount: 0,
-    pending: 0,
-    pendingCount: 0,
-    overdue: 0,
-    overdueCount: 0,
-  };
-  for (const r of rows.value) {
-    if (r.status === "paid") {
-      acc.received += Number(r.paid_value ?? 0);
-      acc.receivedCount++;
-    } else if (r.status === "overdue") {
-      acc.overdue += Number(r.value ?? 0);
-      acc.overdueCount++;
-    } else if (r.status === "pending") {
-      acc.pending += Number(r.value ?? 0);
-      acc.pendingCount++;
-    }
-  }
-  return acc;
-});
+const changeStatus = (row) => {
+  if (row.status === "paid") return;
 
-async function loadData() {
-  try {
-    const data = await getReceivablesMe();
-    // Chave composta: parcelas e avulsos podem ter o mesmo id numérico.
-    // realId é o id verdadeiro usado nas ações (baixa/reabrir/cobrança).
-    rows.value = (data ?? []).map((r) => ({
-      ...r,
-      realId: r.id,
-      id: `${r.source}-${r.id}`,
-    }));
-  } catch (e) {
-    console.error("Erro ao buscar contas a receber:", e);
+  openSettle(row);
+};
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", {
+    currency: "BRL",
+    style: "currency",
+  }).format(Number(value ?? 0));
+
+const loadAux = async () => {
+  if (
+    studentOptions.value.length ||
+    planAccountOptions.value.length
+  ) {
+    return;
   }
-}
 
-async function loadAux() {
-  if (studentOptions.value.length || planAccountOptions.value.length) return;
   loadingAux.value = true;
+
   try {
     const [students, planAccounts] = await Promise.all([
       getStudentsForSelect(),
       getPlanAccountsForSelect(),
     ]);
-    studentOptions.value = (students ?? []).map((s) => ({
-      label: s.name,
-      value: s.id,
+
+    studentOptions.value = (students ?? []).map((student) => ({
+      label: student.name,
+      value: student.id,
     }));
-    planAccountOptions.value = (planAccounts ?? []).map((a) => ({
-      label: `${a.code} — ${a.description}`,
-      value: a.id,
+
+    planAccountOptions.value = (planAccounts ?? []).map((account) => ({
+      label: `${account.code} — ${account.description}`,
+      value: account.id,
     }));
-  } catch (e) {
-    console.error("Erro ao carregar dados auxiliares:", e);
+  } catch (error) {
+    console.error("Erro ao carregar dados auxiliares:", error);
   } finally {
     loadingAux.value = false;
   }
-}
+};
 
-function openCreate() {
-  createForm.value = defaultCreateForm();
-  createDialog.value = true;
-  loadAux();
-}
+const loadData = async () => {
+  try {
+    const data = await getReceivablesMe();
+
+    // chave composta: parcelas e avulsos podem ter o mesmo id numerico.
+    // realId e o id verdadeiro usado nas acoes (baixa/reabrir/cobranca).
+
+    rows.value = (data ?? []).map((row) => ({
+      ...row,
+      id: `${row.source}-${row.id}`,
+      realId: row.id,
+    }));
+  } catch (error) {
+    console.error("Erro ao buscar contas a receber:", error);
+  }
+};
+
+const onCharge = async (row) => {
+  chargingId.value = row.id;
 
-async function onCreate() {
-  const valid = await createFormRef.value?.validate();
-  if (!valid) return;
-  creating.value = true;
   try {
-    await createManualReceivableMe({
-      history: createForm.value.history,
-      value: createForm.value.value,
-      due_date: createForm.value.due_date_iso,
-      student_id: createForm.value.student_id || null,
-      financial_plan_account_id: createForm.value.financial_plan_account_id || null,
-      obs: createForm.value.obs || null,
-    });
-    createDialog.value = false;
+    await chargeReceivableMe(row.realId);
+
     await loadData();
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   } finally {
-    creating.value = false;
+    chargingId.value = null;
   }
-}
+};
 
-function openSettle(row) {
-  const today = new Date();
-  selected.value = row;
-  settleForm.value = {
-    payment_date: today.toLocaleDateString("pt-BR"),
-    payment_date_iso: today.toISOString().slice(0, 10),
-    discount: Number(row.discount ?? 0),
-    fine: Number(row.fine ?? 0),
-  };
-  settleDialog.value = true;
-}
+const onCreate = async () => {
+  await executeCreate(() =>
+    createManualReceivableMe({
+      due_date: createForm.due_date_iso,
 
-function changeStatus(row) {
-  if (row.status !== "paid") openSettle(row);
-}
+      financial_plan_account_id:
+        createForm.financial_plan_account_id || null,
+
+      history: createForm.history,
+      obs: createForm.obs || null,
+      student_id: createForm.student_id || null,
+      value: createForm.value,
+    }),
+  );
+};
 
-async function onSettle() {
+const onSettle = async () => {
   if (!selected.value) return;
-  settling.value = true;
+
   const payload = {
-    payment_date: settleForm.value.payment_date_iso,
     discount: settleForm.value.discount,
     fine: settleForm.value.fine,
+    payment_date: settleForm.value.payment_date_iso,
   };
+
+  settling.value = true;
+
   try {
     if (selected.value.source === "manual") {
-      await settleManualReceivableMe(selected.value.realId, payload);
+      await settleManualReceivableMe(
+        selected.value.realId,
+        payload,
+      );
     } else {
-      await settleReceivableMe(selected.value.realId, payload);
+      await settleReceivableMe(
+        selected.value.realId,
+        payload,
+      );
     }
+
     settleDialog.value = false;
+
     await loadData();
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   } finally {
     settling.value = false;
   }
-}
+};
 
-async function onCharge(row) {
-  chargingId.value = row.id;
-  try {
-    await chargeReceivableMe(row.realId);
-    await loadData();
-  } catch (e) {
-    console.error(e);
-  } finally {
-    chargingId.value = null;
-  }
-}
+const openCreate = () => {
+  Object.assign(createForm, defaultCreateForm());
+
+  createDialog.value = true;
+
+  loadAux();
+};
+
+const openInvoice = (row) => {
+  if (!row.invoice_url) return;
+
+  window.open(row.invoice_url, "_blank");
+};
+
+const openSettle = (row) => {
+  const today = new Date();
+
+  selected.value = row;
+
+  settleForm.value = {
+    discount: Number(row.discount ?? 0),
+    fine: Number(row.fine ?? 0),
+    payment_date: today.toLocaleDateString("pt-BR"),
+    payment_date_iso: today.toISOString().slice(0, 10),
+  };
+
+  settleDialog.value = true;
+};
 
-function openInvoice(row) {
-  if (row.invoice_url) window.open(row.invoice_url, "_blank");
-}
+const positiveAmountRule = (value) =>
+  Number(value) > 0 || "Informe um valor maior que zero.";
 
 onMounted(loadData);
-</script>
+</script>

+ 409 - 197
src/pages/financial/ChartOfAccountsPage.vue

@@ -1,108 +1,200 @@
 <template>
   <div>
-    <DefaultHeaderPage title="Plano de Contas" :show-filter-icon="false" />
+    <DefaultHeaderPage
+      :show-filter-icon="false"
+      title="Plano de Contas"
+    />
 
     <div class="q-px-md">
-      <q-card flat bordered class="q-pa-md q-mt-md">
+      <q-card
+        bordered
+        class="q-pa-md q-mt-md"
+        flat
+      >
         <div class="row items-center q-gutter-md q-mb-md">
-          <div><div class="text-h6">Plano de Contas</div><div class="text-body2">{{ rows.length }} contas cadastradas</div></div>
+          <div>
+            <div class="text-h6">
+              Plano de Contas
+            </div>
+
+            <div class="text-body2">
+              {{ rows.length }} contas cadastradas
+            </div>
+          </div>
+
           <q-space />
-          <q-btn v-if="canAdd" color="secondary" label="Nova Conta" no-caps unelevated @click="openCreate" />
+
+          <q-btn
+            v-if="canAdd"
+            color="secondary"
+            label="Nova Conta"
+            no-caps
+            unelevated
+            @click="openCreate"
+          />
         </div>
-        <DefaultInput v-model="search" dense clearable debounce="250" label="Buscar por código ou nome" class="q-mb-md">
-          <template #prepend><q-icon name="mdi-magnify" color="grey-6" /></template>
+
+        <DefaultInput
+          v-model="search"
+          class="q-mb-md"
+          clearable
+          debounce="250"
+          dense
+          label="Buscar por código ou nome"
+        >
+          <template #prepend>
+            <q-icon
+              color="grey-6"
+              name="mdi-magnify"
+            />
+          </template>
         </DefaultInput>
-        <div class="account-tree-header"><div>Código</div><div>Nome da Conta</div><div>Tipo</div><div>Ações</div></div>
+
+        <div class="account-tree-header">
+          <div>Código</div>
+          <div>Nome da Conta</div>
+          <div>Tipo</div>
+          <div>Ações</div>
+        </div>
+
         <q-list>
           <FinancialPlanAccountTreeNode
             v-for="node in filteredTree"
             :key="node.id"
-            :node="node"
-            :force-expanded="!!normalizedSearch"
-            :can-edit="canEdit"
             :can-delete="canDelete"
-            @edit="openEdit"
+            :can-edit="canEdit"
+            :force-expanded="!!normalizedSearch"
+            :node="node"
             @delete="onDelete"
+            @edit="openEdit"
           />
         </q-list>
-        <div v-if="filteredTree.length === 0" class="text-center q-pa-lg">Nenhuma conta encontrada.</div>
-        <q-inner-loading :showing="loadingParents" color="secondary" />
+
+        <div
+          v-if="filteredTree.length === 0"
+          class="text-center q-pa-lg"
+        >
+          Nenhuma conta encontrada.
+        </div>
+
+        <q-inner-loading
+          color="secondary"
+          :showing="loadingParents"
+        />
       </q-card>
     </div>
 
     <q-dialog v-model="dialog">
-      <q-card class="dialog-form-card" style="min-width: 360px; max-width: 460px">
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 360px; max-width: 460px"
+      >
         <DefaultDialogHeader
           :title="editing ? 'Editar conta' : 'Nova conta'"
           @close="dialog = false"
         />
 
-        <DefaultForm ref="formRef">
-          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--md">
+        <DefaultForm
+          ref="formRef"
+          @submit="onSave"
+        >
+          <q-scroll-area
+            ref="scrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--md"
+          >
             <q-card-section class="q-gutter-sm">
-            <q-option-group
-              v-model="accountKind"
-              :options="kindOptions"
-              color="primary"
-              inline
-              dense
-            />
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="form.code"
-                label="Código (ex.: 1.1.01)"
-                :class="accountKind === 'parent' ? 'col-5' : 'col-12'"
-                outlined
-                :rules="[(v) => !!v || 'Informe o código']"
+              <q-option-group
+                v-model="accountKind"
+                color="primary"
+                dense
+                inline
+                :options="kindOptions"
               />
+
+              <div
+                style="display: grid; gap: 8px"
+                :style="{
+                  gridTemplateColumns:
+                    accountKind === 'parent'
+                      ? 'minmax(0, 7fr) minmax(0, 5fr)'
+                      : 'minmax(0, 1fr)',
+                }"
+              >
+                <DefaultInput
+                  v-model="form.code"
+                  v-model:error="validationErrors.code"
+                  label="Código (ex.: 1.1.01)"
+                  outlined
+                  :rules="[inputRules.required]"
+                />
+
+                <DefaultSelect
+                  v-if="accountKind === 'parent'"
+                  v-model="form.chart_type"
+                  v-model:error="validationErrors.chart_type"
+                  emit-value
+                  label="Tipo"
+                  map-options
+                  outlined
+                  :options="chartTypeOptions"
+                  :rules="[inputRules.required]"
+                />
+              </div>
+
               <DefaultSelect
-                v-if="accountKind === 'parent'"
-                v-model="form.chart_type"
-                label="Tipo"
-                :options="chartTypeOptions"
-                class="col-7"
-                outlined
+                v-if="accountKind === 'child'"
+                v-model="form.parent_id"
+                v-model:error="validationErrors.parent_id"
                 emit-value
+                label="Conta Pai"
                 map-options
-                :rules="[(v) => !!v || 'Selecione o tipo']"
+                option-label="label"
+                option-value="id"
+                outlined
+                :loading="loadingParents"
+                :options="parentOptions"
+                :rules="[inputRules.required]"
               />
-            </div>
-            <DefaultSelect
-              v-if="accountKind === 'child'"
-              v-model="form.parent_id"
-              label="Conta Pai"
-              :options="parentOptions"
-              option-value="id"
-              option-label="label"
-              outlined
-              emit-value
-              map-options
-              :loading="loadingParents"
-              :rules="[(v) => !!v || 'Selecione a conta pai']"
-            />
-            <DefaultInput
-              v-model="form.description"
-              label="Descrição"
-              outlined
-              :rules="[(v) => !!v || 'Informe a descrição']"
-            />
-            <div
-              v-if="accountKind === 'child' && selectedParent"
-              class="text-caption text-grey-7"
-            >
-              Tipo herdado do pai:
-              <b>{{ chartTypeLabel(selectedParent.chart_type) }}</b>
-            </div>
+
+              <DefaultInput
+                v-model="form.description"
+                v-model:error="validationErrors.description"
+                label="Descrição"
+                outlined
+                :rules="[inputRules.required]"
+              />
+
+              <div
+                v-if="accountKind === 'child' && selectedParent"
+                class="text-caption text-grey-7"
+              >
+                Tipo herdado do pai:
+
+                <b>
+                  {{ chartTypeLabel(selectedParent.chart_type) }}
+                </b>
+              </div>
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-pa-md">
-            <q-btn flat label="Cancelar" color="grey-7" @click="dialog = false" />
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="dialog = false"
+            />
+
             <q-btn
               color="primary"
               label="Salvar"
+              no-caps
+              type="submit"
               :loading="saving"
-              @click="onSave"
             />
           </q-card-actions>
         </DefaultForm>
@@ -112,195 +204,315 @@
 </template>
 
 <script setup>
-import { onMounted, ref, computed } from "vue";
-import { useQuasar } from "quasar";
+import { computed, onMounted, ref, useTemplateRef } from "vue";
+
 import {
-  getPlanAccountsMe,
   createPlanAccountMe,
-  updatePlanAccountMe,
   deletePlanAccountMe,
+  getPlanAccountsMe,
+  updatePlanAccountMe,
 } from "src/api/financial_plan_account";
 
+import { permissionStore } from "src/stores/permission";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useQuasar } from "quasar";
+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 DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import FinancialPlanAccountTreeNode from "src/components/financial/FinancialPlanAccountTreeNode.vue";
-import { permissionStore } from "src/stores/permission";
 
 const $q = useQuasar();
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
 const permissions = permissionStore();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const accountKind = ref("parent");
+const dialog = ref(false);
+const editing = ref(false);
+const editingId = ref(null);
+const loadingParents = ref(false);
+const rows = ref([]);
+const search = ref("");
+
 const canAdd = computed(() =>
   permissions.getAccess("franchisee_financial", "add"),
 );
-const canEdit = computed(() =>
-  permissions.getAccess("franchisee_financial", "edit"),
-);
+
 const canDelete = computed(() =>
   permissions.getAccess("franchisee_financial", "delete"),
 );
 
-const rows = ref([]);
-const search = ref("");
-const dialog = ref(false);
-const saving = ref(false);
-const editing = ref(false);
-const editingId = ref(null);
-const formRef = ref(null);
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_financial", "edit"),
+);
 
-const defaultForm = () => ({
-  code: "",
-  description: "",
-  chart_type: "despesa",
-  parent_id: null,
+const accountTree = computed(() => {
+  const nodes = new Map(
+    rows.value.map((account) => [
+      account.id,
+      {
+        ...account,
+        children: [],
+      },
+    ]),
+  );
+
+  const roots = [];
+
+  for (const node of nodes.values()) {
+    const parent = node.parent_id
+      ? nodes.get(node.parent_id)
+      : null;
+
+    if (parent) {
+      parent.children.push(node);
+
+      continue;
+    }
+
+    roots.push(node);
+  }
+
+  const sortAccounts = (accounts) => {
+    accounts.sort((firstAccount, secondAccount) =>
+      String(firstAccount.code).localeCompare(
+        String(secondAccount.code),
+        "pt-BR",
+        {
+          numeric: true,
+        },
+      ),
+    );
+
+    accounts.forEach((account) => {
+      sortAccounts(account.children);
+    });
+
+    return accounts;
+  };
+
+  return sortAccounts(roots);
 });
-const form = ref(defaultForm());
 
-const accountKind = ref("parent");
-const kindOptions = [
-  { label: "Conta Pai", value: "parent" },
-  { label: "Conta Filho", value: "child" },
-];
+const filteredTree = computed(() => {
+  if (!normalizedSearch.value) return accountTree.value;
 
-const chartTypeOptions = [
-  { label: "Receita", value: "receita" },
-  { label: "Despesa", value: "despesa" },
-];
+  const filterAccounts = (accounts) =>
+    accounts.reduce((result, account) => {
+      const children = filterAccounts(account.children);
 
-function chartTypeLabel(type) {
-  return chartTypeOptions.find((o) => o.value === type)?.label ?? type;
-}
+      const matches = `${account.code} ${account.description}`
+        .toLocaleLowerCase("pt-BR")
+        .includes(normalizedSearch.value);
+
+      if (matches || children.length) {
+        result.push({
+          ...account,
+          children,
+        });
+      }
+
+      return result;
+    }, []);
+
+  return filterAccounts(accountTree.value);
+});
+
+const normalizedSearch = computed(
+  () => search.value?.trim().toLocaleLowerCase("pt-BR") ?? "",
+);
 
-const loadingParents = ref(false);
 const parentOptions = computed(() =>
   rows.value
-    .filter((a) => a.id !== editingId.value)
-    .map((a) => ({
-      id: a.id,
-      label: `${a.code} — ${a.description}`,
-      chart_type: a.chart_type,
+    .filter((account) => account.id !== editingId.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 accountTree = computed(() => {
-  const nodes = new Map(rows.value.map((account) => [account.id, { ...account, children: [] }]));
-  const roots = [];
-  for (const node of nodes.values()) {
-    const parent = node.parent_id ? nodes.get(node.parent_id) : null;
-    if (parent) parent.children.push(node); else roots.push(node);
-  }
-  const sort = (items) => {
-    items.sort((a, b) => String(a.code).localeCompare(String(b.code), "pt-BR", { numeric: true }));
-    items.forEach((item) => sort(item.children));
-    return items;
-  };
-  return sort(roots);
-});
-const normalizedSearch = computed(() => search.value?.trim().toLocaleLowerCase("pt-BR") ?? "");
-const filteredTree = computed(() => {
-  if (!normalizedSearch.value) return accountTree.value;
-  const filter = (nodes) => nodes.reduce((result, node) => {
-    const children = filter(node.children);
-    const matches = `${node.code} ${node.description}`.toLocaleLowerCase("pt-BR").includes(normalizedSearch.value);
-    if (matches || children.length) result.push({ ...node, children });
-    return result;
-  }, []);
-  return filter(accountTree.value);
+const chartTypeOptions = [
+  {
+    label: "Receita",
+    value: "receita",
+  },
+  {
+    label: "Despesa",
+    value: "despesa",
+  },
+];
+
+const defaultForm = () => ({
+  chart_type: "despesa",
+  code: "",
+  description: "",
+  parent_id: null,
 });
 
-async function loadData() {
-  loadingParents.value = true;
-  try {
-    rows.value = await getPlanAccountsMe();
-  } catch (e) {
-    console.error("Erro ao carregar plano de contas:", e);
-  } finally {
-    loadingParents.value = false;
-  }
-}
+const kindOptions = [
+  {
+    label: "Conta Pai",
+    value: "parent",
+  },
+  {
+    label: "Conta Filho",
+    value: "child",
+  },
+];
 
-function openCreate() {
-  editing.value = false;
-  editingId.value = null;
-  accountKind.value = "parent";
-  form.value = defaultForm();
-  dialog.value = true;
-}
+const { form, getUpdatedFields } = useForm(defaultForm());
 
-function openEdit(row) {
-  editing.value = true;
-  editingId.value = row.id;
-  accountKind.value = row.parent_id ? "child" : "parent";
-  form.value = {
-    code: row.code,
-    description: row.description,
-    chart_type: row.chart_type,
-    parent_id: row.parent_id ?? null,
-  };
-  dialog.value = true;
-}
+const {
+  loading: saving,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: async () => {
+    dialog.value = false;
 
-async function onSave() {
-  const valid = await formRef.value?.validate();
-  if (!valid) return;
+    await loadData();
+  },
+  scrollFn: scrollToComponent,
+});
 
-  const payload = {
-    code: form.value.code,
-    description: form.value.description,
-  };
-  if (accountKind.value === "child") {
-    payload.parent_id = form.value.parent_id;
-  } else {
-    payload.chart_type = form.value.chart_type;
-    payload.parent_id = null;
-  }
+const chartTypeLabel = (type) =>
+  chartTypeOptions.find((option) => option.value === type)?.label ??
+  type;
+
+const loadData = async () => {
+  loadingParents.value = true;
 
-  saving.value = true;
   try {
-    if (editing.value) {
-      await updatePlanAccountMe(editingId.value, payload);
-    } else {
-      await createPlanAccountMe(payload);
-    }
-    dialog.value = false;
-    await loadData();
-  } catch (e) {
-    console.error(e);
+    rows.value = await getPlanAccountsMe();
+  } catch (error) {
+    console.error("Erro ao carregar plano de contas:", error);
   } finally {
-    saving.value = false;
+    loadingParents.value = false;
   }
-}
+};
 
-function onDelete(id) {
-  const row = rows.value.find((r) => r.id === id);
-  if (row && !row.unit_id) {
+const onDelete = (id) => {
+  const account = rows.value.find((row) => row.id === id);
+
+  if (account && !account.unit_id) {
     $q.notify({
+      message:
+        "O plano de contas da Matriz não pode ser excluído pela unidade.",
       type: "negative",
-      message: "O plano de contas da Matriz não pode ser excluído pela unidade.",
     });
+
     return;
   }
+
   $q.dialog({
-    title: "Excluir conta",
-    message: "Tem certeza que deseja excluir esta conta do plano?",
     cancel: true,
+    message: "Tem certeza que deseja excluir esta conta do plano?",
     persistent: true,
+    title: "Excluir conta",
   }).onOk(async () => {
     try {
       await deletePlanAccountMe(id);
+
       await loadData();
-    } catch (e) {
-      console.error(e);
+    } catch (error) {
+      console.error(error);
     }
   });
-}
+};
+
+const onSave = async () => {
+  const payload = {
+    code: form.code,
+    description: form.description,
+  };
+
+  if (accountKind.value === "child") {
+    payload.parent_id = form.parent_id;
+  } else {
+    payload.chart_type = form.chart_type;
+    payload.parent_id = null;
+  }
+
+  await execute(() => {
+    if (!editing.value) {
+      return createPlanAccountMe(payload);
+    }
+
+    const changedFields = getUpdatedFields.value;
+    const updatePayload = {};
+
+    for (const key of [
+      "code",
+      "description",
+      "chart_type",
+      "parent_id",
+    ]) {
+      if (key in changedFields) {
+        updatePayload[key] = payload[key];
+      }
+    }
+
+    return updatePlanAccountMe(
+      editingId.value,
+      updatePayload,
+    );
+  });
+};
+
+const openCreate = () => {
+  accountKind.value = "parent";
+  editing.value = false;
+  editingId.value = null;
+
+  Object.assign(form, defaultForm());
+
+  dialog.value = true;
+};
+
+const openEdit = (account) => {
+  accountKind.value = account.parent_id ? "child" : "parent";
+  editing.value = true;
+  editingId.value = account.id;
+
+  Object.assign(form, {
+    chart_type: account.chart_type,
+    code: account.code,
+    description: account.description,
+    parent_id: account.parent_id ?? null,
+  });
+
+  dialog.value = true;
+};
 
 onMounted(loadData);
 </script>
 
 <style scoped>
-.account-tree-header { display: grid; grid-template-columns: 15% 1fr 16% 105px; gap: 16px; padding: 12px 56px 12px 16px; border-bottom: 1px solid #c7c7c7; font-weight: 600; }
-</style>
+.account-tree-header {
+  display: grid;
+  grid-template-columns: 15% 1fr 16% 105px;
+  gap: 16px;
+  padding: 12px 56px 12px 16px;
+  border-bottom: 1px solid #c7c7c7;
+  font-weight: 600;
+}
+</style>

+ 137 - 71
src/pages/financial/components/AddEditTreasuryAccountDialog.vue

@@ -1,52 +1,88 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card class="q-dialog-plugin dialog-form-card treasury-dialog-card">
-      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
-
-      <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+      <DefaultDialogHeader
+        :title="title"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
+        <q-scroll-area
+          ref="scrollAreaRef"
+          class="dialog-form-scroll dialog-form-scroll--sm"
+        >
           <q-card-section class="row q-col-gutter-md q-px-lg q-pt-md q-pb-sm">
-          <DefaultInput
-            v-model="form.name"
-            label="Nome (ex.: Banco do Brasil, Caixa Loja)"
-            class="col-12"
-            outlined
-            :rules="[(v) => !!v || 'Informe o nome']"
-          />
-
-          <DefaultInput
-            v-model="form.bank_name"
-            label="Banco"
-            class="col-12 col-sm-6"
-            outlined
-          />
-          <DefaultInput
-            v-model="form.bank_agency"
-            label="Agência"
-            class="col-12 col-sm-6"
-            outlined
-          />
-          <DefaultInput
-            v-model="form.bank_account"
-            label="Conta"
-            class="col-12 col-sm-6"
-            outlined
-          />
-          <DefaultSelect
-            v-model="form.bank_type_account"
-            label="Tipo"
-            :options="accountTypeOptions"
-            class="col-12 col-sm-6"
-            outlined
-            emit-value
-            map-options
-          />
+            <DefaultInput
+              v-model="form.name"
+              v-model:error="validationErrors.name"
+              class="col-12"
+              label="Nome (ex.: Banco do Brasil, Caixa Loja)"
+              outlined
+              :rules="[inputRules.required, optionalMaxLengthRule(255)]"
+            />
+
+            <DefaultInput
+              v-model="form.bank_name"
+              v-model:error="validationErrors.bank_name"
+              class="col-12 col-sm-6"
+              label="Banco"
+              outlined
+              :rules="[optionalMaxLengthRule(255)]"
+            />
+
+            <DefaultInput
+              v-model="form.bank_agency"
+              v-model:error="validationErrors.bank_agency"
+              class="col-12 col-sm-6"
+              label="Agência"
+              outlined
+              :rules="[optionalMaxLengthRule(50)]"
+            />
+
+            <DefaultInput
+              v-model="form.bank_account"
+              v-model:error="validationErrors.bank_account"
+              class="col-12 col-sm-6"
+              label="Conta"
+              outlined
+              :rules="[optionalMaxLengthRule(50)]"
+            />
+
+            <DefaultSelect
+              v-model="form.bank_type_account"
+              v-model:error="validationErrors.bank_type_account"
+              class="col-12 col-sm-6"
+              emit-value
+              label="Tipo"
+              map-options
+              outlined
+              :options="accountTypeOptions"
+            />
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-px-lg q-pt-md q-pb-lg q-gutter-sm">
-          <q-btn flat label="Cancelar" color="grey-7" @click="onDialogCancel" />
-          <q-btn color="primary" label="Salvar" type="submit" :loading="loading" />
+        <q-card-actions
+          align="right"
+          class="q-px-lg q-pt-md q-pb-lg q-gutter-sm"
+        >
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            :loading="loading"
+          />
         </q-card-actions>
       </DefaultForm>
     </q-card>
@@ -54,14 +90,21 @@
 </template>
 
 <script setup>
-import { computed, ref } from "vue";
-import { useDialogPluginComponent } from "quasar";
+import { computed, useTemplateRef } from "vue";
+
 import {
   createTreasuryAccountMe,
   updateTreasuryAccountMe,
 } from "src/api/treasury";
 
+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 DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
@@ -77,45 +120,68 @@ const props = defineProps({
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const formRef = ref(null);
-const loading = ref(false);
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
 const isEdit = computed(() => !!props.account?.id);
-const title = computed(() => (isEdit.value ? "Editar conta" : "Nova conta"));
+
+const title = computed(() =>
+  isEdit.value ? "Editar conta" : "Nova conta",
+);
 
 const accountTypeOptions = [
-  { label: "Corrente", value: "corrente" },
-  { label: "Poupança", value: "poupanca" },
+  {
+    label: "Corrente",
+    value: "corrente",
+  },
+  {
+    label: "Poupança",
+    value: "poupanca",
+  },
 ];
 
-const form = ref({
-  name: props.account?.name ?? "",
-  treasury_type: "bank",
-  bank_name: props.account?.bank_name ?? "",
-  bank_agency: props.account?.bank_agency ?? "",
+const { form, getUpdatedFields } = useForm({
   bank_account: props.account?.bank_account ?? "",
+  bank_agency: props.account?.bank_agency ?? "",
+  bank_name: props.account?.bank_name ?? "",
   bank_type_account: props.account?.bank_type_account ?? null,
+  name: props.account?.name ?? "",
+  treasury_type: "bank",
 });
 
-async function onOKClick() {
-  const valid = await formRef.value?.validate();
-  if (!valid) return;
-
-  loading.value = true;
+const optionalMaxLengthRule = (max) => (value) =>
+  !value ||
+  String(value).length <= max ||
+  `Informe no máximo ${max} caracteres.`;
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => {
+    onDialogOK(true);
+  },
+  scrollFn: scrollToComponent,
+});
 
-  try {
+const onOKClick = async () => {
+  await execute(() => {
     if (isEdit.value) {
-      await updateTreasuryAccountMe(props.account.id, form.value);
-    } else {
-      await createTreasuryAccountMe(form.value);
+      return updateTreasuryAccountMe(
+        props.account.id,
+        { ...getUpdatedFields.value },
+      );
     }
 
-    onDialogOK(true);
-  } catch (e) {
-    console.error(e);
-  } finally {
-    loading.value = false;
-  }
-}
+    return createTreasuryAccountMe({ ...form });
+  });
+};
 </script>
 
 <style scoped>
@@ -123,4 +189,4 @@ async function onOKClick() {
   width: 520px;
   max-width: calc(100vw - 32px);
 }
-</style>
+</style>

+ 164 - 71
src/pages/financial/components/AddTreasuryLaunchDialog.vue

@@ -1,45 +1,79 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card class="q-dialog-plugin dialog-form-card treasury-dialog-card">
-      <DefaultDialogHeader :title="launch ? 'Editar movimentação' : 'Nova movimentação'" @close="onDialogCancel" />
-
-      <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+      <DefaultDialogHeader
+        :title="launch ? 'Editar movimentação' : 'Nova movimentação'"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
+        <q-scroll-area
+          ref="scrollAreaRef"
+          class="dialog-form-scroll dialog-form-scroll--sm"
+        >
           <q-card-section class="row q-col-gutter-md q-px-lg q-pt-md q-pb-sm">
-          <DefaultSelect
-            v-model="form.transaction_type"
-            label="Tipo"
-            :options="transactionTypeOptions"
-            class="col-12"
-            outlined
-            emit-value
-            map-options
-          />
-          <DefaultInput
-            v-model="form.description"
-            label="Descrição"
-            class="col-12"
-            outlined
-            :rules="[(v) => !!v || 'Informe a descrição']"
-          />
-          <DefaultCurrencyInput
-            v-model="form.amount"
-            label="Valor"
-            class="col-12 col-sm-6"
-            outlined
-          />
-          <DefaultInputDatePicker
-            v-model="form.launch_date"
-            v-model:untreated-date="form.launch_date_iso"
-            label="Data"
-            class="col-12 col-sm-6"
-          />
+            <DefaultSelect
+              v-model="form.transaction_type"
+              v-model:error="validationErrors.transaction_type"
+              class="col-12"
+              emit-value
+              label="Tipo"
+              map-options
+              outlined
+              :options="transactionTypeOptions"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.description"
+              v-model:error="validationErrors.description"
+              class="col-12"
+              label="Descrição"
+              outlined
+              :rules="[inputRules.required, maxLengthRule(1000)]"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.amount"
+              v-model:error="validationErrors.amount"
+              class="col-12 col-sm-6"
+              label="Valor"
+              outlined
+              :rules="[inputRules.required, positiveAmountRule]"
+            />
+
+            <DefaultInputDatePicker
+              v-model="form.launch_date"
+              v-model:error="validationErrors.launch_date"
+              v-model:untreated-date="form.launch_date_iso"
+              class="col-12 col-sm-6"
+              label="Data"
+            />
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-px-lg q-pt-md q-pb-lg q-gutter-sm">
-          <q-btn flat label="Cancelar" color="grey-7" @click="onDialogCancel" />
-          <q-btn color="primary-2" label="Salvar" type="submit" :loading="loading" />
+        <q-card-actions
+          align="right"
+          class="q-px-lg q-pt-md q-pb-lg q-gutter-sm"
+        >
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            :loading="loading"
+          />
         </q-card-actions>
       </DefaultForm>
     </q-card>
@@ -47,15 +81,24 @@
 </template>
 
 <script setup>
-import { ref } from "vue";
+import {
+  createTreasuryLaunchMe,
+  updateTreasuryLaunchMe,
+} from "src/api/treasury";
+
 import { useDialogPluginComponent } from "quasar";
-import { createTreasuryLaunchMe, updateTreasuryLaunchMe } from "src/api/treasury";
+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 { useTemplateRef } from "vue";
 
+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 DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -73,49 +116,99 @@ const props = defineProps({
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const formRef = ref(null);
-const loading = ref(false);
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
 
 const transactionTypeOptions = [
-  { label: "Entrada", value: "entrada" },
-  { label: "Saída", value: "saida" },
+  {
+    label: "Entrada",
+    value: "entrada",
+  },
+  {
+    label: "Saída",
+    value: "saida",
+  },
 ];
 
-const form = ref({
-  transaction_type: props.launch?.transaction_type ?? "entrada",
-  description: props.launch?.description ?? "",
+const { form, getUpdatedFields } = useForm({
   amount: props.launch?.amount ?? 0,
+  description: props.launch?.description ?? "",
+
   launch_date: props.launch?.launch_date
     ? props.launch.launch_date.split("-").reverse().join("/")
     : new Date().toLocaleDateString("pt-BR"),
-  launch_date_iso: props.launch?.launch_date ?? new Date().toISOString().slice(0, 10),
-});
-
-async function onOKClick() {
-  const valid = await formRef.value?.validate();
-  if (!valid) return;
 
-  loading.value = true;
+  launch_date_iso:
+    props.launch?.launch_date ?? new Date().toISOString().slice(0, 10),
 
-  try {
-    const payload = {
-      account_id: props.accountId,
-      transaction_type: form.value.transaction_type,
-      description: form.value.description,
-      amount: form.value.amount,
-      launch_date: form.value.launch_date_iso,
-    };
-
-    if (props.launch) await updateTreasuryLaunchMe(props.launch.id, payload);
-    else await createTreasuryLaunchMe(payload);
+  transaction_type: props.launch?.transaction_type ?? "entrada",
+});
 
+const maxLengthRule = (max) => (value) =>
+  String(value ?? "").length <= max ||
+  `Informe no máximo ${max} caracteres.`;
+
+const positiveAmountRule = (value) =>
+  Number(value) > 0 || "Informe um valor maior que zero.";
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => {
     onDialogOK(true);
-  } catch (e) {
-    console.error(e);
-  } finally {
-    loading.value = false;
-  }
-}
+  },
+  scrollFn: scrollToComponent,
+});
+
+const onOKClick = async () => {
+  const payload = {
+    account_id: props.accountId,
+    amount: form.amount,
+    description: form.description,
+    launch_date: form.launch_date_iso,
+    transaction_type: form.transaction_type,
+  };
+
+  await execute(() => {
+    if (!props.launch) {
+      return createTreasuryLaunchMe(payload);
+    }
+
+    const changedFields = getUpdatedFields.value;
+    const updatePayload = {};
+
+    if ("transaction_type" in changedFields) {
+      updatePayload.transaction_type = form.transaction_type;
+    }
+
+    if ("description" in changedFields) {
+      updatePayload.description = form.description;
+    }
+
+    if ("amount" in changedFields) {
+      updatePayload.amount = form.amount;
+    }
+
+    if (
+      "launch_date_iso" in changedFields ||
+      "launch_date" in changedFields
+    ) {
+      updatePayload.launch_date = form.launch_date_iso;
+    }
+
+    return updateTreasuryLaunchMe(
+      props.launch.id,
+      updatePayload,
+    );
+  });
+};
 </script>
 
 <style scoped>
@@ -123,4 +216,4 @@ async function onOKClick() {
   width: 480px;
   max-width: calc(100vw - 32px);
 }
-</style>
+</style>

+ 160 - 68
src/pages/kanban/KanbanPage.vue

@@ -1,21 +1,23 @@
 <template>
   <div>
-    <DefaultHeaderPage title="Atividades" :show-filter-icon="false" />
+    <DefaultHeaderPage
+      :show-filter-icon="false"
+      title="Atividades"
+    />
 
-    <div v-if="loading" class="flex flex-center q-pa-xl">
-      <q-spinner color="primary" size="48px" />
+    <div
+      v-if="loading"
+      class="flex flex-center q-pa-xl"
+    >
+      <q-spinner
+        color="primary"
+        size="48px"
+      />
     </div>
 
     <div
       v-else
       class="kanban-board q-px-md q-pb-md"
-      style="
-        display: flex;
-        gap: 16px;
-        overflow-x: auto;
-        align-items: flex-start;
-        min-height: calc(100vh - 120px);
-      "
     >
       <div
         v-for="column in columns"
@@ -29,54 +31,66 @@
           gap: 8px;
         "
       >
-        <!-- Column header -->
         <div
           class="row items-center justify-between q-px-md q-py-sm"
-          :style="{ backgroundColor: column.color, borderRadius: '8px' }"
+          :style="{
+            backgroundColor: column.color,
+            borderRadius: '8px',
+          }"
         >
-          <span class="text-weight-bold text-white" style="font-size: 14px">
+          <span
+            class="text-weight-bold text-white"
+            style="font-size: 14px"
+          >
             {{ column.label }}
           </span>
+
           <div class="row items-center gap-xs">
             <q-badge
               color="white"
-              :text-color="column.badgeTextColor"
-              :label="columnMap[column.phase].length"
               style="font-size: 11px"
+              :label="columnMap[column.phase].length"
+              :text-color="column.badgeTextColor"
             />
+
             <q-btn
               v-if="canAdd"
-              flat
-              round
+              color="white"
               dense
+              flat
               icon="mdi-plus"
-              color="white"
+              round
               size="sm"
               @click="openDialog(null, column.phase)"
             />
           </div>
         </div>
 
-        <!-- Draggable card list -->
         <draggable
-          :list="columnMap[column.phase]"
-          :data-phase="column.phase"
-          group="kanban"
-          item-key="id"
           :animation="180"
+          :data-phase="column.phase"
           :disabled="!canEdit"
-          ghost-class="drag-ghost"
+          :list="columnMap[column.phase]"
           drag-class="drag-active"
-          style="display: flex; flex-direction: column; gap: 8px; min-height: 48px; flex: 1"
+          ghost-class="drag-ghost"
+          group="kanban"
+          item-key="id"
+          style="
+            display: flex;
+            flex-direction: column;
+            gap: 8px;
+            min-height: 48px;
+            flex: 1;
+          "
           @end="onDragEnd"
         >
           <template #item="{ element }">
             <KanbanCard
-              :card="element"
-              :can-edit="canEdit"
               :can-delete="canDelete"
-              @edit="openDialog(element, column.phase)"
+              :can-edit="canEdit"
+              :card="element"
               @delete="removeCard($event, column.phase)"
+              @edit="openDialog(element, column.phase)"
             />
           </template>
         </draggable>
@@ -86,50 +100,98 @@
 </template>
 
 <script setup>
-import { computed, ref, reactive, defineAsyncComponent, onMounted, watch } from "vue";
+import {
+  computed,
+  defineAsyncComponent,
+  onMounted,
+  reactive,
+  ref,
+  watch,
+} from "vue";
+
+import { getKanbans, reorderKanbans } from "src/api/kanban";
+import { permissionStore } from "src/stores/permission";
 import { useQuasar } from "quasar";
+import { userStore } from "src/stores/user";
+
 import draggable from "vuedraggable";
 
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import KanbanCard from "./components/KanbanCard.vue";
-import { getKanbans, reorderKanbans } from "src/api/kanban";
-import { userStore } from "src/stores/user";
-import { permissionStore } from "src/stores/permission";
 
 const AddEditKanbanDialog = defineAsyncComponent(
   () => import("./components/AddEditKanbanDialog.vue"),
 );
 
 const $q = useQuasar();
-const { selectedUnit } = userStore();
+
 const permissions = permissionStore();
-const canAdd = computed(() => permissions.getAccess("franchisee_activities", "add"));
-const canEdit = computed(() => permissions.getAccess("franchisee_activities", "edit"));
-const canDelete = computed(() => permissions.getAccess("franchisee_activities", "delete"));
-const loading = ref(false);
+const { selectedUnit } = userStore();
 
-const columns = [
-  { phase: "a_fazer",            label: "A Fazer",            color: "#757575", badgeTextColor: "grey-9"   },
-  { phase: "em_progresso",       label: "Em Progresso",       color: "#1976D2", badgeTextColor: "blue-9"   },
-  { phase: "em_revisao",         label: "Em Revisão",         color: "#F57C00", badgeTextColor: "orange-9" },
-  { phase: "concluido",          label: "Concluído",          color: "#388E3C", badgeTextColor: "green-9"  },
-  { phase: "demandas_especiais", label: "Demandas Especiais", color: "#F9A825", badgeTextColor: "yellow-9" },
-];
+const loading = ref(false);
 
 const columnMap = reactive({
   a_fazer: [],
-  em_progresso: [],
-  em_revisao: [],
   concluido: [],
   demandas_especiais: [],
+  em_progresso: [],
+  em_revisao: [],
 });
 
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_activities", "add"),
+);
+
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_activities", "delete"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_activities", "edit"),
+);
+
+const columns = [
+  {
+    badgeTextColor: "grey-9",
+    color: "#757575",
+    label: "A Fazer",
+    phase: "a_fazer",
+  },
+  {
+    badgeTextColor: "blue-9",
+    color: "#1976D2",
+    label: "Em Progresso",
+    phase: "em_progresso",
+  },
+  {
+    badgeTextColor: "orange-9",
+    color: "#F57C00",
+    label: "Em Revisão",
+    phase: "em_revisao",
+  },
+  {
+    badgeTextColor: "green-9",
+    color: "#388E3C",
+    label: "Concluído",
+    phase: "concluido",
+  },
+  {
+    badgeTextColor: "yellow-9",
+    color: "#F9A825",
+    label: "Demandas Especiais",
+    phase: "demandas_especiais",
+  },
+];
+
 const loadCards = async () => {
   loading.value = true;
+
   try {
     const data = await getKanbans();
 
-    Object.keys(columnMap).forEach((k) => (columnMap[k] = []));
+    Object.keys(columnMap).forEach((phase) => {
+      columnMap[phase] = [];
+    });
 
     data.forEach((card) => {
       if (columnMap[card.phase]) {
@@ -141,17 +203,25 @@ const loadCards = async () => {
   }
 };
 
-const onDragEnd = async (evt) => {
-  const sourcePhase = evt.from.dataset.phase;
-  const targetPhase = evt.to.dataset.phase;
+const onDragEnd = async (event) => {
+  const sourcePhase = event.from.dataset.phase;
+  const targetPhase = event.to.dataset.phase;
 
-  const phasesToUpdate = new Set([sourcePhase, targetPhase].filter(Boolean));
+  const phasesToUpdate = new Set(
+    [sourcePhase, targetPhase].filter(Boolean),
+  );
 
   const items = [];
+
   phasesToUpdate.forEach((phase) => {
-    columnMap[phase].forEach((card, idx) => {
+    columnMap[phase].forEach((card, index) => {
       card.phase = phase;
-      items.push({ id: card.id, phase, order: idx });
+
+      items.push({
+        id: card.id,
+        order: index,
+        phase,
+      });
     });
   });
 
@@ -162,30 +232,52 @@ const onDragEnd = async (evt) => {
   }
 };
 
-const removeCard = (cardId, phase) => {
-  const idx = columnMap[phase].findIndex((c) => c.id === cardId);
-  if (idx !== -1) columnMap[phase].splice(idx, 1);
-};
-
 const openDialog = (card = null, phase = "a_fazer") => {
   $q.dialog({
     component: AddEditKanbanDialog,
-    componentProps: { card, initialPhase: phase },
-  }).onOk(() => {
-    loadCards();
-  });
+    componentProps: {
+      card,
+      initialPhase: phase,
+    },
+  }).onOk(loadCards);
 };
 
-// Reload whenever the user switches units via the top selector
-watch(() => selectedUnit?.id, (newId, oldId) => {
-  if (newId !== oldId) loadCards();
-});
+const removeCard = (cardId, phase) => {
+  const index = columnMap[phase].findIndex(
+    (card) => card.id === cardId,
+  );
+
+  if (index !== -1) {
+    columnMap[phase].splice(index, 1);
+  }
+};
+
+watch(
+  () => selectedUnit?.id,
+  (newId, oldId) => {
+    if (newId === oldId) return;
+
+    loadCards();
+  },
+);
 
 onMounted(loadCards);
 </script>
 
 <style scoped>
 .kanban-board {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+  grid-auto-rows: max-content;
+  align-items: start;
+  align-content: start;
+  gap: 16px;
+  width: 100%;
+  max-width: 100%;
+  min-width: 0;
+  min-height: calc(100vh - 120px);
+  box-sizing: border-box;
+  overflow-x: hidden;
   padding-top: 12px;
 }
 
@@ -197,7 +289,7 @@ onMounted(loadCards);
 
 :deep(.drag-active) {
   opacity: 0.95;
-  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
+  box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
   transform: rotate(1.5deg);
 }
-</style>
+</style>

+ 501 - 300
src/pages/kanban/components/AddEditKanbanDialog.vue

@@ -1,7 +1,10 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <div style="width: 100%; max-width: 1100px">
-      <q-card class="dialog-form-card" style="height: 560px">
+      <q-card
+        class="dialog-form-card"
+        style="height: 560px"
+      >
         <DefaultDialogHeader
           :title="card ? 'Editar Atividade' : 'Adicionar Atividade'"
           @close="onDialogCancel"
@@ -9,182 +12,219 @@
 
         <DefaultForm
           ref="formRef"
-          style="flex: 1; display: flex; flex-direction: column; overflow: hidden"
+          style="
+            flex: 1;
+            display: flex;
+            flex-direction: column;
+            overflow: hidden;
+          "
           @submit="onOKClick"
         >
-          <q-scroll-area class="dialog-form-scroll">
+          <q-scroll-area
+            ref="scrollAreaRef"
+            class="dialog-form-scroll"
+          >
             <q-card-section class="q-pt-sm">
-            <CustomTabComponent
-              v-if="card?.id"
-              v-model:active-tab="currentTab"
-              :tabs="tabs"
-              class="q-mb-md"
-            />
+              <CustomTabComponent
+                v-if="card?.id"
+                v-model:active-tab="currentTab"
+                class="q-mb-md"
+                :tabs="tabs"
+              />
+
+              <div v-show="currentTab === 'atividade'">
+                <div class="row q-col-gutter-sm">
+                  <DefaultInput
+                    v-model="form.title"
+                    v-model:error="validationErrors.title"
+                    class="col-12"
+                    label="Título da Tarefa"
+                    :rules="[inputRules.required]"
+                  />
 
-            <!-- Tab: Atividade -->
-            <div v-show="currentTab === 'atividade'">
-              <div class="row q-col-gutter-sm">
-                <!-- 1. Título -->
-                <DefaultInput
-                  v-model="form.title"
-                  label="Título da Tarefa"
-                  class="col-12"
-                  :rules="[val => !!val || 'Campo obrigatório']"
-                />
-
-                <!-- 2. Destino (primeiro campo de configuração) -->
-                <DefaultSelect
-                  v-model="form.scope"
-                  label="Destino"
-                  :options="scopeOptions"
-                  emit-value
-                  map-options
-                  class="col-6"
-                  :rules="[val => !!val || 'Campo obrigatório']"
-                  @update:model-value="onScopeChange"
-                />
-
-                <!-- Prazo ao lado do Destino -->
-                <DefaultInputDatePicker
-                  v-model="form.due_date_display"
-                  v-model:untreated-date="form.due_date"
-                  label="Prazo de Entrega"
-                  class="col-6"
-                />
-
-                <!-- Responsável — só aparece quando destino é Interno -->
-                <DefaultSelect
-                  v-if="form.scope === 'internal'"
-                  v-model="form.responsible_user_id"
-                  label="Responsável"
-                  :options="userOptions"
-                  emit-value
-                  map-options
-                  clearable
-                  class="col-12"
-                />
-
-                <!-- 3. Prioridade + Fase -->
-                <DefaultSelect
-                  v-model="form.priority"
-                  label="Prioridade"
-                  :options="priorityOptions"
-                  emit-value
-                  map-options
-                  class="col-6"
-                  :rules="[val => !!val || 'Campo obrigatório']"
-                />
-
-                <DefaultSelect
-                  v-model="form.phase"
-                  label="Fase"
-                  :options="phaseOptions"
-                  emit-value
-                  map-options
-                  class="col-6"
-                  :rules="[val => !!val || 'Campo obrigatório']"
-                />
-
-                <!-- 4. Setor -->
-                <DefaultInput
-                  v-model="form.sector"
-                  label="Setor"
-                  class="col-6"
-                />
-
-                <!-- 5. Descrição -->
-                <DefaultInput
-                  v-model="form.description"
-                  label="Descrição"
-                  type="textarea"
-                  class="col-12"
-                />
-              </div>
-            </div>
-
-            <!-- Tab: Comentários -->
-            <div v-show="currentTab === 'comentarios'">
-              <div v-if="canAdd" class="flex justify-end q-mb-sm">
-                <q-btn
-                  color="primary"
-                  icon="mdi-plus"
-                  unelevated
-                  style="width: 40px; height: 40px"
-                  @click="onAddComment"
-                />
-              </div>
-              <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"
-                    :created-at="reply.created_at"
-                    :user-name="reply.user_name"
-                    @edit="onEditComment(reply)"
-                    @delete="onDeleteComment(reply)"
+                  <DefaultSelect
+                    v-model="form.scope"
+                    v-model:error="validationErrors.scope"
+                    class="col-6"
+                    emit-value
+                    label="Destino"
+                    map-options
+                    :options="scopeOptions"
+                    :rules="[inputRules.required]"
+                    @update:model-value="onScopeChange"
+                  />
+
+                  <DefaultInputDatePicker
+                    v-model="form.due_date_display"
+                    v-model:untreated-date="form.due_date"
+                    class="col-6"
+                    label="Prazo de Entrega"
+                  />
+
+                  <DefaultSelect
+                    v-if="form.scope === 'internal'"
+                    v-model="form.responsible_user_id"
+                    class="col-12"
+                    clearable
+                    emit-value
+                    label="Responsável"
+                    map-options
+                    :options="userOptions"
+                  />
+
+                  <DefaultSelect
+                    v-model="form.priority"
+                    v-model:error="validationErrors.priority"
+                    class="col-6"
+                    emit-value
+                    label="Prioridade"
+                    map-options
+                    :options="priorityOptions"
+                    :rules="[inputRules.required]"
+                  />
+
+                  <DefaultSelect
+                    v-model="form.phase"
+                    v-model:error="validationErrors.phase"
+                    class="col-6"
+                    emit-value
+                    label="Fase"
+                    map-options
+                    :options="phaseOptions"
+                    :rules="[inputRules.required]"
+                  />
+
+                  <DefaultInput
+                    v-model="form.sector"
+                    class="col-6"
+                    label="Setor"
+                  />
+
+                  <DefaultInput
+                    v-model="form.description"
+                    class="col-12"
+                    label="Descrição"
+                    type="textarea"
                   />
-                </template>
-                <div v-else class="flex flex-center full-height text-grey-5 text-body2">
-                  Nenhum comentário registrado.
                 </div>
               </div>
-            </div>
-
-            <!-- Tab: Mídias -->
-            <div v-show="currentTab === 'midias'">
-              <div v-if="canAdd" class="flex justify-end q-mb-sm">
-                <q-btn
-                  color="primary"
-                  icon="mdi-upload-outline"
-                  unelevated
-                  style="width: 40px; height: 40px"
-                  :loading="uploadingMedia"
-                  @click="triggerFileInput"
-                />
-                <input
-                  ref="fileInputRef"
-                  type="file"
-                  style="display: none"
-                  @change="onFileSelected"
-                />
+
+              <div v-show="currentTab === 'comentarios'">
+                <div
+                  v-if="canAdd"
+                  class="flex justify-end q-mb-sm"
+                >
+                  <q-btn
+                    color="primary"
+                    icon="mdi-plus"
+                    style="width: 40px; height: 40px"
+                    unelevated
+                    @click="onAddComment"
+                  />
+                </div>
+
+                <div
+                  style="
+                    display: flex;
+                    flex-direction: column;
+                    gap: 8px;
+                  "
+                >
+                  <template v-if="replies.length">
+                    <KanbanCommentCard
+                      v-for="reply in replies"
+                      :key="reply.id"
+                      :created-at="reply.created_at"
+                      :reply="reply.reply"
+                      :user-name="reply.user_name"
+                      @delete="onDeleteComment(reply)"
+                      @edit="onEditComment(reply)"
+                    />
+                  </template>
+
+                  <div
+                    v-else
+                    class="flex flex-center full-height text-grey-5 text-body2"
+                  >
+                    Nenhum comentário registrado.
+                  </div>
+                </div>
               </div>
-              <div
-                style="
-                  display: flex;
-                  flex-direction: column;
-                  gap: 8px;
-                "
-              >
-                <template v-if="medias.length">
-                  <KanbanMediaCard
-                    v-for="media in medias"
-                    :key="media.id"
-                    :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"
-                    @delete="onDeleteMedia(media)"
+
+              <div v-show="currentTab === 'midias'">
+                <div
+                  v-if="canAdd"
+                  class="flex justify-end q-mb-sm"
+                >
+                  <q-btn
+                    color="primary"
+                    icon="mdi-upload-outline"
+                    style="width: 40px; height: 40px"
+                    unelevated
+                    :loading="uploadingMedia"
+                    @click="triggerFileInput"
                   />
-                </template>
-                <div v-else class="flex flex-center full-height text-grey-5 text-body2">
-                  Nenhuma mídia anexada.
+
+                  <input
+                    ref="fileInputRef"
+                    style="display: none"
+                    type="file"
+                    @change="onFileSelected"
+                  />
+                </div>
+
+                <div
+                  style="
+                    display: flex;
+                    flex-direction: column;
+                    gap: 8px;
+                  "
+                >
+                  <template v-if="medias.length">
+                    <KanbanMediaCard
+                      v-for="media in medias"
+                      :key="media.id"
+                      :created-at="media.created_at"
+                      :file-name="media.file_name"
+                      :file-url="media.file_url"
+                      :mime-type="media.mime_type"
+                      :user-name="media.user_name"
+                      @delete="onDeleteMedia(media)"
+                    />
+                  </template>
+
+                  <div
+                    v-else
+                    class="flex flex-center full-height text-grey-5 text-body2"
+                  >
+                    Nenhuma mídia anexada.
+                  </div>
                 </div>
               </div>
-            </div>
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md" style="flex-shrink: 0">
-            <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
-            <q-btn v-if="canSave" color="primary" label="Salvar" type="submit" :loading="loading" />
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+            style="flex-shrink: 0"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="onDialogCancel"
+            />
+
+            <q-btn
+              v-if="canSave"
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              :loading="loading"
+            />
           </q-card-actions>
         </DefaultForm>
       </q-card>
@@ -193,224 +233,385 @@
 </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 KanbanCommentCard from "./KanbanCommentCard.vue";
-import KanbanMediaCard from "./KanbanMediaCard.vue";
-
+import { computed, onMounted, ref } from "vue";
 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 { getUsersByUnit } from "src/api/user";
 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 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 CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import KanbanCommentCard from "./KanbanCommentCard.vue";
+import KanbanMediaCard from "./KanbanMediaCard.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { card, initialPhase } = defineProps({
-  card:         { type: Object, default: null },
-  initialPhase: { type: String, default: "a_fazer" },
+  card: {
+    type: Object,
+    default: null,
+  },
+  initialPhase: {
+    type: String,
+    default: "a_fazer",
+  },
 });
 
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
 const $q = useQuasar();
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
 const permissions = permissionStore();
-const canAdd = computed(() => permissions.getAccess("franchisee_activities", "add"));
-const canEdit = computed(() => permissions.getAccess("franchisee_activities", "edit"));
-const canSave = computed(() => card?.id ? canEdit.value : canAdd.value);
-
-const formRef        = ref(null);
-const loading        = ref(false);
-const currentTab     = ref("atividade");
-const userOptions    = ref([]);
-const replies        = ref([]);
-const medias         = ref([]);
+
+const fileInputRef = ref(null);
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const currentTab = ref("atividade");
+const medias = ref([]);
+const replies = ref([]);
 const uploadingMedia = ref(false);
-const fileInputRef   = ref(null);
+const userOptions = ref([]);
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_activities", "add"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_activities", "edit"),
+);
+
+const canSave = computed(() =>
+  card?.id ? canEdit.value : canAdd.value,
+);
 
 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 phaseOptions = [
+  {
+    label: "A Fazer",
+    value: "a_fazer",
+  },
+  {
+    label: "Em Progresso",
+    value: "em_progresso",
+  },
+  {
+    label: "Em Revisão",
+    value: "em_revisao",
+  },
+  {
+    label: "Concluído",
+    value: "concluido",
+  },
+  {
+    label: "Demandas Especiais",
+    value: "demandas_especiais",
+  },
 ];
 
-const phaseOptions = [
-  { label: "A Fazer",            value: "a_fazer"            },
-  { label: "Em Progresso",       value: "em_progresso"       },
-  { label: "Em Revisão",         value: "em_revisao"         },
-  { label: "Concluído",          value: "concluido"          },
-  { label: "Demandas Especiais", value: "demandas_especiais" },
+const priorityOptions = [
+  {
+    label: "Alta",
+    value: "alta",
+  },
+  {
+    label: "Normal",
+    value: "normal",
+  },
+  {
+    label: "Baixa",
+    value: "baixa",
+  },
 ];
 
-// Franchisee: only Internal or send to Matriz (scope=specific, target=null)
 const scopeOptions = [
-  { label: "Interno", value: "internal" },
-  { label: "Matriz",  value: "specific" },
+  {
+    label: "Interno",
+    value: "internal",
+  },
+  {
+    label: "Matriz",
+    value: "specific",
+  },
 ];
 
 const formatDisplayDate = (rawDate) => {
   if (!rawDate) return null;
-  const [y, m, d] = rawDate.split("-");
-  return `${d}/${m}/${y}`;
+
+  const [year, month, day] = rawDate.split("-");
+
+  return `${day}/${month}/${year}`;
 };
 
-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 { 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,
+  title: card?.title ?? null,
 });
 
-// Clear responsible when switching away from internal
-const onScopeChange = (val) => {
-  if (val !== "internal") {
-    form.value.responsible_user_id = null;
-  }
-};
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => {
+    onDialogOK(true);
+  },
+  scrollFn: scrollToComponent,
+});
 
 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,
-  // Only internal tasks carry a responsible
-  responsible_user_id: form.value.scope === "internal"
-    ? (form.value.responsible_user_id || null)
-    : null,
-  target_unit_id: null, // backend resolves from scope + unit header
+  description: form.description || null,
+  due_date: form.due_date || null,
+  phase: form.phase,
+  priority: form.priority,
+
+  responsible_user_id:
+    form.scope === "internal"
+      ? form.responsible_user_id || null
+      : null,
+
+  scope: form.scope,
+  sector: form.sector || null,
+  target_unit_id: null,
+  title: form.title,
 });
 
-const loadUsers = async () => {
-  try {
-    const users = await getUsersByUnit();
-    userOptions.value = users.map((u) => ({ label: u.name, value: u.id }));
-  } catch {
-    console.log('Error');
-  }
+const loadMedias = async () => {
+  if (!card?.id) return;
+
+  medias.value = await getKanbanMedias(card.id);
 };
 
 const loadReplies = async () => {
   if (!card?.id) return;
+
   replies.value = await getKanbanReplies(card.id);
 };
 
-const loadMedias = async () => {
-  if (!card?.id) return;
-  medias.value = await getKanbanMedias(card.id);
+const loadUsers = async () => {
+  try {
+    const users = await getUsersByUnit();
+
+    userOptions.value = users.map((user) => ({
+      label: user.name,
+      value: user.id,
+    }));
+  } catch (error) {
+    console.error("Erro ao carregar usuários:", error);
+  }
 };
 
-const triggerFileInput = () => {
-  fileInputRef.value?.click();
+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",
+  }).onOk(async (text) => {
+    const comment = text?.trim();
+
+    if (!comment) return;
+
+    await createKanbanReply(card.id, {
+      reply: comment,
+    });
+
+    loadReplies();
+  });
 };
 
-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 = "";
-  }
+const onDeleteComment = (reply) => {
+  $q.dialog({
+    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 onDeleteMedia = (media) => {
   $q.dialog({
-    title:   "Excluir Mídia",
+    cancel: {
+      color: "primary",
+      label: "Cancelar",
+      outline: true,
+    },
     message: `Deseja excluir "${media.file_name}"?`,
-    cancel:  { outline: true, color: "primary",  label: "Cancelar" },
-    ok:      {               color: "negative",  label: "Excluir"  },
+    ok: {
+      color: "negative",
+      label: "Excluir",
+    },
+    title: "Excluir Mídia",
   }).onOk(async () => {
     await deleteKanbanMedia(card.id, media.id);
-    loadMedias();
-  });
-};
 
-const onAddComment = () => {
-  $q.dialog({
-    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() });
-    loadReplies();
+    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",
-    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() });
-    loadReplies();
-  });
-};
+    const comment = text?.trim();
+
+    if (!comment) return;
+
+    await updateKanbanReply(card.id, reply.id, {
+      reply: comment,
+    });
 
-const onDeleteComment = (reply) => {
-  $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"  },
-  }).onOk(async () => {
-    await deleteKanbanReply(card.id, reply.id);
     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;
+    uploadingMedia.value = false;
+    event.target.value = "";
   }
 };
 
+const onOKClick = async () => {
+  const payload = buildPayload();
+
+  await execute(() => {
+    if (!card?.id) {
+      return createKanban(payload);
+    }
+
+    const changedFields = getUpdatedFields.value;
+    const updatePayload = {};
+
+    for (const key of [
+      "title",
+      "priority",
+      "phase",
+      "scope",
+      "sector",
+      "description",
+      "responsible_user_id",
+    ]) {
+      if (key in changedFields) {
+        updatePayload[key] = payload[key];
+      }
+    }
+
+    if (
+      "due_date" in changedFields ||
+      "due_date_display" in changedFields
+    ) {
+      updatePayload.due_date = payload.due_date;
+    }
+
+    return updateKanban(card.id, updatePayload);
+  });
+};
+
+const onScopeChange = (value) => {
+  if (value === "internal") return;
+
+  form.responsible_user_id = null;
+};
+
+const triggerFileInput = () => {
+  fileInputRef.value?.click();
+};
+
 onMounted(() => {
   loadUsers();
   loadReplies();

+ 499 - 278
src/pages/packages/components/AddEditPackageDialog.vue

@@ -1,182 +1,250 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <div style="width: 100%; max-width: 700px">
-      <q-card class="dialog-form-card" style="width: 100%">
+      <q-card
+        class="dialog-form-card"
+        style="width: 100%"
+      >
         <DefaultDialogHeader
           :title="props.package ? 'Editar Pacote' : 'Novo Pacote'"
           @close="onDialogCancel"
         />
 
-        <DefaultForm ref="formRef" @submit="onOKClick">
-          <q-scroll-area class="dialog-form-scroll">
+        <DefaultForm
+          ref="formRef"
+          @submit="onOKClick"
+        >
+          <q-scroll-area
+            ref="scrollAreaRef"
+            class="dialog-form-scroll"
+          >
             <q-card-section class="q-pt-sm">
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="form.name"
-                label="Nome do Pacote"
-                class="col-12"
-              />
-
-              <DefaultInput
-                v-model="form.quantity_classes"
-                label="Quantidade de Aulas"
-                class="col-12"
-                type="number"
-              />
-
-              <DefaultInput
-                v-model="form.class_duration_hours"
-                label="Duração da Aula (horas)"
-                class="col-12"
-                type="number"
-                min="0.5"
-                max="24"
-                step="0.5"
-              />
-
-              <div class="col-12 package-duration-hint">
-                A duração padrão das aulas é de 2 horas e pode ser alterada se
-                necessário. É possível configurar no máximo dois dias.
-              </div>
+              <div class="row q-col-gutter-sm">
+                <DefaultInput
+                  v-model="form.name"
+                  v-model:error="validationErrors.name"
+                  class="col-12"
+                  label="Nome do Pacote"
+                  :rules="[inputRules.required]"
+                />
+
+                <DefaultInput
+                  v-model="form.quantity_classes"
+                  v-model:error="validationErrors.quantity_classes"
+                  class="col-12"
+                  label="Quantidade de Aulas"
+                  type="number"
+                  :rules="[inputRules.required, inputRules.minValue(1)]"
+                />
+
+                <DefaultInput
+                  v-model="form.class_duration_hours"
+                  v-model:error="validationErrors.class_duration_minutes"
+                  class="col-12"
+                  label="Duração da Aula (horas)"
+                  max="24"
+                  min="0.5"
+                  step="0.5"
+                  type="number"
+                  :rules="[
+                    inputRules.required,
+                    inputRules.minValue(0.5),
+                    inputRules.maxValue(24),
+                  ]"
+                />
+
+                <div class="col-12 package-duration-hint">
+                  A duração padrão das aulas é de 2 horas e pode ser alterada se
+                  necessário. É possível configurar no máximo dois dias.
+                </div>
 
-              <DefaultSelect
-                v-model="form.weekday"
-                label="1º Dia da Semana"
-                class="col-4"
-                :options="weekdays"
-                option-value="value"
-                option-label="label"
-                emit-value
-                map-options
-                clearable
-              />
-
-              <DefaultInput
-                v-model="form.start_time"
-                label="Horário Inicial"
-                class="col-4"
-                mask="##:##"
-              />
-
-              <DefaultInput
-                :model-value="firstEndTime"
-                label="Horário Final"
-                class="col-4"
-                disable
-              />
-
-              <DefaultSelect
-                v-model="form.second_weekday"
-                label="2º Dia da Semana"
-                class="col-4"
-                :options="secondWeekdayOptions"
-                option-value="value"
-                option-label="label"
-                emit-value
-                map-options
-                clearable
-              />
-
-              <DefaultInput
-                v-model="form.second_start_time"
-                label="Horário Inicial do 2º dia"
-                class="col-4"
-                mask="##:##"
-              />
-
-              <DefaultInput
-                :model-value="secondEndTime"
-                label="Horário Final do 2º dia"
-                class="col-4"
-                disable
-              />
-
-              <DefaultCurrencyInput
-                v-model="form.contract_register_value"
-                label="R$ Matrícula"
-                class="col-4"
-              />
-
-              <DefaultCurrencyInput
-                v-model="form.contract_value"
-                label="R$ Total do Contrato"
-                class="col-4"
-              />
-
-              <DefaultInput
-                v-model="form.contrat_discount_value"
-                label="Desconto em %"
-                class="col-4"
-                type="number"
-                min="0"
-                max="100"
-              />
-
-              <div
-                v-for="(material, index) in form.materials"
-                :key="index"
-                class="col-12"
-              >
-                <div class="row q-col-gutter-sm items-center">
-                  <DefaultSelect
-                    v-model="material.product_id"
-                    label="Material"
-                    class="col"
-                    :options="productOptions"
-                    emit-value
-                    map-options
-                    @update:model-value="onProductSelected(material)"
-                  />
-
-                  <DefaultInput
-                    v-model="material.quantity"
-                    label="Qtd"
-                    class="col-2"
-                    type="number"
-                    min="1"
-                  />
-
-                  <DefaultCurrencyInput
-                    v-model="material.price"
-                    label="R$ Unitário"
-                    class="col-3"
-                  />
-
-                  <div class="col-auto">
-                    <q-btn
-                      v-if="index === form.materials.length - 1"
-                      color="primary"
-                      icon="mdi-plus"
-                      unelevated
-                      style="border-radius: 8px; height: 40px; width: 40px"
-                      @click="addMaterial"
+                <DefaultSelect
+                  v-model="form.weekday"
+                  class="col-4"
+                  clearable
+                  emit-value
+                  label="1º Dia da Semana"
+                  map-options
+                  option-label="label"
+                  option-value="value"
+                  :options="weekdays"
+                  :rules="form.start_time ? [inputRules.required] : []"
+                />
+
+                <DefaultInput
+                  v-model="form.start_time"
+                  class="col-4"
+                  label="Horário Inicial"
+                  mask="##:##"
+                  :rules="
+                    form.weekday !== null
+                      ? [inputRules.required]
+                      : []
+                  "
+                />
+
+                <DefaultInput
+                  :model-value="firstEndTime"
+                  class="col-4"
+                  disable
+                  label="Horário Final"
+                />
+
+                <DefaultSelect
+                  v-model="form.second_weekday"
+                  class="col-4"
+                  clearable
+                  emit-value
+                  label="2º Dia da Semana"
+                  map-options
+                  option-label="label"
+                  option-value="value"
+                  :options="secondWeekdayOptions"
+                  :rules="
+                    form.second_start_time
+                      ? [inputRules.required]
+                      : []
+                  "
+                />
+
+                <DefaultInput
+                  v-model="form.second_start_time"
+                  class="col-4"
+                  label="Horário Inicial do 2º dia"
+                  mask="##:##"
+                  :rules="
+                    form.second_weekday !== null
+                      ? [inputRules.required]
+                      : []
+                  "
+                />
+
+                <DefaultInput
+                  :model-value="secondEndTime"
+                  class="col-4"
+                  disable
+                  label="Horário Final do 2º dia"
+                />
+
+                <DefaultCurrencyInput
+                  v-model="form.contract_register_value"
+                  v-model:error="validationErrors.contract_register_value"
+                  class="col-4"
+                  label="R$ Matrícula"
+                  :rules="[inputRules.required]"
+                />
+
+                <DefaultCurrencyInput
+                  v-model="form.contract_value"
+                  v-model:error="validationErrors.contract_value"
+                  class="col-4"
+                  label="R$ Total do Contrato"
+                  :rules="[inputRules.required]"
+                />
+
+                <DefaultInput
+                  v-model="form.contrat_discount_value"
+                  class="col-4"
+                  label="Desconto em %"
+                  max="100"
+                  min="0"
+                  type="number"
+                />
+
+                <div
+                  v-for="(material, index) in form.materials"
+                  :key="index"
+                  class="col-12"
+                >
+                  <div class="row q-col-gutter-sm items-center">
+                    <DefaultSelect
+                      v-model="material.product_id"
+                      class="col-4"
+                      emit-value
+                      label="Material"
+                      map-options
+                      :options="productOptions"
+                      :rules="
+                        material.product_id
+                          ? [inputRules.required]
+                          : []
+                      "
+                      @update:model-value="onProductSelected(material)"
                     />
-                    <q-btn
-                      v-else
-                      flat
-                      round
-                      dense
-                      icon="mdi-delete-outline"
-                      color="negative"
-                      @click="removeMaterial(index)"
+
+                    <DefaultInput
+                      v-model="material.quantity"
+                      class="col-4"
+                      label="Qtd"
+                      min="1"
+                      type="number"
+                      :rules="
+                        material.product_id
+                          ? [
+                              inputRules.required,
+                              inputRules.minValue(1),
+                            ]
+                          : []
+                      "
+                    />
+
+                    <DefaultCurrencyInput
+                      v-model="material.price"
+                      class="col"
+                      label="R$ Unitário"
+                      :rules="
+                        material.product_id
+                          ? [inputRules.required]
+                          : []
+                      "
                     />
+
+                    <div class="col-auto">
+                      <q-btn
+                        v-if="index === form.materials.length - 1"
+                        color="primary"
+                        icon="mdi-plus"
+                        style="
+                          border-radius: 8px;
+                          height: 40px;
+                          width: 40px;
+                        "
+                        unelevated
+                        @click="addMaterial"
+                      />
+
+                      <q-btn
+                        v-else
+                        color="negative"
+                        dense
+                        flat
+                        icon="mdi-delete-outline"
+                        round
+                        @click="removeMaterial(index)"
+                      />
+                    </div>
                   </div>
                 </div>
               </div>
-            </div>
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
             <q-btn
-              outline
               color="primary"
               label="Cancelar"
+              no-caps
+              outline
               @click="onDialogCancel"
             />
+
             <q-btn
               color="primary"
               label="Salvar"
+              no-caps
               type="submit"
               :loading="loading"
             />
@@ -188,20 +256,32 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch } from "vue";
-import { useDialogPluginComponent } from "quasar";
-
-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";
+import {
+  computed,
+  onMounted,
+  ref,
+  useTemplateRef,
+  watch,
+} from "vue";
 
 import {
-  getUnitPackage,
   createUnitPackage,
+  getUnitPackage,
   updateUnitPackage,
 } from "src/api/package";
+
 import { getProductsForSelect } from "src/api/product";
+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 DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -215,159 +295,300 @@ const props = defineProps({
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const formRef = ref(null);
-const loading = ref(false);
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
 
 const products = ref([]);
 
+const firstEndTime = computed(() =>
+  calculateEndTime(
+    form.start_time,
+    form.class_duration_hours,
+  ),
+);
+
+const productOptions = computed(() =>
+  products.value.map((product) => ({
+    label: product.name,
+    price_sale: product.price_sale,
+    value: product.id,
+  })),
+);
+
+const secondEndTime = computed(() =>
+  calculateEndTime(
+    form.second_start_time,
+    form.class_duration_hours,
+  ),
+);
+
+const secondWeekdayOptions = computed(() =>
+  weekdays.filter(
+    (weekday) => weekday.value !== form.weekday,
+  ),
+);
+
 const weekdays = [
-  { value: 1, label: "Segunda" },
-  { value: 2, label: "Terça" },
-  { value: 3, label: "Quarta" },
-  { value: 4, label: "Quinta" },
-  { value: 5, label: "Sexta" },
-  { value: 6, label: "Sábado" },
-  { value: 0, label: "Domingo" },
+  {
+    label: "Segunda",
+    value: 1,
+  },
+  {
+    label: "Terça",
+    value: 2,
+  },
+  {
+    label: "Quarta",
+    value: 3,
+  },
+  {
+    label: "Quinta",
+    value: 4,
+  },
+  {
+    label: "Sexta",
+    value: 5,
+  },
+  {
+    label: "Sábado",
+    value: 6,
+  },
+  {
+    label: "Domingo",
+    value: 0,
+  },
 ];
 
-const trimTime = (time) => (time ? time.slice(0, 5) : null);
+const trimTime = (time) =>
+  time ? time.slice(0, 5) : null;
 
-const calculateEndTime = (startTime, durationHours) => {
-  if (!/^\d{2}:\d{2}$/.test(startTime ?? "")) return null;
-  const [hours, minutes] = startTime.split(":").map(Number);
-  if (hours > 23 || minutes > 59) return null;
-  const end =
-    (hours * 60 + minutes + Math.round(Number(durationHours) * 60)) %
-    (24 * 60);
-  return `${String(Math.floor(end / 60)).padStart(2, "0")}:${String(
-    end % 60,
-  ).padStart(2, "0")}`;
-};
+const { form, getUpdatedFields } = useForm({
+  class_duration_hours:
+    (props.package?.class_duration_minutes ?? 120) / 60,
 
-const productOptions = computed(() =>
-  products.value.map((p) => ({
-    label: p.name,
-    value: p.id,
-    price_sale: p.price_sale,
-  })),
-);
+  contract_register_value:
+    props.package?.contract_register_value ?? null,
+
+  contract_value: props.package?.contract_value ?? null,
+
+  contrat_discount_value:
+    props.package?.contrat_discount_value ?? null,
+
+  materials: [
+    {
+      price: null,
+      product_id: null,
+      quantity: 1,
+    },
+  ],
 
-const form = ref({
   name: props.package?.name ?? null,
   quantity_classes: props.package?.quantity_classes ?? null,
-  contract_register_value: props.package?.contract_register_value ?? null,
-  contract_value: props.package?.contract_value ?? null,
-  contrat_discount_value: props.package?.contrat_discount_value ?? null,
-  class_duration_hours:
-    (props.package?.class_duration_minutes ?? 120) / 60,
-  weekday: props.package?.weekday ?? null,
-  start_time: trimTime(props.package?.start_time),
+
+  second_start_time: trimTime(
+    props.package?.second_start_time,
+  ),
+
   second_weekday: props.package?.second_weekday ?? null,
-  second_start_time: trimTime(props.package?.second_start_time),
-  materials: [{ product_id: null, quantity: 1, price: null }],
+  start_time: trimTime(props.package?.start_time),
+  weekday: props.package?.weekday ?? null,
 });
 
-const secondWeekdayOptions = computed(() =>
-  weekdays.filter((day) => day.value !== form.value.weekday),
-);
-const firstEndTime = computed(() =>
-  calculateEndTime(form.value.start_time, form.value.class_duration_hours),
-);
-const secondEndTime = computed(() =>
-  calculateEndTime(
-    form.value.second_start_time,
-    form.value.class_duration_hours,
-  ),
-);
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (result) => {
+    onDialogOK(result);
+  },
+  scrollFn: scrollToComponent,
+});
 
-watch(
-  () => form.value.weekday,
-  (weekday) => {
-    if (form.value.second_weekday === weekday) {
-      form.value.second_weekday = null;
+const addMaterial = () => {
+  form.materials.push({
+    price: null,
+    product_id: null,
+    quantity: 1,
+  });
+};
+
+const calculateEndTime = (startTime, durationHours) => {
+  if (!/^\d{2}:\d{2}$/.test(startTime ?? "")) {
+    return null;
+  }
+
+  const [hours, minutes] = startTime
+    .split(":")
+    .map(Number);
+
+  if (hours > 23 || minutes > 59) {
+    return null;
+  }
+
+  const durationMinutes = Math.round(
+    Number(durationHours) * 60,
+  );
+
+  const endMinutes =
+    (hours * 60 + minutes + durationMinutes) %
+    (24 * 60);
+
+  return `${String(
+    Math.floor(endMinutes / 60),
+  ).padStart(2, "0")}:${String(
+    endMinutes % 60,
+  ).padStart(2, "0")}`;
+};
+
+const onOKClick = async () => {
+  const validMaterials = form.materials.filter(
+    (material) => material.product_id,
+  );
+
+  const payload = {
+    class_duration_minutes: Math.round(
+      Number(form.class_duration_hours) * 60,
+    ),
+
+    contract_register_value:
+      form.contract_register_value,
+
+    contract_value: form.contract_value,
+
+    contrat_discount_value:
+      form.contrat_discount_value,
+
+    materials: validMaterials.map((material) => ({
+      price: Number(material.price),
+      product_id: material.product_id,
+      quantity: Number(material.quantity),
+    })),
+
+    name: form.name,
+    quantity_classes: form.quantity_classes,
+
+    second_start_time:
+      form.second_start_time || null,
+
+    second_weekday: form.second_weekday,
+    start_time: form.start_time || null,
+    weekday: form.weekday,
+  };
+
+  await execute(() => {
+    if (!props.package?.id) {
+      return createUnitPackage(payload);
+    }
+
+    const changedFields = getUpdatedFields.value;
+    const updatePayload = {};
+
+    const directKeys = [
+      "name",
+      "quantity_classes",
+      "contract_value",
+      "contract_register_value",
+      "contrat_discount_value",
+      "weekday",
+      "start_time",
+      "second_weekday",
+      "second_start_time",
+    ];
+
+    for (const key of directKeys) {
+      if (key in changedFields) {
+        updatePayload[key] = payload[key];
+      }
+    }
+
+    if ("class_duration_hours" in changedFields) {
+      updatePayload.class_duration_minutes =
+        payload.class_duration_minutes;
     }
-  },
-);
+
+    if ("materials" in changedFields) {
+      updatePayload.materials = payload.materials;
+    }
+
+    return updateUnitPackage(
+      props.package.id,
+      updatePayload,
+    );
+  });
+};
 
 const onProductSelected = (material) => {
   const option = productOptions.value.find(
-    (o) => o.value === material.product_id,
+    (product) => product.value === material.product_id,
   );
-  if (option) material.price = option.price_sale;
-};
 
-const addMaterial = () => {
-  form.value.materials.push({ product_id: null, quantity: 1, price: null });
+  if (option) {
+    material.price = option.price_sale;
+  }
 };
 
 const removeMaterial = (index) => {
-  form.value.materials.splice(index, 1);
+  form.materials.splice(index, 1);
 };
 
-onMounted(async () => {
-  const [allProducts] = await Promise.all([getProductsForSelect()]);
-  products.value = allProducts;
-
-  if (props.package?.id) {
-    const pkg = await getUnitPackage(props.package.id);
-    form.value.class_duration_hours =
-      (pkg.class_duration_minutes ?? 120) / 60;
-    form.value.weekday = pkg.weekday ?? null;
-    form.value.start_time = trimTime(pkg.start_time);
-    form.value.second_weekday = pkg.second_weekday ?? null;
-    form.value.second_start_time = trimTime(pkg.second_start_time);
-    if (pkg.materials?.length) {
-      form.value.materials = pkg.materials.map((m) => ({
-        product_id: m.product_id,
-        quantity: m.quantity,
-        price: m.price,
-      }));
+watch(
+  () => form.weekday,
+  (weekday) => {
+    if (form.second_weekday === weekday) {
+      form.second_weekday = null;
     }
-  }
-});
+  },
+);
 
-const onOKClick = async () => {
-  loading.value = true;
-  try {
-    const validMaterials = form.value.materials.filter((m) => m.product_id);
-
-    const payload = {
-      name: form.value.name,
-      quantity_classes: form.value.quantity_classes,
-      contract_value: form.value.contract_value,
-      contract_register_value: form.value.contract_register_value,
-      contrat_discount_value: form.value.contrat_discount_value,
-      class_duration_minutes: Math.round(
-        Number(form.value.class_duration_hours) * 60,
-      ),
-      weekday: form.value.weekday,
-      start_time: form.value.start_time || null,
-      second_weekday: form.value.second_weekday,
-      second_start_time: form.value.second_start_time || null,
-      materials: validMaterials.map((m) => ({
-        product_id: m.product_id,
-        quantity: Number(m.quantity),
-        price: Number(m.price),
-      })),
-    };
-
-    const result = props.package?.id
-      ? await updateUnitPackage(props.package.id, payload)
-      : await createUnitPackage(payload);
+onMounted(async () => {
+  const productsResponse = await getProductsForSelect();
 
-    onDialogOK(result);
-  } finally {
-    loading.value = false;
+  products.value = productsResponse;
+
+  if (!props.package?.id) return;
+
+  const packageData = await getUnitPackage(
+    props.package.id,
+  );
+
+  form.class_duration_hours =
+    (packageData.class_duration_minutes ?? 120) / 60;
+
+  form.second_start_time = trimTime(
+    packageData.second_start_time,
+  );
+
+  form.second_weekday =
+    packageData.second_weekday ?? null;
+
+  form.start_time = trimTime(packageData.start_time);
+
+  form.weekday = packageData.weekday ?? null;
+
+  if (packageData.materials?.length) {
+    form.materials = packageData.materials.map(
+      (material) => ({
+        price: material.price,
+        product_id: material.product_id,
+        quantity: material.quantity,
+      }),
+    );
   }
-};
+});
 </script>
 
 <style scoped>
 .package-duration-hint {
+  padding-top: 4px;
+  padding-bottom: 2px;
   color: #757575;
   font-size: 12px;
   line-height: 1.35;
-  padding-top: 4px;
-  padding-bottom: 2px;
 }
 </style>

+ 118 - 28
src/pages/permissions/components/AddEditPermissionGroupDialog.vue

@@ -1,33 +1,63 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="dialog-form-card" style="min-width: 440px; max-width: 90vw">
-      <DefaultDialogHeader :title="isEdit ? 'Editar Grupo de Permissões' : 'Novo Grupo de Permissões'" @close="onDialogCancel" />
-      <DefaultForm ref="formRef" @submit="onSave">
-        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
-          <q-card-section class="column q-gutter-md">
+    <q-card
+      class="dialog-form-card"
+      style="min-width: 440px; max-width: 90vw"
+    >
+      <DefaultDialogHeader
+        :title="
+          isEdit
+            ? 'Editar Grupo de Permissões'
+            : 'Novo Grupo de Permissões'
+        "
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onSave"
+      >
+        <q-card-section class="column q-gutter-md permission-group-fields">
           <DefaultInput
             v-model="form.label"
-            label="Nome"
             autofocus
-            :rules="[inputRules.required]"
+            label="Nome"
             :error="!!validationErrors.label"
             :error-message="validationErrors.label"
+            :rules="[inputRules.required]"
             @update:model-value="onLabelChange"
           />
+
           <DefaultInput
             v-model="form.slug"
-            label="Código"
             hint="Identificador único. Ex.: FINANCEIRO"
-            :rules="[inputRules.required, slugRule]"
+            label="Código"
             :error="!!validationErrors.slug"
             :error-message="validationErrors.slug"
+            :rules="[inputRules.required, slugRule]"
             @update:model-value="onSlugTouched"
           />
-          </q-card-section>
-        </q-scroll-area>
-        <q-card-actions align="right">
-          <q-btn label="Cancelar" color="negative" outline @click="onDialogCancel" />
-          <q-btn label="Salvar" color="secondary" type="submit" :loading="loading" />
+        </q-card-section>
+
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="secondary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            :loading="loading"
+          />
         </q-card-actions>
       </DefaultForm>
     </q-card>
@@ -36,30 +66,90 @@
 
 <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 { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createUserType, updateUserType } from "src/api/user_type";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 
-const { group } = defineProps({ group: { type: Object, default: null } });
 defineEmits([...useDialogPluginComponent.emits]);
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const { group } = defineProps({
+  group: {
+    type: Object,
+    default: null,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
 const { inputRules } = useInputRules();
+
 const formRef = useTemplateRef("formRef");
+
+const slugTouched = ref(!!group);
+
 const isEdit = computed(() => !!group);
-const form = ref({ label: group?.label ?? "", slug: group?.slug ?? "" });
-const slugTouched = ref(isEdit.value);
-const toSlug = (value) => (value ?? "").normalize("NFD").replace(/\p{Diacritic}/gu, "").toUpperCase().replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
-const slugRule = (value) => /^[A-Z0-9_]+$/.test(value ?? "") || "Use apenas letras maiúsculas, números e underline.";
-const onLabelChange = (value) => { if (!slugTouched.value) form.value.slug = toSlug(value); };
-const onSlugTouched = (value) => { slugTouched.value = true; form.value.slug = toSlug(value); };
-const { loading, validationErrors, execute } = useSubmitHandler({
+
+const { form, getUpdatedFields } = useForm({
+  label: group?.label ?? "",
+  slug: group?.slug ?? "",
+});
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   formRef,
-  onSuccess: () => onDialogOK(),
+  onSuccess: () => {
+    onDialogOK();
+  },
 });
-const onSave = () => execute(() => isEdit.value
-  ? updateUserType(group.id, form.value)
-  : createUserType(form.value));
+
+const onLabelChange = (value) => {
+  if (slugTouched.value) return;
+
+  form.slug = toSlug(value);
+};
+
+const onSave = () =>
+  execute(() => {
+    if (isEdit.value) {
+      return updateUserType(
+        group.id,
+        { ...getUpdatedFields.value },
+      );
+    }
+
+    return createUserType({ ...form });
+  });
+
+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>
+
+<style scoped>
+.permission-group-fields {
+  min-height: 220px;
+}
+</style>

+ 265 - 53
src/pages/permissions/components/PermissionGroupDialog.vue

@@ -1,47 +1,161 @@
 <template>
-  <q-dialog ref="dialogRef" full-height @hide="onDialogHide">
-    <q-card class="column no-wrap" style="width: 100%; max-width: 95vw; height: 100%">
-      <DefaultDialogHeader :title="`Permissões: ${group.label}`" @close="onDialogCancel" />
-      <q-card-section class="col column no-wrap q-pa-none" style="min-height: 0">
-        <div class="row items-stretch col no-wrap" style="min-height: 0">
-          <div class="col-12 col-md-6 column no-wrap" style="min-height: 0">
-            <div class="text-center text-subtitle1 text-weight-medium q-pa-sm">Menu</div>
+  <q-dialog
+    ref="dialogRef"
+    full-height
+    @hide="onDialogHide"
+  >
+    <q-card
+      class="column no-wrap"
+      style="width: 100%; max-width: 95vw; height: 100%"
+    >
+      <DefaultDialogHeader
+        :title="`Permissões: ${group.label}`"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section
+        class="col column no-wrap q-pa-none"
+        style="min-height: 0"
+      >
+        <div
+          class="row items-stretch col no-wrap"
+          style="min-height: 0"
+        >
+          <div
+            class="col-12 col-md-6 column no-wrap"
+            style="min-height: 0"
+          >
+            <div class="text-center text-subtitle1 text-weight-medium q-pa-sm">
+              Menu
+            </div>
+
             <q-separator />
+
             <q-inner-loading :showing="loading" />
-            <q-list v-if="!loading" separator class="col scroll">
-              <q-item v-for="item in menus" :key="item.scope" clickable :active="selectedScope === item.scope" active-class="bg-orange-1 text-secondary" @click="selectedScope = item.scope">
-                <q-item-section avatar><q-icon :name="item.icon" color="dark" /></q-item-section>
+
+            <q-list
+              v-if="!loading"
+              class="col scroll"
+              separator
+            >
+              <q-item
+                v-for="item in menus"
+                :key="item.scope"
+                :active="selectedScope === item.scope"
+                active-class="bg-orange-1 text-secondary"
+                clickable
+                @click="selectedScope = item.scope"
+              >
+                <q-item-section avatar>
+                  <q-icon
+                    color="dark"
+                    :name="item.icon"
+                  />
+                </q-item-section>
+
                 <q-item-section>
-                  <q-item-label>{{ item.label }}</q-item-label>
-                  <q-item-label caption><PermissionLevelBadges :bits="bitsFor(item.scope)" /></q-item-label>
+                  <q-item-label>
+                    {{ item.label }}
+                  </q-item-label>
+
+                  <q-item-label caption>
+                    <PermissionLevelBadges
+                      :bits="bitsFor(item.scope)"
+                    />
+                  </q-item-label>
                 </q-item-section>
+
                 <q-item-section side>
-                  <q-btn flat round dense icon="mdi-pencil-outline" color="dark" @click.stop>
-                    <q-tooltip>Editar permissões</q-tooltip>
-                    <PermissionLevelMenu :bits="bitsFor(item.scope)" :available-bits="availableBitsFor(item.scope)" @update:bits="(value) => setBits(item.scope, value)" />
+                  <q-btn
+                    color="dark"
+                    dense
+                    flat
+                    icon="mdi-pencil-outline"
+                    round
+                    @click.stop
+                  >
+                    <q-tooltip>
+                      Editar permissões
+                    </q-tooltip>
+
+                    <PermissionLevelMenu
+                      :available-bits="availableBitsFor(item.scope)"
+                      :bits="bitsFor(item.scope)"
+                      @update:bits="
+                        (value) => setBits(item.scope, value)
+                      "
+                    />
                   </q-btn>
                 </q-item-section>
               </q-item>
             </q-list>
           </div>
+
           <q-separator vertical />
-          <div class="col column no-wrap" style="min-height: 0">
-            <div class="text-center text-subtitle1 text-weight-medium q-pa-sm">Detalhes</div>
+
+          <div
+            class="col column no-wrap"
+            style="min-height: 0"
+          >
+            <div class="text-center text-subtitle1 text-weight-medium q-pa-sm">
+              Detalhes
+            </div>
+
             <q-separator />
-            <div v-if="!selected" class="col column flex-center text-center text-grey-7 q-pa-md">
-              <div>Nenhum menu selecionado!</div>
-              <div class="text-caption">← Selecione um menu à esquerda para visualizar suas permissões.</div>
+
+            <div
+              v-if="!selected"
+              class="col column flex-center text-center text-grey-7 q-pa-md"
+            >
+              <div>
+                Nenhum menu selecionado!
+              </div>
+
+              <div class="text-caption">
+                ← Selecione um menu à esquerda para visualizar suas permissões.
+              </div>
             </div>
-            <q-list v-else separator class="col scroll">
+
+            <q-list
+              v-else
+              class="col scroll"
+              separator
+            >
               <q-item>
-                <q-item-section avatar><q-icon :name="selected.icon" color="dark" /></q-item-section>
+                <q-item-section avatar>
+                  <q-icon
+                    color="dark"
+                    :name="selected.icon"
+                  />
+                </q-item-section>
+
                 <q-item-section>
-                  <q-item-label>{{ selected.label }}</q-item-label>
-                  <q-item-label caption><PermissionLevelBadges :bits="bitsFor(selected.scope)" /></q-item-label>
+                  <q-item-label>
+                    {{ selected.label }}
+                  </q-item-label>
+
+                  <q-item-label caption>
+                    <PermissionLevelBadges
+                      :bits="bitsFor(selected.scope)"
+                    />
+                  </q-item-label>
                 </q-item-section>
+
                 <q-item-section side>
-                  <q-btn flat round dense icon="mdi-pencil-outline" color="dark">
-                    <PermissionLevelMenu :bits="bitsFor(selected.scope)" :available-bits="availableBitsFor(selected.scope)" @update:bits="(value) => setBits(selected.scope, value)" />
+                  <q-btn
+                    color="dark"
+                    dense
+                    flat
+                    icon="mdi-pencil-outline"
+                    round
+                  >
+                    <PermissionLevelMenu
+                      :available-bits="availableBitsFor(selected.scope)"
+                      :bits="bitsFor(selected.scope)"
+                      @update:bits="
+                        (value) => setBits(selected.scope, value)
+                      "
+                    />
                   </q-btn>
                 </q-item-section>
               </q-item>
@@ -49,65 +163,163 @@
           </div>
         </div>
       </q-card-section>
-      <q-separator />
-      <q-card-actions align="right" class="q-pa-md">
-        <q-btn label="Cancelar" color="negative" outline @click="onDialogCancel" />
-        <q-btn label="Salvar" color="secondary" :loading="saving" :disable="loading" @click="onSave" />
+
+      <q-card-actions
+        align="right"
+        class="q-pa-md"
+      >
+        <q-btn
+          color="primary"
+          label="Cancelar"
+          no-caps
+          outline
+          @click="onDialogCancel"
+        />
+
+        <q-btn
+          color="secondary"
+          label="Salvar"
+          no-caps
+          :disable="loading"
+          :loading="saving"
+          @click="onSave"
+        />
       </q-card-actions>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
+import {
+  applyComputedLevels,
+  FRANCHISEE_PERMISSION_MENUS,
+} from "../permission_menus";
+
 import { computed, onMounted, ref } from "vue";
+
+import {
+  getUserTypePermissions,
+  updateUserTypePermissions,
+} from "src/api/user_type";
+
 import { Notify, useDialogPluginComponent } from "quasar";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import PermissionLevelBadges from "./PermissionLevelBadges.vue";
 import PermissionLevelMenu from "./PermissionLevelMenu.vue";
-import { getUserTypePermissions, updateUserTypePermissions } from "src/api/user_type";
-import { FRANCHISEE_PERMISSION_MENUS, applyComputedLevels } from "../permission_menus";
 
-const { group } = defineProps({ group: { type: Object, required: true } });
 defineEmits([...useDialogPluginComponent.emits]);
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const { group } = defineProps({
+  group: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const availableBitsByScope = ref({});
+const bitsByScope = ref({});
 const loading = ref(false);
+const permissionIdByScope = ref({});
 const saving = ref(false);
 const selectedScope = ref(null);
-const permissionIdByScope = ref({});
-const bitsByScope = ref({});
-const availableBitsByScope = ref({});
-const menus = computed(() => FRANCHISEE_PERMISSION_MENUS.filter((menu) => permissionIdByScope.value[menu.scope] !== undefined));
-const selected = computed(() => menus.value.find((menu) => menu.scope === selectedScope.value) ?? null);
-const bitsFor = (scope) => bitsByScope.value[scope] ?? 0;
-const availableBitsFor = (scope) => availableBitsByScope.value[scope] ?? 0;
-const setBits = (scope, value) => { bitsByScope.value[scope] = value; };
+
+const menus = computed(() =>
+  FRANCHISEE_PERMISSION_MENUS.filter(
+    (menu) =>
+      permissionIdByScope.value[menu.scope] !== undefined,
+  ),
+);
+
+const selected = computed(
+  () =>
+    menus.value.find(
+      (menu) => menu.scope === selectedScope.value,
+    ) ?? null,
+);
+
+const availableBitsFor = (scope) =>
+  availableBitsByScope.value[scope] ?? 0;
+
+const bitsFor = (scope) =>
+  bitsByScope.value[scope] ?? 0;
 
 const fetchPermissions = async () => {
   loading.value = true;
+
   try {
     const payload = await getUserTypePermissions(group.id);
-    const byScope = Object.fromEntries(payload.permissions.map((permission) => [permission.scope, permission]));
+
+    const permissionsByScope = Object.fromEntries(
+      payload.permissions.map((permission) => [
+        permission.scope,
+        permission,
+      ]),
+    );
+
     for (const menu of FRANCHISEE_PERMISSION_MENUS) {
-      const permission = byScope[menu.scope];
+      const permission = permissionsByScope[menu.scope];
+
       if (!permission) continue;
-      permissionIdByScope.value[menu.scope] = permission.id;
-      availableBitsByScope.value[menu.scope] = permission.available_bits;
-      bitsByScope.value[menu.scope] = applyComputedLevels(permission.bits);
+
+      availableBitsByScope.value[menu.scope] =
+        permission.available_bits;
+
+      bitsByScope.value[menu.scope] =
+        applyComputedLevels(permission.bits);
+
+      permissionIdByScope.value[menu.scope] =
+        permission.id;
     }
   } catch {
-    Notify.create({ message: "Não foi possível carregar as permissões do grupo.", type: "negative" });
-  } finally { loading.value = false; }
+    Notify.create({
+      message:
+        "Não foi possível carregar as permissões do grupo.",
+      type: "negative",
+    });
+  } finally {
+    loading.value = false;
+  }
 };
 
 const onSave = async () => {
   saving.value = true;
+
   try {
-    await updateUserTypePermissions(group.id, Object.entries(permissionIdByScope.value).map(([scope, id]) => ({ permission_id: id, bits: bitsByScope.value[scope] ?? 0 })));
-    Notify.create({ message: "Permissões atualizadas.", type: "positive" });
+    const permissions = Object.entries(
+      permissionIdByScope.value,
+    ).map(([scope, id]) => ({
+      bits: bitsByScope.value[scope] ?? 0,
+      permission_id: id,
+    }));
+
+    await updateUserTypePermissions(
+      group.id,
+      permissions,
+    );
+
+    Notify.create({
+      message: "Permissões atualizadas.",
+      type: "positive",
+    });
+
     onDialogOK();
   } catch {
-    Notify.create({ message: "Não foi possível salvar as permissões.", type: "negative" });
-  } finally { saving.value = false; }
+    Notify.create({
+      message:
+        "Não foi possível salvar as permissões.",
+      type: "negative",
+    });
+  } finally {
+    saving.value = false;
+  }
+};
+
+const setBits = (scope, value) => {
+  bitsByScope.value[scope] = value;
 };
 
 onMounted(fetchPermissions);

File diff suppressed because it is too large
+ 527 - 473
src/pages/students/components/AddEditContractDialog.vue


+ 596 - 318
src/pages/students/components/AddEditStudentDialog.vue

@@ -4,10 +4,19 @@
       class="q-dialog-plugin dialog-form-card"
       style="width: 100%; max-width: 1100px"
     >
-      <DefaultDialogHeader title="Cadastrar Aluno" @close="onDialogCancel" />
-
-      <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area ref="scrollAreaRef" class="dialog-form-scroll">
+      <DefaultDialogHeader
+        title="Cadastrar Aluno"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
+        <q-scroll-area
+          ref="scrollAreaRef"
+          class="dialog-form-scroll"
+        >
           <q-card-section class="q-pt-sm">
             <CustomTabComponent
               :active-tab="activeTab"
@@ -16,11 +25,14 @@
               @update:active-tab="handleTabChange"
             />
 
-            <div v-show="activeTab === 'student'" ref="studentTabRef">
+            <div
+              v-show="activeTab === 'student'"
+              ref="studentTabRef"
+            >
               <q-banner
                 v-if="validationErrors.registration_draft_token"
-                rounded
                 class="bg-red-1 text-negative q-mb-md"
+                rounded
               >
                 {{ validationErrors.registration_draft_token }}
               </q-banner>
@@ -35,153 +47,161 @@
               <div class="row q-col-gutter-sm">
                 <DefaultInput
                   v-model="form.name"
+                  class="col-6"
+                  label="Nome do Aluno"
                   :error="!!validationErrors.name"
                   :error-message="validationErrors.name"
-                  label="Nome do Aluno"
-                  class="col-6"
                   :rules="[inputRules.required]"
                 />
 
                 <DefaultInputDatePicker
                   v-model="form.birthdate"
+                  class="col-6"
+                  label="Data de Nascimento"
                   :error="!!validationErrors.birth_date"
                   :error-message="validationErrors.birth_date"
-                  label="Data de Nascimento"
-                  class="col-6"
                   :rules="[inputRules.required]"
                 />
 
                 <DefaultInput
                   v-model="form.cpf"
+                  class="col-6"
+                  label="CPF"
                   :error="!!validationErrors.document_number"
                   :error-message="validationErrors.document_number"
-                  label="CPF"
-                  class="col-6"
                   :mask="masks.Brasil.cpf"
                   :rules="[inputRules.cpf]"
                 />
 
                 <DefaultSelect
                   v-model="form.gender"
-                  :error="!!validationErrors.gender"
-                  :error-message="validationErrors.gender"
-                  label="Gênero"
                   class="col-6"
                   emit-value
+                  label="Gênero"
                   map-options
+                  :error="!!validationErrors.gender"
+                  :error-message="validationErrors.gender"
                   :options="genderOptions"
                 />
 
                 <DefaultInput
                   v-model="form.email"
-                  :error="!!validationErrors.email"
-                  :error-message="validationErrors.email"
-                  label="E-mail"
                   class="col-6"
+                  label="E-mail"
                   type="email"
+                  :error="!!validationErrors.email"
+                  :error-message="validationErrors.email"
                   :rules="[inputRules.email]"
                 />
 
                 <DefaultInput
                   v-model="form.phone"
+                  class="col-6"
+                  label="Celular com DDD"
                   :error="!!validationErrors.phone"
                   :error-message="validationErrors.phone"
-                  label="Celular com DDD"
-                  class="col-6"
                   :mask="masks.Brasil.celular"
                 />
 
                 <DefaultCepInput
                   v-model="form.cep"
+                  class="col-4"
                   :error="!!validationErrors.postal_code"
                   :error-message="validationErrors.postal_code"
-                  class="col-4"
                   :rules="[inputRules.cep]"
-                  @rua="(v) => (form.address = v)"
-                  @bairro="(v) => (form.neighborhood = v)"
-                  @uf="(v) => stateSelectRef?.selectStateByCode(v)"
-                  @cidade="(v) => citySelectRef?.selectCityByName(v)"
+                  @bairro="(value) => (form.neighborhood = value)"
+                  @cidade="
+                    (value) =>
+                      citySelectRef?.selectCityByName(value)
+                  "
+                  @rua="(value) => (form.address = value)"
+                  @uf="
+                    (value) =>
+                      stateSelectRef?.selectStateByCode(value)
+                  "
                 />
 
                 <DefaultInput
                   v-model="form.address"
+                  class="col-5"
+                  label="Endereço"
                   :error="!!validationErrors.street"
                   :error-message="validationErrors.street"
-                  label="Endereço"
-                  class="col-5"
                 />
 
                 <DefaultInput
                   v-model="form.address_number"
+                  class="col-3"
+                  label="Número"
                   :error="!!validationErrors.address_number"
                   :error-message="validationErrors.address_number"
-                  label="Número"
-                  class="col-3"
                 />
 
                 <DefaultInput
                   v-model="form.neighborhood"
+                  class="col-4"
+                  label="Bairro"
                   :error="!!validationErrors.neighborhood"
                   :error-message="validationErrors.neighborhood"
-                  label="Bairro"
-                  class="col-4"
                 />
 
                 <CitySelect
                   ref="citySelectRef"
                   v-model="selectedCity"
+                  class="col-4"
+                  label="Cidade"
                   :error="!!validationErrors.city_id"
                   :error-message="validationErrors.city_id"
-                  label="Cidade"
-                  class="col-4"
                   :state="selectedState"
                 />
 
                 <StateSelect
                   ref="stateSelectRef"
                   v-model="selectedState"
+                  class="col-4"
+                  label="Estado"
                   :error="!!validationErrors.state_id"
                   :error-message="validationErrors.state_id"
-                  label="Estado"
-                  class="col-4"
                 />
 
                 <DefaultInput
                   v-model="form.complement"
+                  class="col-6"
+                  label="Complemento"
                   :error="!!validationErrors.complement"
                   :error-message="validationErrors.complement"
-                  label="Complemento"
-                  class="col-6"
                 />
 
                 <DefaultInput
                   v-model="form.payer"
+                  class="col-6"
+                  label="Pagador"
                   :error="!!validationErrors.payer_name"
                   :error-message="validationErrors.payer_name"
-                  label="Pagador"
-                  class="col-6"
                 />
 
                 <DefaultSelect
                   v-model="form.how_found"
-                  :error="!!validationErrors.how_did_you_know_us"
-                  :error-message="validationErrors.how_did_you_know_us"
-                  label="Como nos conheceu?"
                   class="col-12"
                   emit-value
+                  label="Como nos conheceu?"
                   map-options
+                  :error="!!validationErrors.how_did_you_know_us"
+                  :error-message="
+                    validationErrors.how_did_you_know_us
+                  "
                   :options="howFoundOptions"
                 />
 
                 <DefaultInput
                   v-model="form.notes"
-                  :error="!!validationErrors.notes"
-                  :error-message="validationErrors.notes"
-                  label="Observações"
+                  autogrow
                   class="col-12"
+                  label="Observações"
                   type="textarea"
+                  :error="!!validationErrors.notes"
+                  :error-message="validationErrors.notes"
                   :input-style="{ minHeight: '120px' }"
-                  autogrow
                 />
               </div>
             </div>
@@ -191,7 +211,10 @@
               v-show="activeTab === 'responsible'"
               ref="responsibleTabRef"
             >
-              <q-banner rounded class="bg-orange-1 text-orange-10 q-mb-md">
+              <q-banner
+                class="bg-orange-1 text-orange-10 q-mb-md"
+                rounded
+              >
                 O aluno é menor de 18 anos. Cadastre um responsável para
                 concluir.
               </q-banner>
@@ -199,37 +222,51 @@
               <div class="row q-col-gutter-sm">
                 <DefaultInput
                   v-model="responsibleForm.name"
-                  :error="!!validationErrors['responsible.name']"
-                  :error-message="validationErrors['responsible.name']"
-                  label="Nome"
                   class="col-6"
+                  label="Nome"
+                  :error="!!validationErrors['responsible.name']"
+                  :error-message="
+                    validationErrors['responsible.name']
+                  "
                   :rules="
-                    activeTab === 'responsible' ? [inputRules.required] : []
+                    activeTab === 'responsible'
+                      ? [inputRules.required]
+                      : []
                   "
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.degree"
-                  :error="!!validationErrors['responsible.degree']"
-                  :error-message="validationErrors['responsible.degree']"
-                  label="Grau de Parentesco"
                   class="col-6"
+                  label="Grau de Parentesco"
+                  :error="!!validationErrors['responsible.degree']"
+                  :error-message="
+                    validationErrors['responsible.degree']
+                  "
+                  :rules="responsibleRequiredRules"
                 />
 
                 <DefaultInputDatePicker
                   v-model="responsibleForm.birth_date"
-                  :error="!!validationErrors['responsible.birth_date']"
-                  :error-message="validationErrors['responsible.birth_date']"
-                  label="Data de Nascimento"
                   class="col-4"
+                  label="Data de Nascimento"
+                  :error="
+                    !!validationErrors['responsible.birth_date']
+                  "
+                  :error-message="
+                    validationErrors['responsible.birth_date']
+                  "
+                  :rules="responsibleRequiredRules"
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.cpf"
-                  :error="!!validationErrors['responsible.cpf']"
-                  :error-message="validationErrors['responsible.cpf']"
-                  label="CPF"
                   class="col-4"
+                  label="CPF"
+                  :error="!!validationErrors['responsible.cpf']"
+                  :error-message="
+                    validationErrors['responsible.cpf']
+                  "
                   :mask="masks.Brasil.cpf"
                   :rules="
                     activeTab === 'responsible'
@@ -240,123 +277,192 @@
 
                 <DefaultSelect
                   v-model="responsibleForm.gender"
-                  :error="!!validationErrors['responsible.gender']"
-                  :error-message="validationErrors['responsible.gender']"
-                  label="Gênero"
                   class="col-4"
                   emit-value
+                  label="Gênero"
                   map-options
+                  :error="!!validationErrors['responsible.gender']"
+                  :error-message="
+                    validationErrors['responsible.gender']
+                  "
                   :options="genderOptions"
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.email"
-                  :error="!!validationErrors['responsible.email']"
-                  :error-message="validationErrors['responsible.email']"
-                  label="E-mail"
                   class="col-6"
+                  label="E-mail"
                   type="email"
-                  :rules="[inputRules.email]"
+                  :error="!!validationErrors['responsible.email']"
+                  :error-message="
+                    validationErrors['responsible.email']
+                  "
+                  :rules="responsibleEmailRules"
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.phone"
-                  :error="!!validationErrors['responsible.phone']"
-                  :error-message="validationErrors['responsible.phone']"
-                  label="Telefone"
                   class="col-6"
+                  label="Telefone"
+                  :error="!!validationErrors['responsible.phone']"
+                  :error-message="
+                    validationErrors['responsible.phone']
+                  "
                   :mask="masks.Brasil.celular"
+                  :rules="responsibleRequiredRules"
                 />
 
                 <DefaultCepInput
                   v-model="responsibleForm.postal_code"
-                  :error="!!validationErrors['responsible.postal_code']"
-                  :error-message="validationErrors['responsible.postal_code']"
                   class="col-4"
-                  :rules="[inputRules.cep]"
-                  @rua="(v) => (responsibleForm.street = v)"
-                  @bairro="(v) => (responsibleForm.neighborhood = v)"
-                  @uf="(v) => responsibleStateSelectRef?.selectStateByCode(v)"
-                  @cidade="(v) => responsibleCitySelectRef?.selectCityByName(v)"
+                  :error="
+                    !!validationErrors['responsible.postal_code']
+                  "
+                  :error-message="
+                    validationErrors['responsible.postal_code']
+                  "
+                  :rules="responsibleCepRules"
+                  @bairro="
+                    (value) =>
+                      (responsibleForm.neighborhood = value)
+                  "
+                  @cidade="
+                    (value) =>
+                      responsibleCitySelectRef?.selectCityByName(
+                        value,
+                      )
+                  "
+                  @rua="
+                    (value) =>
+                      (responsibleForm.street = value)
+                  "
+                  @uf="
+                    (value) =>
+                      responsibleStateSelectRef?.selectStateByCode(
+                        value,
+                      )
+                  "
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.street"
-                  :error="!!validationErrors['responsible.street']"
-                  :error-message="validationErrors['responsible.street']"
-                  label="Endereço"
                   class="col-5"
+                  label="Endereço"
+                  :error="!!validationErrors['responsible.street']"
+                  :error-message="
+                    validationErrors['responsible.street']
+                  "
+                  :rules="responsibleRequiredRules"
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.address_number"
-                  :error="!!validationErrors['responsible.address_number']"
-                  :error-message="validationErrors['responsible.address_number']"
-                  label="Número"
                   class="col-3"
+                  label="Número"
+                  :error="
+                    !!validationErrors[
+                      'responsible.address_number'
+                    ]
+                  "
+                  :error-message="
+                    validationErrors[
+                      'responsible.address_number'
+                    ]
+                  "
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.neighborhood"
-                  :error="!!validationErrors['responsible.neighborhood']"
-                  :error-message="validationErrors['responsible.neighborhood']"
-                  label="Bairro"
                   class="col-4"
+                  label="Bairro"
+                  :error="
+                    !!validationErrors[
+                      'responsible.neighborhood'
+                    ]
+                  "
+                  :error-message="
+                    validationErrors[
+                      'responsible.neighborhood'
+                    ]
+                  "
+                  :rules="responsibleRequiredRules"
                 />
 
                 <CitySelect
                   ref="responsibleCitySelectRef"
                   v-model="responsibleSelectedCity"
-                  :error="!!validationErrors['responsible.city_id']"
-                  :error-message="validationErrors['responsible.city_id']"
-                  label="Cidade"
                   class="col-4"
+                  label="Cidade"
+                  :error="
+                    !!validationErrors['responsible.city_id']
+                  "
+                  :error-message="
+                    validationErrors['responsible.city_id']
+                  "
+                  :rules="responsibleRequiredRules"
                   :state="responsibleSelectedState"
                 />
 
                 <StateSelect
                   ref="responsibleStateSelectRef"
                   v-model="responsibleSelectedState"
-                  :error="!!validationErrors['responsible.state_id']"
-                  :error-message="validationErrors['responsible.state_id']"
-                  label="Estado"
                   class="col-4"
+                  label="Estado"
+                  :error="
+                    !!validationErrors['responsible.state_id']
+                  "
+                  :error-message="
+                    validationErrors['responsible.state_id']
+                  "
+                  :rules="responsibleRequiredRules"
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.complement"
-                  :error="!!validationErrors['responsible.complement']"
-                  :error-message="validationErrors['responsible.complement']"
-                  label="Complemento"
                   class="col-12"
+                  label="Complemento"
+                  :error="
+                    !!validationErrors['responsible.complement']
+                  "
+                  :error-message="
+                    validationErrors['responsible.complement']
+                  "
                 />
 
                 <DefaultInput
                   v-model="responsibleForm.notes"
-                  :error="!!validationErrors['responsible.notes']"
-                  :error-message="validationErrors['responsible.notes']"
-                  label="Observações"
+                  autogrow
                   class="col-12"
+                  label="Observações"
                   type="textarea"
+                  :error="!!validationErrors['responsible.notes']"
+                  :error-message="
+                    validationErrors['responsible.notes']
+                  "
                   :input-style="{ minHeight: '120px' }"
-                  autogrow
                 />
               </div>
             </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right">
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
           <q-btn
-            outline
             color="primary"
-            label="CANCELAR"
+            label="Cancelar"
+            no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
-            :label="primaryActionLabel"
+            no-caps
             type="submit"
+            :label="primaryActionLabel"
             :loading="loading"
           />
         </q-card-actions>
@@ -366,27 +472,41 @@
 </template>
 
 <script setup>
-import { computed, nextTick, ref, watch, useTemplateRef } from "vue";
+import {
+  computed,
+  nextTick,
+  ref,
+  useTemplateRef,
+  watch,
+} from "vue";
+
+import {
+  createStudent,
+  createStudentRegistrationDraft,
+} from "src/api/student";
+
+import {
+  formatDateDMYtoYMD,
+  isUnderage,
+} from "src/helpers/utils";
+
 import { useDialogPluginComponent } from "quasar";
+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 DefaultCepInput from "src/components/defaults/DefaultCepInput.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 DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import CitySelect from "src/components/selects/CitySelect.vue";
+import StateSelect from "src/components/selects/StateSelect.vue";
 import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
 import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
-import StateSelect from "src/components/selects/StateSelect.vue";
-import CitySelect from "src/components/selects/CitySelect.vue";
-import { useInputRules } from "src/composables/useInputRules";
-import { useScroll } from "src/composables/useScroll";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import {
-  createStudent,
-  createStudentRegistrationDraft,
-} from "src/api/student";
-import masks from "src/helpers/masks";
-import { formatDateDMYtoYMD, isUnderage } from "src/helpers/utils";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -394,130 +514,197 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
 const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
 
+const citySelectRef = useTemplateRef("citySelectRef");
 const formRef = useTemplateRef("formRef");
-const scrollAreaRef = useTemplateRef("scrollAreaRef");
-const studentTabRef = useTemplateRef("studentTabRef");
+
+const responsibleCitySelectRef = useTemplateRef(
+  "responsibleCitySelectRef",
+);
+
+const responsibleStateSelectRef = useTemplateRef(
+  "responsibleStateSelectRef",
+);
+
 const responsibleTabRef = useTemplateRef("responsibleTabRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
 const stateSelectRef = useTemplateRef("stateSelectRef");
-const citySelectRef = useTemplateRef("citySelectRef");
-const responsibleStateSelectRef = useTemplateRef("responsibleStateSelectRef");
-const responsibleCitySelectRef = useTemplateRef("responsibleCitySelectRef");
-
-const REGISTRATION_DRAFT_TOKEN_STORAGE_KEY =
-  "student_registration_draft_token";
+const studentTabRef = useTemplateRef("studentTabRef");
 
+const activeTab = ref("student");
 const avatarFile = ref(null);
-const selectedState = ref(null);
-const selectedCity = ref(null);
-const responsibleSelectedState = ref(null);
+const registrationDraftToken = ref(null);
 const responsibleSelectedCity = ref(null);
-const activeTab = ref("student");
-
-const registrationDraftToken = ref(
-  localStorage.getItem(REGISTRATION_DRAFT_TOKEN_STORAGE_KEY) || null,
-);
-
-const { scrollToComponent } = useScroll();
+const responsibleSelectedState = ref(null);
+const selectedCity = ref(null);
+const selectedState = ref(null);
 
 const form = ref({
-  name: null,
-  birthdate: null,
-  cpf: null,
-  gender: "no_preference",
-  email: null,
-  phone: null,
-  cep: null,
   address: null,
   address_number: null,
-  neighborhood: null,
-  state_id: null,
+  birthdate: null,
+  cep: null,
   city_id: null,
   complement: null,
-  payer: null,
+  cpf: null,
+  email: null,
+  gender: "no_preference",
   how_found: null,
+  name: null,
+  neighborhood: null,
   notes: null,
+  payer: null,
+  phone: null,
+  state_id: null,
 });
 
 const responsibleForm = ref({
-  name: null,
+  address_number: null,
   birth_date: null,
+  city_id: null,
+  complement: null,
   cpf: null,
-  gender: "no_preference",
   degree: null,
   email: null,
+  gender: "no_preference",
+  name: null,
+  neighborhood: null,
+  notes: null,
   phone: null,
   postal_code: null,
-  street: null,
-  address_number: null,
-  neighborhood: null,
-  city_id: null,
   state_id: null,
-  complement: null,
-  notes: null,
+  street: null,
 });
 
-const isStudentUnderage = computed(() => isUnderage(form.value.birthdate));
+const isStudentUnderage = computed(() =>
+  isUnderage(form.value.birthdate),
+);
+
+const responsibleCepRules = computed(() =>
+  activeTab.value === "responsible"
+    ? [inputRules.required, inputRules.cep]
+    : [inputRules.cep],
+);
+
+const responsibleEmailRules = computed(() =>
+  activeTab.value === "responsible"
+    ? [inputRules.required, inputRules.email]
+    : [inputRules.email],
+);
+
+const responsibleRequiredRules = computed(() =>
+  activeTab.value === "responsible"
+    ? [inputRules.required]
+    : [],
+);
+
 const tabs = computed(() => [
-  { name: "student", label: "Dados do Aluno" },
+  {
+    label: "Dados do Aluno",
+    name: "student",
+  },
   ...(isStudentUnderage.value
-    ? [{ name: "responsible", label: "Responsável" }]
+    ? [
+        {
+          label: "Responsável",
+          name: "responsible",
+        },
+      ]
     : []),
 ]);
+
 const primaryActionLabel = computed(() =>
-  isStudentUnderage.value && activeTab.value === "student"
-    ? "PRÓXIMO"
-    : "CADASTRAR",
+  isStudentUnderage.value &&
+  activeTab.value === "student"
+    ? "Próximo"
+    : "Cadastrar",
 );
 
-watch(selectedState, (state) => {
-  form.value.state_id = state?.value ?? null;
-});
+const genderOptions = [
+  {
+    label: "Prefiro não informar",
+    value: "no_preference",
+  },
+  {
+    label: "Masculino",
+    value: "male",
+  },
+  {
+    label: "Feminino",
+    value: "female",
+  },
+  {
+    label: "Outro",
+    value: "other",
+  },
+];
 
-watch(selectedCity, (city) => {
-  form.value.city_id = city?.value ?? null;
-});
+const howFoundOptions = [
+  {
+    label: "Indicação",
+    value: "referral",
+  },
+  {
+    label: "Redes Sociais",
+    value: "social_media",
+  },
+  {
+    label: "Google",
+    value: "google",
+  },
+  {
+    label: "Outro",
+    value: "other",
+  },
+];
 
-watch(responsibleSelectedState, (state) => {
-  responsibleForm.value.state_id = state?.value ?? null;
-});
+const REGISTRATION_DRAFT_TOKEN_STORAGE_KEY =
+  "student_registration_draft_token";
 
-watch(responsibleSelectedCity, (city) => {
-  responsibleForm.value.city_id = city?.value ?? null;
-});
+localStorage.removeItem(
+  REGISTRATION_DRAFT_TOKEN_STORAGE_KEY,
+);
 
-watch(isStudentUnderage, (underage) => {
-  if (!underage && activeTab.value === "responsible") {
-    activeTab.value = "student";
-  }
-});
+const scrollToTabError = async (
+  component,
+  container,
+  options,
+) => {
+  const element = component?.$el ?? component;
 
-const genderOptions = ref([
-  { label: "Prefiro não informar", value: "no_preference" },
-  { label: "Masculino", value: "male" },
-  { label: "Feminino", value: "female" },
-  { label: "Outro", value: "other" },
-]);
+  const studentElement =
+    studentTabRef.value?.$el ?? studentTabRef.value;
 
-const howFoundOptions = ref([
-  { label: "Indicação", value: "referral" },
-  { label: "Redes Sociais", value: "social_media" },
-  { label: "Google", value: "google" },
-  { label: "Outro", value: "other" },
-]);
+  const responsibleElement =
+    responsibleTabRef.value?.$el ??
+    responsibleTabRef.value;
+
+  if (studentElement?.contains(element)) {
+    await setActiveTab("student");
+  } else if (responsibleElement?.contains(element)) {
+    await setActiveTab("responsible");
+  }
+
+  return scrollToComponent(
+    component,
+    container,
+    options,
+  );
+};
 
 const {
   loading: saving,
   validationErrors: submitValidationErrors,
   execute: executeSubmit,
 } = useSubmitHandler({
-  formRef,
   containerRef: scrollAreaRef,
-  scrollFn: scrollToTabError,
+  formRef,
   onSuccess: (student) => {
     clearRegistrationDraftToken();
     onDialogOK(student);
   },
+  scrollFn: scrollToTabError,
 });
 
 const {
@@ -525,199 +712,290 @@ const {
   validationErrors: draftValidationErrors,
   execute: executeDraft,
 } = useSubmitHandler({
-  formRef,
   containerRef: scrollAreaRef,
+  formRef,
   scrollFn: scrollToTabError,
 });
 
-const loading = computed(() => saving.value || validatingStudent.value);
+const loading = computed(
+  () => saving.value || validatingStudent.value,
+);
+
 const validationErrors = computed(() => ({
   ...draftValidationErrors.value,
   ...submitValidationErrors.value,
 }));
 
-watch(validationErrors, (errors) => {
-  const fields = Object.keys(errors);
-  if (fields.some((field) => !field.startsWith("responsible"))) {
-    activeTab.value = "student";
-  } else if (fields.some((field) => field.startsWith("responsible"))) {
-    activeTab.value = "responsible";
-  }
-});
-
-function onAvatarChange(file) {
-  avatarFile.value = file;
-}
-
-function saveRegistrationDraftToken(token) {
-  registrationDraftToken.value = token;
-  localStorage.setItem(REGISTRATION_DRAFT_TOKEN_STORAGE_KEY, token);
-}
-
-function clearRegistrationDraftToken() {
-  registrationDraftToken.value = null;
-  localStorage.removeItem(REGISTRATION_DRAFT_TOKEN_STORAGE_KEY);
-}
-
-async function setActiveTab(tab, resetScroll = false) {
-  activeTab.value = tab;
-  await nextTick();
-
-  if (resetScroll) {
-    scrollAreaRef.value?.setScrollPosition("vertical", 0, 0);
-  }
-}
-
-async function scrollToTabError(component, container, options) {
-  const element = component?.$el ?? component;
-  const studentElement = studentTabRef.value?.$el ?? studentTabRef.value;
-  const responsibleElement =
-    responsibleTabRef.value?.$el ?? responsibleTabRef.value;
-
-  if (studentElement?.contains(element)) {
-    await setActiveTab("student");
-
-  } else if (responsibleElement?.contains(element)) {
-    await setActiveTab("responsible");
-  }
-
-  return scrollToComponent(component, container, options);
-}
-
-function buildResponsiblePayload() {
-  return {
-    name: responsibleForm.value.name,
-    birth_date: responsibleForm.value.birth_date
-      ? formatDateDMYtoYMD(responsibleForm.value.birth_date)
-      : null,
-    cpf: responsibleForm.value.cpf,
-    gender: responsibleForm.value.gender,
-    degree: responsibleForm.value.degree,
-    email: responsibleForm.value.email || null,
-    phone: responsibleForm.value.phone,
-    postal_code: responsibleForm.value.postal_code,
-    street: responsibleForm.value.street,
-    address_number: responsibleForm.value.address_number,
-    neighborhood: responsibleForm.value.neighborhood,
-    city_id: responsibleForm.value.city_id,
-    state_id: responsibleForm.value.state_id,
-    complement: responsibleForm.value.complement,
-    notes: responsibleForm.value.notes,
-  };
-}
-
-function appendResponsibleToFormData(formData) {
+const appendResponsibleToFormData = (formData) => {
   const responsible = buildResponsiblePayload();
+
   Object.entries(responsible).forEach(([field, value]) => {
-    formData.append(`responsible[${field}]`, value ?? "");
+    formData.append(
+      `responsible[${field}]`,
+      value ?? "",
+    );
   });
-}
-
-function buildStudentPayload() {
-  return {
-    name: form.value.name,
-    birth_date: form.value.birthdate
-      ? formatDateDMYtoYMD(form.value.birthdate)
-      : null,
-    document_number: form.value.cpf,
-    gender: form.value.gender,
-    email: form.value.email || null,
-    phone: form.value.phone,
-    postal_code: form.value.cep,
-    street: form.value.address,
-    address_number: form.value.address_number,
-    neighborhood: form.value.neighborhood,
-    state_id: form.value.state_id,
-    city_id: form.value.city_id,
-    complement: form.value.complement,
-    payer_name: form.value.payer,
-    how_did_you_know_us: form.value.how_found,
-    notes: form.value.notes,
-  };
-}
-
-function buildRegistrationDraftPayload() {
-  return {
-    name: form.value.name,
-    birth_date: form.value.birthdate
-      ? formatDateDMYtoYMD(form.value.birthdate)
-      : null,
-    document_number: form.value.cpf,
-    email: form.value.email || null,
-    registration_draft_token: registrationDraftToken.value,
-  };
-}
-
-function appendStudentToFormData(formData) {
+};
+
+const appendStudentToFormData = (formData) => {
   const student = buildStudentPayload();
+
   Object.entries(student).forEach(([field, value]) => {
     formData.append(field, value ?? "");
   });
-}
+};
 
-function buildPayload() {
+const buildPayload = () => {
   if (isStudentUnderage.value) {
     if (avatarFile.value) {
       const formData = new FormData();
+
       appendStudentToFormData(formData);
+
       formData.append(
         "registration_draft_token",
         registrationDraftToken.value,
       );
+
       formData.append("avatar", avatarFile.value);
+
       appendResponsibleToFormData(formData);
+
       return formData;
     }
 
     return {
       ...buildStudentPayload(),
-      registration_draft_token: registrationDraftToken.value,
+      registration_draft_token:
+        registrationDraftToken.value,
       responsible: buildResponsiblePayload(),
     };
   }
 
   if (avatarFile.value) {
     const formData = new FormData();
+
     appendStudentToFormData(formData);
     formData.append("avatar", avatarFile.value);
+
     return formData;
   }
 
   return buildStudentPayload();
-}
+};
 
-async function goToResponsible() {
-  if (validatingStudent.value) return;
+const buildRegistrationDraftPayload = () => ({
+  birth_date: form.value.birthdate
+    ? formatDateDMYtoYMD(form.value.birthdate)
+    : null,
 
-  const draft = await executeDraft(() =>
-    createStudentRegistrationDraft(buildRegistrationDraftPayload()),
+  document_number: form.value.cpf,
+  email: form.value.email || null,
+  name: form.value.name,
+
+  registration_draft_token:
+    registrationDraftToken.value,
+});
+
+const buildResponsiblePayload = () => ({
+  address_number: responsibleForm.value.address_number,
+
+  birth_date: responsibleForm.value.birth_date
+    ? formatDateDMYtoYMD(
+        responsibleForm.value.birth_date,
+      )
+    : null,
+
+  city_id: responsibleForm.value.city_id,
+  complement: responsibleForm.value.complement,
+  cpf: responsibleForm.value.cpf,
+  degree: responsibleForm.value.degree,
+  email: responsibleForm.value.email || null,
+  gender: responsibleForm.value.gender,
+  name: responsibleForm.value.name,
+  neighborhood: responsibleForm.value.neighborhood,
+  notes: responsibleForm.value.notes,
+  phone: responsibleForm.value.phone,
+  postal_code: responsibleForm.value.postal_code,
+  state_id: responsibleForm.value.state_id,
+  street: responsibleForm.value.street,
+});
+
+const buildStudentPayload = () => ({
+  address_number: form.value.address_number,
+
+  birth_date: form.value.birthdate
+    ? formatDateDMYtoYMD(form.value.birthdate)
+    : null,
+
+  city_id: form.value.city_id,
+  complement: form.value.complement,
+  document_number: form.value.cpf,
+  email: form.value.email || null,
+  gender: form.value.gender,
+  how_did_you_know_us: form.value.how_found,
+  name: form.value.name,
+  neighborhood: form.value.neighborhood,
+  notes: form.value.notes,
+  payer_name: form.value.payer,
+  phone: form.value.phone,
+  postal_code: form.value.cep,
+  state_id: form.value.state_id,
+  street: form.value.address,
+});
+
+const clearRegistrationDraftToken = () => {
+  registrationDraftToken.value = null;
+
+  localStorage.removeItem(
+    REGISTRATION_DRAFT_TOKEN_STORAGE_KEY,
   );
+};
+
+const goToResponsible = async () => {
+  if (validatingStudent.value) return;
+
+  const createDraft = () =>
+    createStudentRegistrationDraft(
+      buildRegistrationDraftPayload(),
+    );
+
+  let draft;
+
+  try {
+    draft = await executeDraft(createDraft);
+  } catch (error) {
+    const draftTokenError =
+      draftValidationErrors.value
+        .registration_draft_token;
+
+    if (
+      !draftTokenError ||
+      !registrationDraftToken.value
+    ) {
+      throw error;
+    }
+
+    clearRegistrationDraftToken();
+
+    draft = await executeDraft(createDraft);
+  }
+
   const nextToken = draft?.registration_draft_token;
 
-  if (nextToken && nextToken !== registrationDraftToken.value) {
+  if (
+    nextToken &&
+    nextToken !== registrationDraftToken.value
+  ) {
     saveRegistrationDraftToken(nextToken);
   }
 
   await setActiveTab("responsible", true);
-}
+};
 
-async function handleTabChange(tab) {
+const handleTabChange = async (tab) => {
   if (tab === activeTab.value) return;
 
   if (tab === "responsible") {
     await goToResponsible();
+
     return;
   }
 
   await setActiveTab(tab, true);
-}
+};
 
-async function onOKClick() {
-  if (isStudentUnderage.value && activeTab.value === "student") {
+const onAvatarChange = (file) => {
+  avatarFile.value = file;
+};
+
+const onOKClick = async () => {
+  if (
+    isStudentUnderage.value &&
+    activeTab.value === "student"
+  ) {
     await goToResponsible();
+
+    return;
+  }
+
+  await executeSubmit(() =>
+    createStudent(buildPayload()),
+  );
+};
+
+const saveRegistrationDraftToken = (token) => {
+  registrationDraftToken.value = token;
+
+  localStorage.setItem(
+    REGISTRATION_DRAFT_TOKEN_STORAGE_KEY,
+    token,
+  );
+};
+
+const setActiveTab = async (
+  tab,
+  resetScroll = false,
+) => {
+  activeTab.value = tab;
+
+  await nextTick();
+
+  if (resetScroll) {
+    scrollAreaRef.value?.setScrollPosition(
+      "vertical",
+      0,
+      0,
+    );
+  }
+};
+
+watch(selectedState, (state) => {
+  form.value.state_id = state?.value ?? null;
+});
+
+watch(selectedCity, (city) => {
+  form.value.city_id = city?.value ?? null;
+});
+
+watch(responsibleSelectedState, (state) => {
+  responsibleForm.value.state_id =
+    state?.value ?? null;
+});
+
+watch(responsibleSelectedCity, (city) => {
+  responsibleForm.value.city_id =
+    city?.value ?? null;
+});
+
+watch(isStudentUnderage, (underage) => {
+  if (
+    !underage &&
+    activeTab.value === "responsible"
+  ) {
+    activeTab.value = "student";
+  }
+});
+
+watch(validationErrors, (errors) => {
+  const fields = Object.keys(errors);
+
+  if (
+    fields.some(
+      (field) => !field.startsWith("responsible"),
+    )
+  ) {
+    activeTab.value = "student";
+
     return;
   }
 
-  await executeSubmit(() => createStudent(buildPayload()));
-}
+  if (
+    fields.some((field) =>
+      field.startsWith("responsible"),
+    )
+  ) {
+    activeTab.value = "responsible";
+  }
+});
 </script>

+ 80 - 39
src/pages/students/components/AddStudentMediaDialog.vue

@@ -1,40 +1,61 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin dialog-form-card" style="width: 480px; max-width: 95vw">
-      <DefaultDialogHeader title="Adicionar Mídia" @close="onDialogCancel" />
+    <q-card
+      class="q-dialog-plugin dialog-form-card"
+      style="width: 480px; max-width: 95vw"
+    >
+      <DefaultDialogHeader
+        title="Adicionar Mídia"
+        @close="onDialogCancel"
+      />
 
-      <DefaultForm ref="formRef" @submit="onSubmit">
+      <DefaultForm
+        ref="formRef"
+        @submit="onSubmit"
+      >
         <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
           <q-card-section class="q-pt-none">
-          <div class="column q-gutter-sm">
-            <DefaultInput
-              v-model="form.name"
-              :error="!!validationErrors.name"
-              :error-message="validationErrors.name"
-              label="Nome do documento"
-              :rules="[inputRules.required]"
-            />
-
-            <q-file
-              v-model="selectedFile"
-              :error="!!validationErrors.file"
-              :error-message="validationErrors.file"
-              label="Arquivo"
-              outlined
-              accept="image/*,video/*,.pdf"
-              :rules="[inputRules.required]"
-            >
-              <template #prepend>
-                <q-icon name="mdi-paperclip" />
-              </template>
-            </q-file>
-          </div>
+            <div class="column q-gutter-sm">
+              <DefaultInput
+                v-model="form.name"
+                label="Nome do documento"
+                :error="!!validationErrors.name"
+                :error-message="validationErrors.name"
+                :rules="[inputRules.required]"
+              />
+
+              <CustomFileInput
+                v-model="selectedFile"
+                accept="image/*,video/*,.pdf"
+                label="Arquivo"
+                outlined
+                :error="!!validationErrors.file"
+                :error-message="validationErrors.file"
+                :rules="[inputRules.required]"
+              />
+            </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-pa-md">
-          <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
-          <q-btn color="primary" label="Adicionar" type="submit" :loading="loading" />
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Adicionar"
+            no-caps
+            type="submit"
+            :loading="loading"
+          />
         </q-card-actions>
       </DefaultForm>
     </q-card>
@@ -42,39 +63,59 @@
 </template>
 
 <script setup>
+import { createStudentMedia } from "src/api/student_media";
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
 import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createStudentMedia } from "src/api/student_media";
+
+import CustomFileInput from "src/components/defaults/CustomFileInput.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";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { studentId } = defineProps({
-  studentId: { type: Number, required: true },
+  studentId: {
+    type: Number,
+    required: true,
+  },
 });
 
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
 const { inputRules } = useInputRules();
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
 
 const formRef = ref(null);
+
+const form = ref({
+  name: "",
+});
+
 const selectedFile = ref(null);
-const form = ref({ name: "" });
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   formRef,
-  onSuccess: (result) => onDialogOK(result),
+  onSuccess: (result) => {
+    onDialogOK(result);
+  },
 });
 
-async function onSubmit() {
+const onSubmit = async () => {
   await execute(() => {
     const formData = new FormData();
+
+    formData.append("file", selectedFile.value);
     formData.append("name", form.value.name);
     formData.append("student_id", studentId);
-    formData.append("file", selectedFile.value);
+
     return createStudentMedia(formData);
   });
-}
-</script>
+};
+</script>

+ 161 - 81
src/pages/students/components/EditStudentDialog.vue

@@ -4,58 +4,77 @@
       class="q-dialog-plugin dialog-form-card"
       style="width: 100%; max-width: 1400px"
     >
-      <DefaultDialogHeader title="Dados do Aluno" @close="onDialogCancel" />
-
-      <q-scroll-area class="dialog-form-scroll" style="height: 65vh">
+      <DefaultDialogHeader
+        title="Dados do Aluno"
+        @close="onDialogCancel"
+      />
+
+      <q-scroll-area
+        class="dialog-form-scroll"
+        style="height: 65vh"
+      >
         <q-card-section class="q-pt-sm">
-        <CustomTabComponent
-          v-model:active-tab="currentTab"
-          :tabs="tabs"
-          class="q-mb-md"
-        />
-
-        <div v-show="currentTab === 'profile'">
-          <StudentDataTab
-            ref="studentDataTabRef"
-            :student="props.student"
-            :validation-errors="validationErrors"
-          />
-        </div>
-
-        <div v-show="currentTab === 'responsible'">
-          <ResponsibleTab
-            ref="responsibleTabRef"
-            :student-id="props.student.id"
-            :required="studentIsUnderage"
+          <CustomTabComponent
+            v-model:active-tab="currentTab"
+            class="q-mb-md"
+            :tabs="tabs"
           />
-        </div>
 
-        <div v-if="canViewContracts" v-show="currentTab === 'contracts'">
-          <ContractTab :student="props.student" />
-        </div>
-
-        <div v-if="canViewClasses" v-show="currentTab === 'history'">
-          <HistoryTab />
-        </div>
-
-        <div v-show="currentTab === 'media'">
-          <MediaTab ref="mediaTabRef" :student-id="props.student.id" />
-        </div>
+          <div v-show="currentTab === 'profile'">
+            <StudentDataTab
+              ref="studentDataTabRef"
+              :student="props.student"
+              :validation-errors="validationErrors"
+            />
+          </div>
+
+          <div v-show="currentTab === 'responsible'">
+            <ResponsibleTab
+              ref="responsibleTabRef"
+              :required="studentIsUnderage"
+              :student-id="props.student.id"
+            />
+          </div>
+
+          <div
+            v-if="canViewContracts"
+            v-show="currentTab === 'contracts'"
+          >
+            <ContractTab :student="props.student" />
+          </div>
+
+          <div
+            v-if="canViewClasses"
+            v-show="currentTab === 'history'"
+          >
+            <HistoryTab />
+          </div>
+
+          <div v-show="currentTab === 'media'">
+            <MediaTab
+              ref="mediaTabRef"
+              :student-id="props.student.id"
+            />
+          </div>
         </q-card-section>
       </q-scroll-area>
 
-      <q-separator />
-
-      <q-card-actions align="right">
+      <q-card-actions
+        align="right"
+        class="q-px-md q-pb-md"
+      >
         <q-btn
-          outline
           color="primary"
-          label="CANCELAR"
+          label="Cancelar"
+          no-caps
+          outline
           @click="onDialogCancel"
         />
+
         <q-btn
           color="primary"
-          label="SALVAR"
+          label="Salvar"
+          no-caps
           :loading="saving"
           @click="handleSave"
         />
@@ -65,31 +84,45 @@
 </template>
 
 <script setup>
-import { computed, ref, watch, useTemplateRef, defineAsyncComponent } from "vue";
+import {
+  computed,
+  defineAsyncComponent,
+  ref,
+  useTemplateRef,
+  watch,
+} from "vue";
+
+import { isUnderage } from "src/helpers/utils";
+import { permissionStore } from "src/stores/permission";
+import { updateStudent } from "src/api/student";
 import { useDialogPluginComponent, useQuasar } from "quasar";
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { updateStudent } from "src/api/student";
-import { permissionStore } from "src/stores/permission";
-import { isUnderage } from "src/helpers/utils";
 
-const StudentDataTab = defineAsyncComponent(
-  () => import("src/pages/students/tabs/StudentDataTab.vue"),
-);
-const ResponsibleTab = defineAsyncComponent(
-  () => import("src/pages/students/tabs/ResponsibleTab.vue"),
-);
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
 const ContractTab = defineAsyncComponent(
   () => import("src/pages/students/tabs/ContractTab.vue"),
 );
+
 const HistoryTab = defineAsyncComponent(
   () => import("src/pages/students/tabs/HistoryTab.vue"),
 );
+
 const MediaTab = defineAsyncComponent(
   () => import("src/pages/students/tabs/MediaTab.vue"),
 );
 
+const ResponsibleTab = defineAsyncComponent(
+  () => import("src/pages/students/tabs/ResponsibleTab.vue"),
+);
+
+const StudentDataTab = defineAsyncComponent(
+  () => import("src/pages/students/tabs/StudentDataTab.vue"),
+);
+
+defineEmits([...useDialogPluginComponent.emits]);
+
 const props = defineProps({
   student: {
     type: Object,
@@ -97,65 +130,112 @@ const props = defineProps({
   },
 });
 
-defineEmits([...useDialogPluginComponent.emits]);
-
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const studentDataTabRef = useTemplateRef("studentDataTabRef");
-const responsibleTabRef = useTemplateRef("responsibleTabRef");
+const $q = useQuasar();
+
+const permissions = permissionStore();
+
 const mediaTabRef = useTemplateRef("mediaTabRef");
+const responsibleTabRef = useTemplateRef("responsibleTabRef");
+const studentDataTabRef = useTemplateRef("studentDataTabRef");
 
-const $q = useQuasar();
 const currentTab = ref("profile");
-const studentIsUnderage = computed(() => isUnderage(props.student.birth_date));
-const permissions = permissionStore();
-const canViewContracts = computed(() =>
-  permissions.getAccess("franchisee_contracts", "view"),
-);
+
 const canViewClasses = computed(() =>
   permissions.getAccess("franchisee_classes", "view"),
 );
 
-watch(currentTab, (tab) => {
-  if (tab === "media") mediaTabRef.value?.refresh();
-});
+const canViewContracts = computed(() =>
+  permissions.getAccess("franchisee_contracts", "view"),
+);
 
-const { loading: saving, validationErrors, execute } = useSubmitHandler({
-  onSuccess: () => onDialogOK(true),
-});
+const studentIsUnderage = computed(() =>
+  isUnderage(props.student.birth_date),
+);
 
 const tabs = computed(() => [
-  { name: "profile", label: "Perfil do Aluno" },
-  { name: "responsible", label: "Responsáveis" },
+  {
+    label: "Perfil do Aluno",
+    name: "profile",
+  },
+  {
+    label: "Responsáveis",
+    name: "responsible",
+  },
   ...(canViewContracts.value
-    ? [{ name: "contracts", label: "Contratos" }]
+    ? [
+        {
+          label: "Contratos",
+          name: "contracts",
+        },
+      ]
     : []),
   ...(canViewClasses.value
-    ? [{ name: "history", label: "Frequência" }]
+    ? [
+        {
+          label: "Frequência",
+          name: "history",
+        },
+      ]
     : []),
-  { name: "media", label: "Mídias" },
+  {
+    label: "Mídias",
+    name: "media",
+  },
 ]);
 
-async function handleSave() {
-  const valid = await (studentDataTabRef.value?.validate() ?? true);
+const {
+  loading: saving,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  onSuccess: () => {
+    onDialogOK(true);
+  },
+});
+
+const handleSave = async () => {
+  const valid = await (
+    studentDataTabRef.value?.validate() ?? true
+  );
+
   if (!valid) return;
 
-  const underage = studentDataTabRef.value?.isUnderage() ?? false;
+  const underage =
+    studentDataTabRef.value?.isUnderage() ?? false;
+
   const hasRequiredResponsible = underage
     ? await responsibleTabRef.value?.validateRequired(true)
     : true;
 
   if (!hasRequiredResponsible) {
     currentTab.value = "responsible";
+
     $q.notify({
+      message:
+        "Cadastre ao menos um responsável para o aluno menor de idade.",
       type: "warning",
-      message: "Cadastre ao menos um responsável para o aluno menor de idade.",
     });
+
     return;
   }
 
-  const studentPayload = studentDataTabRef.value.buildPayload();
-  await execute(() => updateStudent(studentPayload, props.student.id));
-}
-</script>
+  const studentPayload =
+    studentDataTabRef.value.buildPayload();
+
+  await execute(() =>
+    updateStudent(
+      studentPayload,
+      props.student.id,
+    ),
+  );
+};
+
+watch(currentTab, (tab) => {
+  if (tab === "media") {
+    mediaTabRef.value?.refresh();
+  }
+});
+</script>

+ 84 - 39
src/pages/students/components/EditStudentMediaDialog.vue

@@ -1,40 +1,61 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin dialog-form-card" style="width: 480px; max-width: 95vw">
-      <DefaultDialogHeader title="Editar Mídia" @close="onDialogCancel" />
+    <q-card
+      class="q-dialog-plugin dialog-form-card"
+      style="width: 480px; max-width: 95vw"
+    >
+      <DefaultDialogHeader
+        title="Editar Mídia"
+        @close="onDialogCancel"
+      />
 
-      <DefaultForm ref="formRef" @submit="onSubmit">
+      <DefaultForm
+        ref="formRef"
+        @submit="onSubmit"
+      >
         <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
           <q-card-section class="q-pt-none">
-          <div class="column q-gutter-sm">
-            <DefaultInput
-              v-model="form.name"
-              :error="!!validationErrors.name"
-              :error-message="validationErrors.name"
-              label="Nome do documento"
-              :rules="[inputRules.required]"
-            />
-
-            <q-file
-              v-model="selectedFile"
-              :error="!!validationErrors.file"
-              :error-message="validationErrors.file"
-              label="Arquivo (opcional)"
-              outlined
-              accept="image/*,video/*,.pdf"
-              clearable
-            >
-              <template #prepend>
-                <q-icon name="mdi-paperclip" />
-              </template>
-            </q-file>
-          </div>
+            <div class="column q-gutter-sm">
+              <DefaultInput
+                v-model="form.name"
+                label="Nome do documento"
+                :error="!!validationErrors.name"
+                :error-message="validationErrors.name"
+                :rules="[inputRules.required]"
+              />
+
+              <CustomFileInput
+                v-model="selectedFile"
+                accept="image/*,video/*,.pdf"
+                clearable
+                label="Arquivo (opcional)"
+                outlined
+                :error="!!validationErrors.file"
+                :error-message="validationErrors.file"
+              />
+            </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-pa-md">
-          <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
-          <q-btn color="primary" label="Salvar" type="submit" :loading="loading" />
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            :loading="loading"
+          />
         </q-card-actions>
       </DefaultForm>
     </q-card>
@@ -43,40 +64,64 @@
 
 <script setup>
 import { ref } from "vue";
+import { updateStudentMedia } from "src/api/student_media";
 import { useDialogPluginComponent } from "quasar";
 import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { updateStudentMedia } from "src/api/student_media";
+
+import CustomFileInput from "src/components/defaults/CustomFileInput.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";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { media } = defineProps({
-  media: { type: Object, required: true },
+  media: {
+    type: Object,
+    required: true,
+  },
 });
 
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
 const { inputRules } = useInputRules();
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
 
 const formRef = ref(null);
+
+const form = ref({
+  name: media.name ?? "",
+});
+
 const selectedFile = ref(null);
-const form = ref({ name: media.name ?? "" });
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   formRef,
-  onSuccess: (result) => onDialogOK(result),
+  onSuccess: (result) => {
+    onDialogOK(result);
+  },
 });
 
-async function onSubmit() {
+const onSubmit = async () => {
   await execute(() => {
     const formData = new FormData();
+
     formData.append("name", form.value.name);
     formData.append("student_id", media.student_id);
+
     if (selectedFile.value) {
       formData.append("file", selectedFile.value);
     }
-    return updateStudentMedia(media.id, formData);
+
+    return updateStudentMedia(
+      media.id,
+      formData,
+    );
   });
-}
-</script>
+};
+</script>

+ 270 - 150
src/pages/students/components/FreezeContractDialog.vue

@@ -1,88 +1,133 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin dialog-form-card" style="width: 100%; max-width: 860px">
-      <DefaultDialogHeader title="Trancar Contrato" @close="onDialogCancel" />
+    <q-card
+      class="q-dialog-plugin dialog-form-card"
+      style="width: 100%; max-width: 860px"
+    >
+      <DefaultDialogHeader
+        title="Trancar Contrato"
+        @close="onDialogCancel"
+      />
 
       <q-scroll-area class="dialog-form-scroll">
         <q-card-section class="q-pt-sm">
+          <div class="row q-col-gutter-sm q-mb-md items-center">
+            <div class="col-12 col-md-4">
+              <DefaultSelect
+                v-model="selectedMonths"
+                emit-value
+                label="Tempo de Trancamento"
+                map-options
+                option-label="label"
+                option-value="value"
+                :option-disable="(option) => option.disable"
+                :options="monthOptions"
+              />
+            </div>
 
-        <!-- Seleção de meses -->
-        <div class="row q-col-gutter-sm q-mb-md items-center">
-          <div class="col-12 col-md-4">
-            <DefaultSelect
-              v-model="selectedMonths"
-              label="Tempo de Trancamento"
-              :options="monthOptions"
-              option-value="value"
-              option-label="label"
-              :option-disable="opt => opt.disable"
-              emit-value
-              map-options
+            <div
+              v-if="selectedMonths"
+              class="col-12 col-md-8"
+            >
+              <q-banner
+                class="bg-blue-1 text-blue-9"
+                dense
+                rounded
+              >
+                <template #avatar>
+                  <q-icon
+                    color="blue-7"
+                    name="mdi-information-outline"
+                  />
+                </template>
+
+                {{ pendingInstallments.length }} parcela(s) pendente(s) serão
+                adiadas
+
+                <strong>
+                  {{ selectedMonths }}
+                  {{ selectedMonths === 1 ? "mês" : "meses" }}
+                </strong>.
+              </q-banner>
+            </div>
+          </div>
+
+          <div
+            v-if="loading"
+            class="flex flex-center q-py-lg"
+          >
+            <q-spinner
+              color="primary"
+              size="32px"
             />
           </div>
 
-          <div v-if="selectedMonths" class="col-12 col-md-8">
-            <q-banner class="bg-blue-1 text-blue-9" dense rounded>
-              <template #avatar>
-                <q-icon name="mdi-information-outline" color="blue-7" />
-              </template>
-              {{ pendingInstallments.length }} parcela(s) pendente(s) serão adiadas
-              <strong>{{ selectedMonths }} {{ selectedMonths === 1 ? 'mês' : 'meses' }}</strong>.
-            </q-banner>
+          <div
+            v-else-if="!pendingInstallments.length"
+            class="text-center text-grey-6 q-py-lg"
+          >
+            Nenhuma parcela pendente encontrada para este contrato.
           </div>
-        </div>
-
-        <!-- Tabela de parcelas / preview -->
-        <div v-if="loading" class="flex flex-center q-py-lg">
-          <q-spinner color="primary" size="32px" />
-        </div>
-
-        <div v-else-if="!pendingInstallments.length" class="text-center text-grey-6 q-py-lg">
-          Nenhuma parcela pendente encontrada para este contrato.
-        </div>
-
-        <q-table
-          v-else
-          :rows="previewRows"
-          :columns="tableColumns"
-          flat
-          bordered
-          dense
-          hide-pagination
-          :rows-per-page-options="[0]"
-          class="q-mt-sm"
-        >
-          <template #body-cell-new_due_date="{ row }">
-            <q-td align="center">
-              <span
-                v-if="row.new_due_date"
-                class="text-positive text-weight-medium"
-              >
-                {{ row.new_due_date }}
-              </span>
-              <span v-else class="text-grey-5">—</span>
-            </q-td>
-          </template>
-
-          <template #body-cell-value="{ row }">
-            <q-td align="right">
-              {{ formatCurrency(row.value) }}
-            </q-td>
-          </template>
-        </q-table>
 
+          <q-table
+            v-else
+            bordered
+            class="q-mt-sm"
+            dense
+            flat
+            hide-pagination
+            :columns="tableColumns"
+            :rows="previewRows"
+            :rows-per-page-options="[0]"
+          >
+            <template #body-cell-new_due_date="{ row }">
+              <q-td align="center">
+                <span
+                  v-if="row.new_due_date"
+                  class="text-positive text-weight-medium"
+                >
+                  {{ row.new_due_date }}
+                </span>
+
+                <span
+                  v-else
+                  class="text-grey-5"
+                >
+                  —
+                </span>
+              </q-td>
+            </template>
+
+            <template #body-cell-value="{ row }">
+              <q-td align="right">
+                {{ formatCurrency(row.value) }}
+              </q-td>
+            </template>
+          </q-table>
         </q-card-section>
       </q-scroll-area>
 
-      <q-separator />
+      <q-card-actions
+        align="right"
+        class="q-px-md q-pb-md"
+      >
+        <q-btn
+          color="primary"
+          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="CONFIRMAR TRANCAMENTO"
+          label="Confirmar trancamento"
+          no-caps
+          :disable="
+            !selectedMonths ||
+            !pendingInstallments.length
+          "
           :loading="saving"
-          :disable="!selectedMonths || !pendingInstallments.length"
           @click="handleConfirm"
         />
       </q-card-actions>
@@ -91,134 +136,209 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
+import { computed, onMounted, ref } from "vue";
+
+import {
+  freezeContract,
+  getContractInstallments,
+} from "src/api/studentContract";
+
+import { getFinancialMe } from "src/api/unit_financial";
+import { permissionStore } from "src/stores/permission";
 import { useDialogPluginComponent, useQuasar } from "quasar";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import { getContractInstallments, freezeContract } from "src/api/studentContract";
-import { getFinancialMe } from "src/api/unit_financial";
-import { permissionStore } from "src/stores/permission";
+
+defineEmits([...useDialogPluginComponent.emits]);
 
 const props = defineProps({
-  contract: { type: Object, required: true },
+  contract: {
+    type: Object,
+    required: true,
+  },
 });
 
-defineEmits([...useDialogPluginComponent.emits]);
-
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
 const $q = useQuasar();
+
 const permissions = permissionStore();
+
+const loading = ref(true);
+const maxFreezeCount = ref(null);
+const pendingInstallments = ref([]);
+const saving = ref(false);
+const selectedMonths = ref(null);
+
 const canViewFinancial = computed(() =>
   permissions.getAccess("franchisee_financial", "view"),
 );
 
-const loading        = ref(true);
-const saving         = ref(false);
-const selectedMonths = ref(null);
-const pendingInstallments = ref([]);
-const maxFreezeCount = ref(null);
-
-// --- Opções de meses ---
 const monthOptions = computed(() => {
-  const max   = maxFreezeCount.value;
-  const limit = max ?? 12;
+  const maxFreezeMonths = maxFreezeCount.value;
+  const limit = maxFreezeMonths ?? 12;
 
-  const opts = Array.from({ length: limit }, (_, i) => ({
-    value: i + 1,
-    label: i + 1 === 1 ? '1 mês' : `${i + 1} meses`,
-    disable: false,
-  }));
+  const options = Array.from(
+    { length: limit },
+    (_, index) => {
+      const months = index + 1;
 
-  if (max) {
-    opts.push({
-      value: null,
-      label: `Acima de ${max} meses — não permitido`,
+      return {
+        disable: false,
+        label:
+          months === 1
+            ? "1 mês"
+            : `${months} meses`,
+        value: months,
+      };
+    },
+  );
+
+  if (maxFreezeMonths) {
+    options.push({
       disable: true,
+      label:
+        `Acima de ${maxFreezeMonths} meses — ` +
+        "não permitido",
+      value: null,
     });
   }
 
-  return opts;
+  return options;
 });
 
-// --- Data helpers ---
-function addMonthsToDMY(dateStr, months) {
-  if (!dateStr) return null;
-  const [day, month, year] = dateStr.split("/").map(Number);
-  const targetMonthStart = new Date(year, month - 1 + months, 1);
+const previewRows = computed(() =>
+  pendingInstallments.value.map((installment) => ({
+    ...installment,
+    new_due_date: selectedMonths.value
+      ? addMonthsToDMY(
+          installment.due_date,
+          selectedMonths.value,
+        )
+      : null,
+  })),
+);
+
+const tableColumns = [
+  {
+    align: "left",
+    field: "history",
+    label: "Histórico",
+    name: "history",
+  },
+  {
+    align: "center",
+    field: "order",
+    label: "Ordem",
+    name: "order",
+  },
+  {
+    align: "right",
+    field: "value",
+    label: "Valor",
+    name: "value",
+  },
+  {
+    align: "center",
+    field: "due_date",
+    label: "Vencimento Atual",
+    name: "due_date",
+  },
+  {
+    align: "center",
+    field: "new_due_date",
+    label: "Novo Vencimento",
+    name: "new_due_date",
+  },
+];
+
+const addMonthsToDMY = (dateString, months) => {
+  if (!dateString) return null;
+
+  const [day, month, year] = dateString
+    .split("/")
+    .map(Number);
+
+  const targetMonthStart = new Date(
+    year,
+    month - 1 + months,
+    1,
+  );
+
   const lastDay = new Date(
     targetMonthStart.getFullYear(),
     targetMonthStart.getMonth() + 1,
     0,
   ).getDate();
+
   const safeDay = Math.min(day, lastDay);
-  return (
-    String(safeDay).padStart(2, "0") +
-    "/" +
-    String(targetMonthStart.getMonth() + 1).padStart(2, "0") +
-    "/" +
-    targetMonthStart.getFullYear()
-  );
-}
 
-function formatCurrency(value) {
-  return new Intl.NumberFormat("pt-BR", {
-    style: "currency",
+  return `${String(safeDay).padStart(2, "0")}/${String(
+    targetMonthStart.getMonth() + 1,
+  ).padStart(2, "0")}/${targetMonthStart.getFullYear()}`;
+};
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", {
     currency: "BRL",
+    style: "currency",
   }).format(value ?? 0);
-}
 
-// --- Preview ---
-const previewRows = computed(() =>
-  pendingInstallments.value.map((inst) => ({
-    ...inst,
-    new_due_date: selectedMonths.value
-      ? addMonthsToDMY(inst.due_date, selectedMonths.value)
-      : null,
-  })),
-);
+const handleConfirm = async () => {
+  if (!selectedMonths.value) return;
 
-const tableColumns = [
-  { name: "history",      label: "Histórico",        field: "history",      align: "left"   },
-  { name: "order",        label: "Ordem",             field: "order",        align: "center" },
-  { name: "value",        label: "Valor",             field: "value",        align: "right"  },
-  { name: "due_date",     label: "Vencimento Atual",  field: "due_date",     align: "center" },
-  { name: "new_due_date", label: "Novo Vencimento",   field: "new_due_date", align: "center" },
-];
+  saving.value = true;
+
+  try {
+    const updatedContract = await freezeContract(
+      props.contract.id,
+      selectedMonths.value,
+    );
+
+    $q.notify({
+      message: "Contrato trancado com sucesso!",
+      type: "positive",
+    });
+
+    onDialogOK(updatedContract);
+  } catch (error) {
+    const message =
+      error?.response?.data?.message ??
+      "Erro ao trancar contrato.";
+
+    $q.notify({
+      message,
+      type: "negative",
+    });
+  } finally {
+    saving.value = false;
+  }
+};
 
-// --- Init ---
 onMounted(async () => {
   try {
     const [installments, financial] = await Promise.all([
       getContractInstallments(props.contract.id),
-      canViewFinancial.value ? getFinancialMe() : Promise.resolve(null),
+      canViewFinancial.value
+        ? getFinancialMe()
+        : Promise.resolve(null),
     ]);
+
     pendingInstallments.value = installments ?? [];
-    maxFreezeCount.value      = financial?.max_freeze_count ?? null;
-  } catch (e) {
-    console.error(e);
-    $q.notify({ type: "negative", message: "Erro ao carregar parcelas." });
+
+    maxFreezeCount.value =
+      financial?.max_freeze_count ?? null;
+  } catch (error) {
+    console.error(error);
+
+    $q.notify({
+      message: "Erro ao carregar parcelas.",
+      type: "negative",
+    });
   } finally {
     loading.value = false;
   }
 });
-
-// --- Confirmar ---
-async function handleConfirm() {
-  if (!selectedMonths.value) return;
-  saving.value = true;
-  try {
-    const updated = await freezeContract(props.contract.id, selectedMonths.value);
-    $q.notify({ type: "positive", message: "Contrato trancado com sucesso!" });
-    onDialogOK(updated);
-  } catch (e) {
-    const msg =
-      e?.response?.data?.message ??
-      "Erro ao trancar contrato.";
-    $q.notify({ type: "negative", message: msg });
-  } finally {
-    saving.value = false;
-  }
-}
 </script>

+ 367 - 267
src/pages/students/components/ResponsibleDialog.vue

@@ -5,312 +5,412 @@
       style="width: 100%; max-width: 900px"
     >
       <DefaultDialogHeader
-        :title="props.responsible ? 'Editar Responsável' : 'Novo Responsável'"
+        :title="
+          props.responsible
+            ? 'Editar Responsável'
+            : 'Novo Responsável'
+        "
         @close="onDialogCancel"
       />
 
-      <q-scroll-area class="dialog-form-scroll" style="height: 65vh">
-        <q-card-section class="q-pt-sm">
-          <DefaultForm ref="formRef">
-          <div class="row q-col-gutter-sm">
-            <DefaultInput
-              v-model="form.name"
-              :error="!!validationErrors.name"
-              :error-message="validationErrors.name"
-              label="Nome"
-              class="col-6"
-              :rules="[inputRules.required]"
-            />
-
-            <DefaultInput
-              v-model="form.degree"
-              :error="!!validationErrors.degree"
-              :error-message="validationErrors.degree"
-              label="Grau de Parentesco"
-              class="col-6"
-            />
-
-            <DefaultInputDatePicker
-              v-model="form.birth_date"
-              :error="!!validationErrors.birth_date"
-              :error-message="validationErrors.birth_date"
-              label="Data de Nascimento"
-              class="col-4"
-            />
-
-            <DefaultInput
-              v-model="form.cpf"
-              :error="!!validationErrors.cpf"
-              :error-message="validationErrors.cpf"
-              label="CPF"
-              class="col-4"
-              :mask="masks.Brasil.cpf"
-              :rules="[inputRules.required, inputRules.cpf]"
-            />
-
-            <DefaultSelect
-              v-model="form.gender"
-              :error="!!validationErrors.gender"
-              :error-message="validationErrors.gender"
-              label="Gênero"
-              class="col-4"
-              emit-value
-              map-options
-              :options="genderOptions"
-            />
-
-            <DefaultInput
-              v-model="form.email"
-              :error="!!validationErrors.email"
-              :error-message="validationErrors.email"
-              label="E-mail"
-              class="col-6"
-              type="email"
-              :rules="[inputRules.email]"
-            />
-
-            <DefaultInput
-              v-model="form.phone"
-              :error="!!validationErrors.phone"
-              :error-message="validationErrors.phone"
-              label="Telefone"
-              class="col-6"
-              :mask="masks.Brasil.celular"
-            />
-
-            <DefaultCepInput
-              v-model="form.postal_code"
-              :error="!!validationErrors.postal_code"
-              :error-message="validationErrors.postal_code"
-              class="col-4"
-              @rua="(v) => (form.street = v)"
-              @bairro="(v) => (form.neighborhood = v)"
-              @uf="(v) => stateSelectRef?.selectStateByCode(v)"
-              @cidade="(v) => citySelectRef?.selectCityByName(v)"
-            />
-
-            <DefaultInput
-              v-model="form.street"
-              :error="!!validationErrors.street"
-              :error-message="validationErrors.street"
-              label="Endereço"
-              class="col-5"
-            />
-
-            <DefaultInput
-              v-model="form.address_number"
-              :error="!!validationErrors.address_number"
-              :error-message="validationErrors.address_number"
-              label="Número"
-              class="col-3"
-            />
-
-            <DefaultInput
-              v-model="form.neighborhood"
-              :error="!!validationErrors.neighborhood"
-              :error-message="validationErrors.neighborhood"
-              label="Bairro"
-              class="col-4"
-            />
-
-            <CitySelect
-              ref="citySelectRef"
-              v-model="selectedCity"
-              :error="!!validationErrors.city_id"
-              :error-message="validationErrors.city_id"
-              label="Cidade"
-              class="col-4"
-              :state="selectedState"
-              :initial-id="props.responsible?.city_id ?? null"
-            />
-
-            <StateSelect
-              ref="stateSelectRef"
-              v-model="selectedState"
-              :error="!!validationErrors.state_id"
-              :error-message="validationErrors.state_id"
-              label="Estado"
-              class="col-4"
-              :initial-id="props.responsible?.state_id ?? null"
-            />
-
-            <DefaultInput
-              v-model="form.complement"
-              :error="!!validationErrors.complement"
-              :error-message="validationErrors.complement"
-              label="Complemento"
-              class="col-12"
-            />
-
-            <DefaultInput
-              v-model="form.notes"
-              :error="!!validationErrors.notes"
-              :error-message="validationErrors.notes"
-              label="Observações"
-              class="col-12"
-              type="textarea"
-              :input-style="{ minHeight: '120px' }"
-              autogrow
-            />
-          </div>
-          </DefaultForm>
-        </q-card-section>
-      </q-scroll-area>
-
-      <q-separator />
-
-      <q-card-actions align="right">
-        <q-btn
-          outline
-          color="primary"
-          label="CANCELAR"
-          @click="onDialogCancel"
-        />
-        <q-btn
-          color="primary"
-          label="SALVAR"
-          :loading="saving"
-          @click="handleSave"
-        />
-      </q-card-actions>
+      <DefaultForm
+        ref="formRef"
+        @submit="handleSave"
+      >
+        <q-scroll-area
+          class="dialog-form-scroll"
+          style="height: 65vh"
+        >
+          <q-card-section class="q-pt-sm">
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="form.name"
+                class="col-6"
+                label="Nome"
+                :error="!!validationErrors.name"
+                :error-message="validationErrors.name"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInput
+                v-model="form.degree"
+                class="col-6"
+                label="Grau de Parentesco"
+                :error="!!validationErrors.degree"
+                :error-message="validationErrors.degree"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInputDatePicker
+                v-model="form.birth_date"
+                class="col-4"
+                label="Data de Nascimento"
+                :error="!!validationErrors.birth_date"
+                :error-message="validationErrors.birth_date"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInput
+                v-model="form.cpf"
+                class="col-4"
+                label="CPF"
+                :error="!!validationErrors.cpf"
+                :error-message="validationErrors.cpf"
+                :mask="masks.Brasil.cpf"
+                :rules="[inputRules.required, inputRules.cpf]"
+              />
+
+              <DefaultSelect
+                v-model="form.gender"
+                class="col-4"
+                emit-value
+                label="Gênero"
+                map-options
+                :error="!!validationErrors.gender"
+                :error-message="validationErrors.gender"
+                :options="genderOptions"
+              />
+
+              <DefaultInput
+                v-model="form.email"
+                class="col-6"
+                label="E-mail"
+                type="email"
+                :error="!!validationErrors.email"
+                :error-message="validationErrors.email"
+                :rules="[inputRules.required, inputRules.email]"
+              />
+
+              <DefaultInput
+                v-model="form.phone"
+                class="col-6"
+                label="Telefone"
+                :error="!!validationErrors.phone"
+                :error-message="validationErrors.phone"
+                :mask="masks.Brasil.celular"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultCepInput
+                v-model="form.postal_code"
+                class="col-4"
+                :error="!!validationErrors.postal_code"
+                :error-message="validationErrors.postal_code"
+                :rules="[inputRules.required, inputRules.cep]"
+                @bairro="
+                  (value) =>
+                    (form.neighborhood = value)
+                "
+                @cidade="
+                  (value) =>
+                    citySelectRef?.selectCityByName(value)
+                "
+                @rua="
+                  (value) =>
+                    (form.street = value)
+                "
+                @uf="
+                  (value) =>
+                    stateSelectRef?.selectStateByCode(value)
+                "
+              />
+
+              <DefaultInput
+                v-model="form.street"
+                class="col-5"
+                label="Endereço"
+                :error="!!validationErrors.street"
+                :error-message="validationErrors.street"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInput
+                v-model="form.address_number"
+                class="col-3"
+                label="Número"
+                :error="!!validationErrors.address_number"
+                :error-message="validationErrors.address_number"
+              />
+
+              <DefaultInput
+                v-model="form.neighborhood"
+                class="col-4"
+                label="Bairro"
+                :error="!!validationErrors.neighborhood"
+                :error-message="validationErrors.neighborhood"
+                :rules="[inputRules.required]"
+              />
+
+              <CitySelect
+                ref="citySelectRef"
+                v-model="selectedCity"
+                class="col-4"
+                label="Cidade"
+                :error="!!validationErrors.city_id"
+                :error-message="validationErrors.city_id"
+                :initial-id="props.responsible?.city_id ?? null"
+                :rules="[inputRules.required]"
+                :state="selectedState"
+              />
+
+              <StateSelect
+                ref="stateSelectRef"
+                v-model="selectedState"
+                class="col-4"
+                label="Estado"
+                :error="!!validationErrors.state_id"
+                :error-message="validationErrors.state_id"
+                :initial-id="props.responsible?.state_id ?? null"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInput
+                v-model="form.complement"
+                class="col-12"
+                label="Complemento"
+                :error="!!validationErrors.complement"
+                :error-message="validationErrors.complement"
+              />
+
+              <DefaultInput
+                v-model="form.notes"
+                autogrow
+                class="col-12"
+                label="Observações"
+                type="textarea"
+                :error="!!validationErrors.notes"
+                :error-message="validationErrors.notes"
+                :input-style="{ minHeight: '120px' }"
+              />
+            </div>
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            :loading="saving"
+          />
+        </q-card-actions>
+      </DefaultForm>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, watch, useTemplateRef } from "vue";
+import {
+  createStudentResponsible,
+  updateStudentResponsible,
+} from "src/api/studentResponsible";
+
+import {
+  formatDateDMYtoYMD,
+  formatDateYMDtoDMY,
+} from "src/helpers/utils";
+
+import { ref, useTemplateRef, watch } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import masks from "src/helpers/masks";
+
+import DefaultCepInput from "src/components/defaults/DefaultCepInput.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 DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import CitySelect from "src/components/selects/CitySelect.vue";
 import StateSelect from "src/components/selects/StateSelect.vue";
-import { useInputRules } from "src/composables/useInputRules";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import {
-  createStudentResponsible,
-  updateStudentResponsible,
-} from "src/api/studentResponsible";
-import masks from "src/helpers/masks";
-import { formatDateYMDtoDMY, formatDateDMYtoYMD } from "src/helpers/utils";
+
+defineEmits([...useDialogPluginComponent.emits]);
 
 const props = defineProps({
-  studentId: {
-    type: Number,
-    required: true,
-  },
   responsible: {
     type: Object,
     default: null,
   },
+  studentId: {
+    type: Number,
+    required: true,
+  },
 });
 
-defineEmits([...useDialogPluginComponent.emits]);
-
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
 const { inputRules } = useInputRules();
+
+const citySelectRef = useTemplateRef("citySelectRef");
 const formRef = useTemplateRef("formRef");
 const stateSelectRef = useTemplateRef("stateSelectRef");
-const citySelectRef = useTemplateRef("citySelectRef");
 
-const selectedState = ref(null);
 const selectedCity = ref(null);
+const selectedState = ref(null);
 
 const genderOptions = [
-  { label: "Masculino", value: "male" },
-  { label: "Feminino", value: "female" },
-  { label: "Outro", value: "other" },
-  { label: "Prefiro não informar", value: "no_preference" },
+  {
+    label: "Masculino",
+    value: "male",
+  },
+  {
+    label: "Feminino",
+    value: "female",
+  },
+  {
+    label: "Outro",
+    value: "other",
+  },
+  {
+    label: "Prefiro não informar",
+    value: "no_preference",
+  },
 ];
 
-const form = ref(
-  props.responsible
-    ? {
-        name: props.responsible.name ?? null,
-        birth_date: props.responsible.birth_date
-          ? formatDateYMDtoDMY(props.responsible.birth_date)
-          : null,
-        cpf: props.responsible.cpf ?? null,
-        gender: props.responsible.gender ?? "no_preference",
-        degree: props.responsible.degree ?? null,
-        email: props.responsible.email ?? null,
-        phone: props.responsible.phone ?? null,
-        postal_code: props.responsible.postal_code ?? null,
-        street: props.responsible.street ?? null,
-        address_number: props.responsible.address_number ?? null,
-        neighborhood: props.responsible.neighborhood ?? null,
-        state_id: props.responsible.state_id ?? null,
-        city_id: props.responsible.city_id ?? null,
-        complement: props.responsible.complement ?? null,
-        notes: props.responsible.notes ?? null,
-      }
-    : {
-        name: null,
-        birth_date: null,
-        cpf: null,
-        gender: "no_preference",
-        degree: null,
-        email: null,
-        phone: null,
-        postal_code: null,
-        street: null,
-        address_number: null,
-        neighborhood: null,
-        state_id: null,
-        city_id: null,
-        complement: null,
-        notes: null,
-      },
-);
+const initialForm = props.responsible
+  ? {
+      address_number:
+        props.responsible.address_number ?? null,
 
-watch(selectedState, (state) => {
-  form.value.state_id = state?.value ?? null;
-});
+      birth_date: props.responsible.birth_date
+        ? formatDateYMDtoDMY(
+            props.responsible.birth_date,
+          )
+        : null,
 
-watch(selectedCity, (city) => {
-  form.value.city_id = city?.value ?? null;
-});
+      city_id: props.responsible.city_id ?? null,
+      complement: props.responsible.complement ?? null,
+      cpf: props.responsible.cpf ?? null,
+      degree: props.responsible.degree ?? null,
+      email: props.responsible.email ?? null,
+
+      gender:
+        props.responsible.gender ?? "no_preference",
+
+      name: props.responsible.name ?? null,
+
+      neighborhood:
+        props.responsible.neighborhood ?? null,
 
-const { loading: saving, validationErrors, execute } = useSubmitHandler({
+      notes: props.responsible.notes ?? null,
+      phone: props.responsible.phone ?? null,
+
+      postal_code:
+        props.responsible.postal_code ?? null,
+
+      state_id: props.responsible.state_id ?? null,
+      street: props.responsible.street ?? null,
+    }
+  : {
+      address_number: null,
+      birth_date: null,
+      city_id: null,
+      complement: null,
+      cpf: null,
+      degree: null,
+      email: null,
+      gender: "no_preference",
+      name: null,
+      neighborhood: null,
+      notes: null,
+      phone: null,
+      postal_code: null,
+      state_id: null,
+      street: null,
+    };
+
+const { form, getUpdatedFields } = useForm(initialForm);
+
+const {
+  loading: saving,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   formRef,
-  onSuccess: () => onDialogOK(true),
+  onSuccess: () => {
+    onDialogOK(true);
+  },
 });
 
-async function handleSave() {
-  const valid = await formRef.value?.validate();
-  if (!valid) return;
-
-  const payload = {
-    student_id: props.studentId,
-    name: form.value.name,
-    birth_date: form.value.birth_date
-      ? formatDateDMYtoYMD(form.value.birth_date)
-      : null,
-    cpf: form.value.cpf,
-    gender: form.value.gender,
-    degree: form.value.degree,
-    email: form.value.email,
-    phone: form.value.phone,
-    postal_code: form.value.postal_code,
-    street: form.value.street,
-    address_number: form.value.address_number,
-    neighborhood: form.value.neighborhood,
-    city_id: form.value.city_id,
-    state_id: form.value.state_id,
-    complement: form.value.complement,
-    notes: form.value.notes,
-  };
-
-  if (props.responsible) {
-    await execute(() =>
-      updateStudentResponsible(props.responsible.id, payload),
+const buildPayload = () => ({
+  address_number: form.address_number,
+
+  birth_date: form.birth_date
+    ? formatDateDMYtoYMD(form.birth_date)
+    : null,
+
+  city_id: form.city_id,
+  complement: form.complement,
+  cpf: form.cpf,
+  degree: form.degree,
+  email: form.email,
+  gender: form.gender,
+  name: form.name,
+  neighborhood: form.neighborhood,
+  notes: form.notes,
+  phone: form.phone,
+  postal_code: form.postal_code,
+  state_id: form.state_id,
+  street: form.street,
+  student_id: props.studentId,
+});
+
+const handleSave = async () => {
+  const payload = buildPayload();
+
+  await execute(() => {
+    if (!props.responsible) {
+      return createStudentResponsible(payload);
+    }
+
+    const changedFields = {
+      ...getUpdatedFields.value,
+    };
+
+    const updatePayload = {};
+
+    for (const key of Object.keys(payload)) {
+      if (
+        key !== "student_id" &&
+        key !== "birth_date" &&
+        key in changedFields
+      ) {
+        updatePayload[key] = payload[key];
+      }
+    }
+
+    if ("birth_date" in changedFields) {
+      updatePayload.birth_date = payload.birth_date;
+    }
+
+    return updateStudentResponsible(
+      props.responsible.id,
+      updatePayload,
     );
-  } else {
-    await execute(() => createStudentResponsible(payload));
-  }
-}
+  });
+};
+
+watch(selectedState, (state) => {
+  form.state_id = state?.value ?? null;
+});
+
+watch(selectedCity, (city) => {
+  form.city_id = city?.value ?? null;
+});
 </script>

+ 262 - 97
src/pages/students/components/ViewContractDialog.vue

@@ -4,153 +4,293 @@
       class="q-dialog-plugin overflow-hidden"
       style="width: 100%; max-width: 1350px"
     >
-      <DefaultDialogHeader title="Visualizar Contrato" @close="onDialogCancel" />
+      <DefaultDialogHeader
+        title="Visualizar Contrato"
+        @close="onDialogCancel"
+      />
 
       <template v-if="loading">
-        <q-card-section style="height: 65vh" class="flex flex-center">
-          <q-spinner size="40px" color="primary" />
+        <q-card-section
+          class="flex flex-center"
+          style="height: 65vh"
+        >
+          <q-spinner
+            color="primary"
+            size="40px"
+          />
         </q-card-section>
       </template>
 
       <template v-else-if="contract">
         <q-tabs
           v-model="activeTab"
-          dense
+          active-color="primary"
           align="left"
           class="q-px-md text-grey-7"
-          active-color="primary"
+          dense
           indicator-color="primary"
         >
-          <q-tab name="dados" label="Dados do Contrato" />
-          <q-tab name="midias" label="Mídias do Contrato" />
+          <q-tab
+            label="Dados do Contrato"
+            name="dados"
+          />
+
+          <q-tab
+            label="Mídias do Contrato"
+            name="midias"
+          />
         </q-tabs>
+
         <q-separator />
 
-        <q-card-section class="q-pt-sm" style="height: 65vh; overflow-y: auto">
+        <q-card-section
+          class="q-pt-sm"
+          style="height: 65vh; overflow-y: auto"
+        >
           <div v-show="activeTab === 'dados'">
-            <div class="text-subtitle1 q-mb-md">Dados do Aluno</div>
+            <div class="text-subtitle1 q-mb-md">
+              Dados do Aluno
+            </div>
 
             <div class="row q-col-gutter-sm">
               <div class="col-12">
-                <DefaultInput :model-value="contract.student_name" label="Aluno" disable />
+                <DefaultInput
+                  :model-value="contract.student_name"
+                  disable
+                  label="Aluno"
+                />
               </div>
 
               <div class="col-6">
                 <DefaultInput
                   :model-value="contract.student_document"
-                  label="CPF"
-                  disable
                   :mask="masks.Brasil.cpf"
                   :rules="[inputRules.cpf]"
+                  disable
+                  label="CPF"
                 />
               </div>
 
               <div class="col-6">
-                <DefaultInput :model-value="contract.student_birth_date" label="Data de Nascimento" disable />
+                <DefaultInput
+                  :model-value="contract.student_birth_date"
+                  disable
+                  label="Data de Nascimento"
+                />
               </div>
             </div>
 
-            <div class="text-subtitle1 q-mt-lg q-mb-md">Dados do Contrato</div>
+            <div class="text-subtitle1 q-mt-lg q-mb-md">
+              Dados do Contrato
+            </div>
 
             <div class="row q-col-gutter-sm">
               <div class="col-4">
-                <DefaultInput :model-value="contract.protocol" label="Protocolo" disable />
+                <DefaultInput
+                  :model-value="contract.protocol"
+                  disable
+                  label="Protocolo"
+                />
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="contract.signature_date" label="Data Assinatura" disable />
+                <DefaultInput
+                  :model-value="contract.signature_date"
+                  disable
+                  label="Data Assinatura"
+                />
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="contract.end_date" label="Data Encerramento" disable />
+                <DefaultInput
+                  :model-value="contract.end_date"
+                  disable
+                  label="Data Encerramento"
+                />
               </div>
 
               <div class="col-5">
-                <DefaultInput :model-value="contract.package_name" label="Pacote de Aulas" disable />
+                <DefaultInput
+                  :model-value="contract.package_name"
+                  disable
+                  label="Pacote de Aulas"
+                />
               </div>
 
               <div class="col-7">
-                <DefaultInput :model-value="contract.class_quantity" label="Qtd. Aulas" disable />
+                <DefaultInput
+                  :model-value="contract.class_quantity"
+                  disable
+                  label="Qtd. Aulas"
+                />
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="weekdayLabel(contract.weekday)" label="Dia da Semana" disable />
+                <DefaultInput
+                  :model-value="weekdayLabel(contract.weekday)"
+                  disable
+                  label="Dia da Semana"
+                />
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="contract.start_time" label="Hora de Início" disable>
-                  <template #append><q-icon name="mdi-clock-outline" /></template>
+                <DefaultInput
+                  :model-value="contract.start_time"
+                  disable
+                  label="Hora de Início"
+                >
+                  <template #append>
+                    <q-icon name="mdi-clock-outline" />
+                  </template>
                 </DefaultInput>
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="contract.end_time" label="Hora de Término" disable>
-                  <template #append><q-icon name="mdi-clock-outline" /></template>
+                <DefaultInput
+                  :model-value="contract.end_time"
+                  disable
+                  label="Hora de Término"
+                >
+                  <template #append>
+                    <q-icon name="mdi-clock-outline" />
+                  </template>
                 </DefaultInput>
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="weekdayLabel(contract.second_weekday)" label="2° Dia da Semana" disable />
+                <DefaultInput
+                  :model-value="weekdayLabel(contract.second_weekday)"
+                  disable
+                  label="2° Dia da Semana"
+                />
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="contract.second_start_time" label="Hora de Início" disable>
-                  <template #append><q-icon name="mdi-clock-outline" /></template>
+                <DefaultInput
+                  :model-value="contract.second_start_time"
+                  disable
+                  label="Hora de Início"
+                >
+                  <template #append>
+                    <q-icon name="mdi-clock-outline" />
+                  </template>
                 </DefaultInput>
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="contract.second_end_time" label="Hora de Término" disable>
-                  <template #append><q-icon name="mdi-clock-outline" /></template>
+                <DefaultInput
+                  :model-value="contract.second_end_time"
+                  disable
+                  label="Hora de Término"
+                >
+                  <template #append>
+                    <q-icon name="mdi-clock-outline" />
+                  </template>
                 </DefaultInput>
               </div>
             </div>
 
-            <div class="text-subtitle1 q-mt-lg q-mb-md">Dados Financeiros</div>
+            <div class="text-subtitle1 q-mt-lg q-mb-md">
+              Dados Financeiros
+            </div>
 
             <div class="row q-col-gutter-sm">
               <div class="col-4">
-                <DefaultInput :model-value="contract.due_day" label="Dia de Vencimento" disable />
+                <DefaultInput
+                  :model-value="contract.due_day"
+                  disable
+                  label="Dia de Vencimento"
+                />
               </div>
 
               <div class="col-4">
-                <DefaultCurrencyInput :model-value="contract.tax_register" label="Taxa de Matrícula" disable />
+                <DefaultCurrencyInput
+                  :model-value="contract.tax_register"
+                  disable
+                  label="Taxa de Matrícula"
+                />
               </div>
 
               <div class="col-4">
-                <DefaultInput :model-value="contract.class_quantity" label="Total de Aulas" disable />
+                <DefaultInput
+                  :model-value="contract.class_quantity"
+                  disable
+                  label="Total de Aulas"
+                />
               </div>
 
               <div class="col-3">
-                <DefaultCurrencyInput :model-value="contract.down_payment" label="Entrada" disable />
+                <DefaultCurrencyInput
+                  :model-value="contract.down_payment"
+                  disable
+                  label="Entrada"
+                />
               </div>
 
               <div class="col-3">
-                <DefaultInput :model-value="contract.installments ? `${contract.installments}x` : null" label="Parcelas" disable />
+                <DefaultInput
+                  :model-value="
+                    contract.installments
+                      ? `${contract.installments}x`
+                      : null
+                  "
+                  disable
+                  label="Parcelas"
+                />
               </div>
 
               <div class="col-6">
-                <DefaultInput :model-value="contract.early_payment_discount" label="Desconto até o vencimento (%)" disable />
+                <DefaultInput
+                  :model-value="contract.early_payment_discount"
+                  disable
+                  label="Desconto até o vencimento (%)"
+                />
               </div>
 
               <div class="col-3">
-                <DefaultCurrencyInput :model-value="contract.material_value" label="Valor dos Materiais" disable />
+                <DefaultCurrencyInput
+                  :model-value="contract.material_value"
+                  disable
+                  label="Valor dos Materiais"
+                />
               </div>
 
               <div class="col-3">
-                <DefaultInput :model-value="contract.material_installments ? `${contract.material_installments}x` : null" label="Parcelas" disable />
+                <DefaultInput
+                  :model-value="
+                    contract.material_installments
+                      ? `${contract.material_installments}x`
+                      : null
+                  "
+                  disable
+                  label="Parcelas"
+                />
               </div>
 
               <div class="col-6">
-                <DefaultInput :model-value="contract.interest_rate" label="Juros (%) a.m" disable />
+                <DefaultInput
+                  :model-value="contract.interest_rate"
+                  disable
+                  label="Juros (%) a.m"
+                />
               </div>
 
               <div class="col-6">
-                <DefaultInput :model-value="paymentMethodLabel(contract.payment_method)" label="Forma de Pagamento" disable />
+                <DefaultInput
+                  :model-value="
+                    paymentMethodLabel(contract.payment_method)
+                  "
+                  disable
+                  label="Forma de Pagamento"
+                />
               </div>
 
               <div class="col-6">
-                <DefaultInput :model-value="contract.fine_cancelled" label="Multa (%)" disable />
+                <DefaultInput
+                  :model-value="contract.fine_cancelled"
+                  disable
+                  label="Multa (%)"
+                />
               </div>
             </div>
           </div>
@@ -158,19 +298,19 @@
           <div v-show="activeTab === 'midias'">
             <DefaultTable
               v-model:rows="medias"
-              title="Mídias"
               :columns="mediaColumns"
-              descricao="mídias"
               :feminino="true"
-              no-api-call
-              :show-search-field="false"
               :loading="loadingMedias"
+              :show-search-field="false"
+              descricao="mídias"
+              no-api-call
+              title="Mídias"
             >
               <template #body-cell-actions="{ row }">
                 <q-td align="center">
                   <q-btn
-                    outline
                     icon="mdi-eye-outline"
+                    outline
                     style="width: 36px"
                     @click.prevent.stop="openFile(row.file_url)"
                   />
@@ -181,26 +321,37 @@
         </q-card-section>
       </template>
 
-      <q-separator />
-
-      <q-card-actions align="right">
-        <q-btn outline color="primary" label="FECHAR" @click="onDialogCancel" />
+      <q-card-actions
+        align="right"
+        class="q-px-md q-pb-md"
+      >
+        <q-btn
+          color="primary"
+          label="Fechar"
+          no-caps
+          outline
+          @click="onDialogCancel"
+        />
       </q-card-actions>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, watch, onMounted } from "vue";
+import { getContractMedias } from "src/api/student_media";
+import { getStudentContractById } from "src/api/studentContract";
+import { onMounted, ref, watch } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+
 import masks from "src/helpers/masks";
+
+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";
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import { useInputRules } from "src/composables/useInputRules";
-import { getStudentContractById } from "src/api/studentContract";
-import { getContractMedias } from "src/api/student_media";
+
+defineEmits([...useDialogPluginComponent.emits]);
 
 const props = defineProps({
   id: {
@@ -209,48 +360,38 @@ const props = defineProps({
   },
 });
 
-defineEmits([...useDialogPluginComponent.emits]);
+const { dialogRef, onDialogHide, onDialogCancel } =
+  useDialogPluginComponent();
 
-const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
 const { inputRules } = useInputRules();
 
+const activeTab = ref("dados");
 const contract = ref(null);
 const loading = ref(false);
-const activeTab = ref("dados");
-const medias = ref([]);
 const loadingMedias = ref(false);
-
-onMounted(async () => {
-  loading.value = true;
-  try {
-    contract.value = await getStudentContractById(props.id);
-  } finally {
-    loading.value = false;
-  }
-});
-
-async function fetchMedias() {
-  loadingMedias.value = true;
-  try {
-    medias.value = await getContractMedias(props.id);
-  } finally {
-    loadingMedias.value = false;
-  }
-}
-
-watch(activeTab, (tab) => {
-  if (tab === "midias") fetchMedias();
-});
-
-function openFile(url) {
-  window.open(url, "_blank");
-}
+const medias = ref([]);
 
 const mediaColumns = [
-  { name: "created_at", label: "Data de Anexo", field: "created_at", align: "left" },
-  { name: "actions", label: "Ações", field: null, align: "center" },
+  {
+    align: "left",
+    field: "created_at",
+    label: "Data de Anexo",
+    name: "created_at",
+  },
+  {
+    align: "center",
+    field: null,
+    label: "Ações",
+    name: "actions",
+  },
 ];
 
+const paymentMethods = {
+  credit_card: "Cartão de Crédito",
+  debit_card: "Cartão de Débito",
+  pix: "Pix",
+};
+
 const weekdays = {
   0: "Domingo",
   1: "Segunda",
@@ -261,17 +402,41 @@ const weekdays = {
   6: "Sábado",
 };
 
-const paymentMethods = {
-  pix: "Pix",
-  credit_card: "Cartão de Crédito",
-  debit_card: "Cartão de Débito",
+const fetchMedias = async () => {
+  loadingMedias.value = true;
+
+  try {
+    medias.value = await getContractMedias(props.id);
+  } finally {
+    loadingMedias.value = false;
+  }
+};
+
+const openFile = (url) => {
+  window.open(url, "_blank");
 };
 
-function weekdayLabel(value) {
-  return value != null ? weekdays[value] ?? null : null;
-}
+const paymentMethodLabel = (value) =>
+  value ? paymentMethods[value] ?? value : null;
+
+const weekdayLabel = (value) =>
+  value != null ? weekdays[value] ?? null : null;
+
+watch(activeTab, (tab) => {
+  if (tab === "midias") {
+    fetchMedias();
+  }
+});
+
+onMounted(async () => {
+  loading.value = true;
 
-function paymentMethodLabel(value) {
-  return value ? paymentMethods[value] ?? value : null;
-}
+  try {
+    contract.value = await getStudentContractById(
+      props.id,
+    );
+  } finally {
+    loading.value = false;
+  }
+});
 </script>

+ 135 - 59
src/pages/students/tabs/MediaTab.vue

@@ -2,15 +2,19 @@
   <div>
     <DefaultTable
       v-model:rows="rows"
-      :columns="columns"
-      no-api-call
       :add-item="canAdd"
+      :columns="columns"
+      :feminino="true"
       :show-search-field="false"
-      hide-no-data-label
+      descricao="mídias"
+      no-api-call
+      title="Mídias"
       @on-add-item="openAddDialog"
     >
       <template #body-cell-item="{ rowIndex }">
-        <q-td>{{ rowIndex + 1 }}</q-td>
+        <q-td>
+          {{ rowIndex + 1 }}
+        </q-td>
       </template>
 
       <template #body-cell-type="{ row }">
@@ -23,34 +27,38 @@
       </template>
 
       <template #body-cell-name="{ row }">
-        <q-td>{{ row.name ?? '—' }}</q-td>
+        <q-td>
+          {{ row.name ?? "—" }}
+        </q-td>
       </template>
 
       <template #body-cell-actions="{ row }">
         <q-btn
-          flat
-          round
+          class="q-mr-xs"
           dense
+          flat
           icon="mdi-eye-outline"
-          class="q-mr-xs"
+          round
           :disable="!row.file_url"
           @click.stop="openFile(row.file_url)"
         />
+
         <q-btn
           v-if="canEdit && row.type !== 'contract'"
-          flat
-          round
+          class="q-mr-xs"
           dense
+          flat
           icon="mdi-file-edit-outline"
-          class="q-mr-xs"
+          round
           @click.stop="openEditDialog(row)"
         />
+
         <q-btn
           v-if="canDelete"
-          flat
-          round
           dense
+          flat
           icon="mdi-trash-can-outline"
+          round
           @click.stop="confirmDelete(row)"
         />
       </template>
@@ -59,81 +67,149 @@
 </template>
 
 <script setup>
-import { computed, ref, onMounted } from "vue";
+import { computed, onMounted, ref } from "vue";
+
+import {
+  deleteStudentMedia,
+  getStudentMedias,
+} from "src/api/student_media";
+
+import { permissionStore } from "src/stores/permission";
 import { useQuasar } from "quasar";
+
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import AddStudentMediaDialog from "src/pages/students/components/AddStudentMediaDialog.vue";
 import EditStudentMediaDialog from "src/pages/students/components/EditStudentMediaDialog.vue";
-import { getStudentMedias, deleteStudentMedia } from "src/api/student_media";
-import { permissionStore } from "src/stores/permission";
 
 const props = defineProps({
-  studentId: { type: Number, required: true },
+  studentId: {
+    type: Number,
+    required: true,
+  },
 });
 
 const $q = useQuasar();
+
 const permissions = permissionStore();
-const canAdd = computed(() => permissions.getAccess("franchisee_students", "add"));
-const canEdit = computed(() => permissions.getAccess("franchisee_students", "edit"));
-const canDelete = computed(() => permissions.getAccess("franchisee_students", "delete"));
+
 const rows = ref([]);
 
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_students", "add"),
+);
+
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_students", "delete"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_students", "edit"),
+);
+
 const columns = [
-  { name: "item", label: "Item", field: "id", align: "left" },
-  { name: "date", label: "Data", field: "created_at", align: "left" },
-  { name: "type", label: "Tipo", field: "type", align: "left" },
-  { name: "name", label: "Nome", field: "name", align: "left" },
-  { name: "actions", label: "Ações", field: null, align: "right" },
+  {
+    align: "left",
+    field: "id",
+    label: "Item",
+    name: "item",
+  },
+  {
+    align: "left",
+    field: "created_at",
+    label: "Data",
+    name: "date",
+  },
+  {
+    align: "left",
+    field: "type",
+    label: "Tipo",
+    name: "type",
+  },
+  {
+    align: "left",
+    field: "name",
+    label: "Nome",
+    name: "name",
+  },
+  {
+    align: "right",
+    field: null,
+    label: "Ações",
+    name: "actions",
+  },
 ];
 
-async function fetchMedias() {
+const confirmDelete = (row) => {
+  $q.dialog({
+    cancel: {
+      color: "primary",
+      label: "Cancelar",
+      outline: true,
+    },
+    message: `Deseja remover "${row.name}"?`,
+    ok: {
+      color: "negative",
+      label: "Remover",
+    },
+    title: "Remover mídia",
+  }).onOk(async () => {
+    try {
+      await deleteStudentMedia(row.id);
+
+      rows.value = rows.value.filter(
+        (media) => media.id !== row.id,
+      );
+    } catch (error) {
+      console.error(error);
+    }
+  });
+};
+
+const fetchMedias = async () => {
   try {
-    rows.value = await getStudentMedias(props.studentId);
-  } catch (e) {
-    console.error(e);
+    rows.value = await getStudentMedias(
+      props.studentId,
+    );
+  } catch (error) {
+    console.error(error);
   }
-}
+};
 
-function openAddDialog() {
+const openAddDialog = () => {
   $q.dialog({
     component: AddStudentMediaDialog,
-    componentProps: { studentId: props.studentId },
+    componentProps: {
+      studentId: props.studentId,
+    },
   }).onOk((result) => {
     rows.value.unshift(result);
   });
-}
+};
 
-function openEditDialog(row) {
+const openEditDialog = (row) => {
   $q.dialog({
     component: EditStudentMediaDialog,
-    componentProps: { media: row },
-  }).onOk((updated) => {
-    const index = rows.value.findIndex((r) => r.id === updated.id);
-    if (index !== -1) rows.value[index] = updated;
-  });
-}
+    componentProps: {
+      media: row,
+    },
+  }).onOk((updatedMedia) => {
+    const index = rows.value.findIndex(
+      (media) => media.id === updatedMedia.id,
+    );
 
-function openFile(url) {
-  window.open(url, "_blank");
-}
-
-function confirmDelete(row) {
-  $q.dialog({
-    title: "Remover mídia",
-    message: `Deseja remover "${row.name}"?`,
-    ok: { color: "negative", label: "Remover" },
-    cancel: { color: "primary", outline: true, label: "Cancelar" },
-  }).onOk(async () => {
-    try {
-      await deleteStudentMedia(row.id);
-      rows.value = rows.value.filter((r) => r.id !== row.id);
-    } catch (e) {
-      console.error(e);
+    if (index !== -1) {
+      rows.value[index] = updatedMedia;
     }
   });
-}
+};
+
+const openFile = (url) => {
+  window.open(url, "_blank");
+};
 
 onMounted(fetchMedias);
 
-defineExpose({ refresh: fetchMedias });
-</script>
+defineExpose({
+  refresh: fetchMedias,
+});
+</script>

+ 125 - 67
src/pages/students/tabs/ResponsibleTab.vue

@@ -2,37 +2,41 @@
   <div>
     <q-banner
       v-if="required && !loading && rows.length === 0"
-      rounded
       class="bg-orange-1 text-orange-10 q-mb-md"
+      rounded
     >
       Este aluno é menor de idade e precisa ter ao menos um responsável.
     </q-banner>
 
     <DefaultTable
       v-model:rows="rows"
-      title="Responsáveis"
-      :columns
-      descricao="responsáveis"
-      :feminino="true"
-      no-api-call
       :add-item="canAdd"
+      :columns="columns"
+      :feminino="true"
       :show-search-field="false"
+      descricao="responsáveis"
+      no-api-call
+      title="Responsáveis"
       @on-add-item="handleAdd"
     >
       <template #body-cell-actions="{ row }">
         <q-td align="center">
-          <q-item-section class="no-wrap" style="flex-direction: row; gap: 4px">
+          <q-item-section
+            class="no-wrap"
+            style="flex-direction: row; gap: 4px"
+          >
             <q-btn
               v-if="canEdit"
-              outline
               icon="mdi-pencil-outline"
+              outline
               style="width: 36px"
               @click.prevent.stop="handleEdit(row)"
             />
+
             <q-btn
               v-if="canDelete"
-              outline
               icon="mdi-trash-can-outline"
+              outline
               style="width: 36px"
               @click.prevent.stop="handleDelete(row)"
             />
@@ -44,103 +48,157 @@
 </template>
 
 <script setup>
-import { computed, ref, onMounted } from "vue";
-import { useQuasar } from "quasar";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import ResponsibleDialog from "src/pages/students/components/ResponsibleDialog.vue";
+import { computed, onMounted, ref } from "vue";
+
 import {
-  getStudentResponsible,
   deleteStudentResponsible,
+  getStudentResponsible,
 } from "src/api/studentResponsible";
+
 import { permissionStore } from "src/stores/permission";
+import { useQuasar } from "quasar";
+
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import ResponsibleDialog from "src/pages/students/components/ResponsibleDialog.vue";
 
 const props = defineProps({
-  studentId: {
-    type: Number,
-    required: true,
-  },
   required: {
     type: Boolean,
     default: false,
   },
+  studentId: {
+    type: Number,
+    required: true,
+  },
 });
 
 const $q = useQuasar();
+
 const permissions = permissionStore();
-const canAdd = computed(() => permissions.getAccess("franchisee_students", "add"));
-const canEdit = computed(() => permissions.getAccess("franchisee_students", "edit"));
-const canDelete = computed(() => permissions.getAccess("franchisee_students", "delete"));
-const rows = ref([]);
+
 const loading = ref(false);
+const rows = ref([]);
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_students", "add"),
+);
 
-const columns = ref([
-  { name: "name", label: "Nome", field: "name", align: "left" },
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_students", "delete"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_students", "edit"),
+);
+
+const columns = [
   {
-    name: "degree",
-    label: "Grau de Parentesco",
+    align: "left",
+    field: "name",
+    label: "Nome",
+    name: "name",
+  },
+  {
+    align: "left",
     field: "degree",
+    label: "Grau de Parentesco",
+    name: "degree",
+  },
+  {
     align: "left",
+    field: "phone",
+    label: "Telefone",
+    name: "phone",
   },
-  { name: "phone", label: "Telefone", field: "phone", align: "left" },
-  { name: "email", label: "E-mail", field: "email", align: "left" },
-  { name: "actions", label: "Ações", field: null, align: "center" },
-]);
-
-async function loadResponsibles() {
-  loading.value = true;
-  try {
-    rows.value = await getStudentResponsible(props.studentId);
-  } finally {
-    loading.value = false;
-  }
-}
-
-onMounted(loadResponsibles);
-
-function openDialog(responsible = null) {
-  $q.dialog({
-    component: ResponsibleDialog,
-    componentProps: { studentId: props.studentId, responsible },
-  }).onOk(loadResponsibles);
-}
+  {
+    align: "left",
+    field: "email",
+    label: "E-mail",
+    name: "email",
+  },
+  {
+    align: "center",
+    field: null,
+    label: "Ações",
+    name: "actions",
+  },
+];
 
-function handleAdd() {
+const handleAdd = () => {
   openDialog();
-}
+};
 
-function handleEdit(responsible) {
-  openDialog(responsible);
-}
-
-function handleDelete(responsible) {
+const handleDelete = (responsible) => {
   if (props.required && rows.value.length === 1) {
     $q.notify({
+      message:
+        "O aluno menor de idade precisa ter ao menos um responsável.",
       type: "warning",
-      message: "O aluno menor de idade precisa ter ao menos um responsável.",
     });
+
     return;
   }
 
   $q.dialog({
-    title: "Excluir Responsável",
-    message: `Deseja excluir o responsável "${responsible.name}"?`,
     cancel: true,
+    message: `Deseja excluir o responsável "${responsible.name}"?`,
     persistent: true,
+    title: "Excluir Responsável",
   }).onOk(async () => {
     try {
       await deleteStudentResponsible(responsible.id);
-      rows.value = rows.value.filter((r) => r.id !== responsible.id);
-    } catch (e) {
-      console.error(e);
-      $q.notify({ type: "negative", message: "Erro ao excluir responsável." });
+
+      rows.value = rows.value.filter(
+        (item) => item.id !== responsible.id,
+      );
+    } catch (error) {
+      console.error(error);
+
+      $q.notify({
+        message: "Erro ao excluir responsável.",
+        type: "negative",
+      });
     }
   });
-}
+};
 
-async function validateRequired(required = props.required) {
+const handleEdit = (responsible) => {
+  openDialog(responsible);
+};
+
+const loadResponsibles = async () => {
+  loading.value = true;
+
+  try {
+    rows.value = await getStudentResponsible(
+      props.studentId,
+    );
+  } finally {
+    loading.value = false;
+  }
+};
+
+const openDialog = (responsible = null) => {
+  $q.dialog({
+    component: ResponsibleDialog,
+    componentProps: {
+      responsible,
+      studentId: props.studentId,
+    },
+  }).onOk(loadResponsibles);
+};
+
+const validateRequired = async (
+  required = props.required,
+) => {
   await loadResponsibles();
+
   return !required || rows.value.length > 0;
-}
+};
 
-defineExpose({ validateRequired });
-</script>
+onMounted(loadResponsibles);
+
+defineExpose({
+  validateRequired,
+});
+</script>

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

@@ -1,35 +1,52 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <div style="width: 100%; max-width: 600px">
-      <q-card class="dialog-form-card" style="width: 100%">
+      <q-card
+        class="dialog-form-card"
+        style="width: 100%"
+      >
         <DefaultDialogHeader
           :title="replyItem ? 'Editar Comentário' : 'Responder Suporte'"
           @close="onDialogCancel"
         />
 
-        <DefaultForm ref="formRef" @submit="onOKClick">
-          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
+        <DefaultForm
+          ref="formRef"
+          @submit="onOKClick"
+        >
+          <q-scroll-area
+            ref="scrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--xs"
+          >
             <q-card-section class="q-pt-sm">
-            <DefaultInput
-              v-model="replyText"
-              label="Descreva a resposta"
-              type="textarea"
-              class="col-12"
-            />
+              <DefaultInput
+                v-model="form.reply"
+                v-model:error="validationErrors.reply"
+                class="col-12"
+                label="Descreva a resposta"
+                type="textarea"
+                :rules="[inputRules.required]"
+              />
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
             <q-btn
-              outline
               color="primary"
               label="Cancelar"
+              no-caps
+              outline
               @click="onDialogCancel"
             />
+
             <q-btn
               color="primary"
-              :label="replyItem ? 'Salvar' : 'Responder'"
+              no-caps
               type="submit"
+              :label="replyItem ? 'Salvar' : 'Responder'"
               :loading="loading"
             />
           </q-card-actions>
@@ -40,43 +57,79 @@
 </template>
 
 <script setup>
-import { ref } from "vue";
+import {
+  createSupportReply,
+  updateSupportReply,
+} from "src/api/support_reply";
+
 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 { useTemplateRef } from "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 { createSupportReply, updateSupportReply } from "src/api/support_reply";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { ticketId, replyItem } = defineProps({
-  ticketId: {
-    type: Number,
-    required: true,
-  },
   replyItem: {
     type: Object,
     default: null,
   },
+  ticketId: {
+    type: Number,
+    required: true,
+  },
 });
 
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const loading = ref(false);
-const replyText = ref(replyItem?.reply ?? null);
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const { form, getUpdatedFields } = useForm({
+  reply: replyItem?.reply ?? null,
+});
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => {
+    onDialogOK(true);
+  },
+  scrollFn: scrollToComponent,
+});
 
 const onOKClick = async () => {
-  loading.value = true;
-  try {
+  const payload = replyItem
+    ? { ...getUpdatedFields.value }
+    : { reply: form.reply };
+
+  await execute(() => {
     if (replyItem) {
-      await updateSupportReply(ticketId, replyItem.id, { reply: replyText.value });
-    } else {
-      await createSupportReply(ticketId, { reply: replyText.value });
+      return updateSupportReply(
+        ticketId,
+        replyItem.id,
+        payload,
+      );
     }
-    onDialogOK(true);
-  } finally {
-    loading.value = false;
-  }
+
+    return createSupportReply(
+      ticketId,
+      payload,
+    );
+  });
 };
-</script>
+</script>

+ 277 - 172
src/pages/support/components/AddEditTicketDialog.vue

@@ -1,7 +1,10 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <div style="width: 100%; max-width: 1100px">
-      <q-card class="dialog-form-card" style="height: 500px">
+      <q-card
+        class="dialog-form-card"
+        style="height: 500px"
+      >
         <DefaultDialogHeader
           :title="ticket ? 'Editar Ticket' : 'Novo Ticket'"
           @close="onDialogCancel"
@@ -17,125 +20,153 @@
           "
           @submit="onOKClick"
         >
-          <q-scroll-area class="dialog-form-scroll">
+          <q-scroll-area
+            ref="scrollAreaRef"
+            class="dialog-form-scroll"
+          >
             <q-card-section class="q-pt-sm">
-            <CustomTabComponent
-              v-if="ticket?.id"
-              v-model:active-tab="currentTab"
-              :tabs="tabs"
-              class="q-mb-md"
-            />
+              <CustomTabComponent
+                v-if="ticket?.id"
+                v-model:active-tab="currentTab"
+                class="q-mb-md"
+                :tabs="tabs"
+              />
 
-            <!-- Tab: Ticket -->
-            <div v-show="currentTab === 'ticket'">
-              <div class="row q-col-gutter-sm">
-                <DefaultInput
-                  v-model="form.title"
-                  label="Título da Tarefa"
-                  class="col-12"
-                />
-
-                <DefaultSelect
-                  v-model="form.severity"
-                  label="Prioridade"
-                  :options="priorityOptions"
-                  emit-value
-                  map-options
-                  class="col-6"
-                />
-
-                <DefaultInput
-                  :model-value="user?.name"
-                  label="Responsável"
-                  disable
-                  class="col-6"
-                />
-
-                <DefaultSelect
-                  v-model="form.scope"
-                  label="Destino"
-                  :options="unitTargetOptions"
-                  emit-value
-                  map-options
-                  class="col-6"
-                />
-
-                <DefaultInput
-                  v-model="form.sector"
-                  label="Setor"
-                  class="col-6"
-                />
-
-                <DefaultInput
-                  v-model="form.description"
-                  label="Descrição"
-                  type="textarea"
-                  class="col-12"
-                />
-              </div>
-            </div>
-
-            <!-- Tab: Comentários -->
-            <div v-show="currentTab === 'comentarios'">
-              <div
-                v-if="canAdd && ticket?.status === 'in_progress'"
-                class="flex justify-end q-mb-sm"
-              >
-                <q-btn
-                  color="primary"
-                  icon="mdi-plus"
-                  unelevated
-                  style="width: 40px; height: 40px"
-                  @click="onAddComment"
-                />
+              <div v-show="currentTab === 'ticket'">
+                <div class="row q-col-gutter-sm">
+                  <DefaultInput
+                    v-model="form.title"
+                    v-model:error="validationErrors.title"
+                    class="col-12"
+                    label="Título da Tarefa"
+                    :rules="[inputRules.required]"
+                  />
+
+                  <DefaultSelect
+                    v-model="form.severity"
+                    v-model:error="validationErrors.severity"
+                    class="col-6"
+                    emit-value
+                    label="Prioridade"
+                    map-options
+                    :options="priorityOptions"
+                    :rules="[inputRules.required]"
+                  />
+
+                  <DefaultInput
+                    :model-value="user?.name"
+                    class="col-6"
+                    disable
+                    label="Responsável"
+                  />
+
+                  <DefaultSelect
+                    v-model="form.scope"
+                    v-model:error="validationErrors.scope"
+                    class="col-6"
+                    emit-value
+                    label="Destino"
+                    map-options
+                    :options="unitTargetOptions"
+                    :rules="[inputRules.required]"
+                  />
+
+                  <DefaultInput
+                    v-model="form.sector"
+                    v-model:error="validationErrors.sector"
+                    class="col-6"
+                    label="Setor"
+                  />
+
+                  <DefaultInput
+                    v-model="form.description"
+                    class="col-12"
+                    label="Descrição"
+                    type="textarea"
+                  />
+                </div>
               </div>
-              <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"
-                    :created-at="reply.created_at"
-                    :user-name="reply.user_name"
-                    @edit="onEditComment(reply)"
-                    @delete="onDeleteComment(reply)"
+
+              <div v-show="currentTab === 'comentarios'">
+                <div
+                  v-if="canAdd && ticket?.status === 'in_progress'"
+                  class="flex justify-end q-mb-sm"
+                >
+                  <q-btn
+                    color="primary"
+                    icon="mdi-plus"
+                    style="width: 40px; height: 40px"
+                    unelevated
+                    @click="onAddComment"
                   />
-                </template>
+                </div>
+
                 <div
-                  v-else
-                  class="flex flex-center full-height text-grey-5 text-body2"
+                  style="
+                    display: flex;
+                    flex-direction: column;
+                    gap: 8px;
+                  "
                 >
-                  Nenhum comentário registrado.
+                  <template v-if="replies.length">
+                    <TicketCommentCard
+                      v-for="reply in replies"
+                      :key="reply.id"
+                      :created-at="reply.created_at"
+                      :reply="reply.reply"
+                      :user-name="reply.user_name"
+                      @delete="onDeleteComment(reply)"
+                      @edit="onEditComment(reply)"
+                    />
+                  </template>
+
+                  <div
+                    v-else
+                    class="flex flex-center full-height text-grey-5 text-body2"
+                  >
+                    Nenhum comentário registrado.
+                  </div>
                 </div>
               </div>
-            </div>
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md" style="flex-shrink: 0">
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+            style="flex-shrink: 0"
+          >
             <q-btn
-              outline
               color="primary"
               label="Cancelar"
+              no-caps
+              outline
               @click="onDialogCancel"
             />
+
             <q-btn
-              v-if="canEdit && canManage && ticket?.id && ticket?.status === 'in_progress'"
-              outline
+              v-if="
+                canEdit &&
+                canManage &&
+                ticket?.id &&
+                ticket?.status === 'in_progress'
+              "
               color="negative"
               label="Encerrar"
+              no-caps
+              outline
               @click="onCloseTicket"
             />
+
             <q-btn
-              v-if="canSave && canManage && (!ticket?.id || ticket?.status === 'in_progress')"
+              v-if="
+                canSave &&
+                canManage &&
+                (!ticket?.id || ticket?.status === 'in_progress')
+              "
               color="primary"
               label="Salvar"
+              no-caps
               type="submit"
               :loading="loading"
             />
@@ -147,24 +178,35 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
-import { useDialogPluginComponent } from "quasar";
+import { computed, onMounted, ref } 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 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 { 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 { userStore } from "src/stores/user";
+
+import AddEditReplyDialog from "./AddEditReplyDialog.vue";
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import CloseTicketDialog from "./CloseTicketDialog.vue";
+import TicketCommentCard from "./TicketCommentCard.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";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -179,117 +221,180 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
 const $q = useQuasar();
-const { user } = userStore();
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
 const permissions = permissionStore();
-const canAdd = computed(() => permissions.getAccess("franchisee_support", "add"));
-const canEdit = computed(() => permissions.getAccess("franchisee_support", "edit"));
-const canSave = computed(() => ticket?.id ? canEdit.value : canAdd.value);
+const { user } = userStore();
 
 const formRef = ref(null);
-const loading = ref(false);
+const scrollAreaRef = ref(null);
+
 const currentTab = ref("ticket");
+const replies = ref([]);
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_support", "add"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_support", "edit"),
+);
+
+const canManage = computed(
+  () =>
+    !ticket?.id ||
+    (ticket?.origin === "unit" &&
+      ticket?.scope === "internal"),
+);
 
-// Novo ticket (sem id) → pode salvar. Ticket existente → só se for interno próprio.
-const canManage = computed(() =>
-  !ticket?.id || (ticket?.origin === "unit" && ticket?.scope === "internal")
+const canSave = computed(() =>
+  ticket?.id ? canEdit.value : canAdd.value,
 );
 
 const tabs = computed(() => {
-  const base = [{ name: "ticket", label: "Ticket" }];
-  if (ticket?.id) base.push({ name: "comentarios", label: "Comentários" });
-  return base;
-});
+  const items = [
+    {
+      label: "Ticket",
+      name: "ticket",
+    },
+  ];
 
-const replies = ref([]);
+  if (ticket?.id) {
+    items.push({
+      label: "Comentários",
+      name: "comentarios",
+    });
+  }
 
-const loadReplies = async () => {
-  if (!ticket?.id) return;
-  replies.value = await getSupportReplies(ticket.id);
-};
+  return items;
+});
 
 const priorityOptions = [
-  { label: "Alta", value: "alta" },
-  { label: "Normal", value: "normal" },
-  { label: "Baixa", value: "baixa" },
+  {
+    label: "Alta",
+    value: "alta",
+  },
+  {
+    label: "Normal",
+    value: "normal",
+  },
+  {
+    label: "Baixa",
+    value: "baixa",
+  },
 ];
 
 const unitTargetOptions = [
-  { label: "Suporte à Matriz", value: "specific" },
-  { label: "Suporte Interno", value: "internal" },
+  {
+    label: "Suporte à Matriz",
+    value: "specific",
+  },
+  {
+    label: "Suporte Interno",
+    value: "internal",
+  },
 ];
 
-const form = ref({
-  title: ticket?.title ?? null,
-  severity: ticket?.severity ?? null,
+const { form, getUpdatedFields } = useForm({
+  description: ticket?.description ?? null,
   scope: ticket?.scope ?? null,
   sector: ticket?.sector ?? null,
-  description: ticket?.description ?? null,
+  severity: ticket?.severity ?? null,
+  title: ticket?.title ?? null,
+});
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => {
+    onDialogOK(true);
+  },
+  scrollFn: scrollToComponent,
 });
 
 const buildPayload = () => ({
-  title: form.value.title,
-  severity: form.value.severity,
-  scope: form.value.scope,
-  sector: form.value.sector || null,
-  description: form.value.description || null,
-  target_unit_id: null, // Backend resolve baseado em scope + user.unit_id
+  description: form.description || null,
+  scope: form.scope,
+  sector: form.sector || null,
+  severity: form.severity,
+  target_unit_id: null,
+  title: form.title,
 });
 
+const loadReplies = async () => {
+  if (!ticket?.id) return;
+
+  replies.value = await getSupportReplies(ticket.id);
+};
+
 const onAddComment = () => {
   $q.dialog({
     component: AddEditReplyDialog,
-    componentProps: { ticketId: ticket.id },
-  }).onOk(() => {
-    loadReplies();
-  });
+    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 },
-  }).onOk(() => {
-    onDialogOK(true);
-  });
+    component: AddEditReplyDialog,
+    componentProps: {
+      replyItem: reply,
+      ticketId: ticket.id,
+    },
+  }).onOk(loadReplies);
 };
 
 const onOKClick = async () => {
-  loading.value = true;
-  try {
-    const payload = buildPayload();
+  const payload = ticket?.id
+    ? { ...getUpdatedFields.value }
+    : buildPayload();
+
+  await execute(() => {
     if (ticket?.id) {
-      await updateSupportTicket(ticket.id, payload);
-    } else {
-      await createSupportTicket(payload);
+      return updateSupportTicket(ticket.id, payload);
     }
-    onDialogOK(true);
-  } finally {
-    loading.value = false;
-  }
+
+    return createSupportTicket(payload);
+  });
 };
-</script>
+
+onMounted(loadReplies);
+</script>

+ 63 - 26
src/pages/support/components/CloseTicketDialog.vue

@@ -1,41 +1,62 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <div style="width: 100%; max-width: 500px">
-      <q-card class="dialog-form-card" style="width: 100%">
-        <DefaultDialogHeader title="Encerrar Ticket" @close="onDialogCancel" />
+      <q-card
+        class="dialog-form-card"
+        style="width: 100%"
+      >
+        <DefaultDialogHeader
+          title="Encerrar Ticket"
+          @close="onDialogCancel"
+        />
 
         <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
           <q-card-section class="q-pt-sm column q-gutter-y-md">
-          <DefaultSelect
-            v-model="resolved"
-            label="A solicitação foi resolvida?"
-            :options="resolvedOptions"
-            emit-value
-            map-options
-          />
+            <DefaultSelect
+              v-model="resolved"
+              emit-value
+              label="A solicitação foi resolvida?"
+              map-options
+              :options="resolvedOptions"
+              :rules="[inputRules.required]"
+            />
+
+            <p
+              v-if="resolved !== null"
+              class="text-body2 q-mb-none"
+            >
+              <template v-if="resolved">
+                Sua solicitação foi resolvida com sucesso, hora de finalizar
+                este suporte.
+              </template>
 
-          <p v-if="resolved !== null" class="text-body2 q-mb-none">
-            <template v-if="resolved">
-              Sua solicitação foi resolvida com sucesso, hora de finalizar este suporte.
-            </template>
-            <template v-else>
-              Sua solicitação não foi resolvida, finalize o suporte.
-            </template>
-          </p>
+              <template v-else>
+                Sua solicitação não foi resolvida, finalize o suporte.
+              </template>
+            </p>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-px-md q-pb-md">
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
           <q-btn
-            outline
             color="primary"
             label="Cancelar"
+            no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
-            v-if="resolved !== null && ticket.status === 'in_progress'"
+            v-if="
+              resolved !== null &&
+              ticket.status === 'in_progress'
+            "
             color="primary"
             label="Encerrar"
+            no-caps
             :loading="loading"
             @click="onOKClick"
           />
@@ -47,11 +68,12 @@
 
 <script setup>
 import { ref } from "vue";
+import { updateSupportTicket } from "src/api/support_ticket";
 import { useDialogPluginComponent } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import { updateSupportTicket } from "src/api/support_ticket";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -65,22 +87,37 @@ const { ticket } = defineProps({
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
+const { inputRules } = useInputRules();
+
 const loading = ref(false);
 const resolved = ref(null);
 
 const resolvedOptions = [
-  { label: "Sim", value: true },
-  { label: "Não", value: false },
+  {
+    label: "Sim",
+    value: true,
+  },
+  {
+    label: "Não",
+    value: false,
+  },
 ];
 
 const onOKClick = async () => {
   loading.value = true;
+
   try {
-    const status = resolved.value ? "resolved" : "unresolved";
-    await updateSupportTicket(ticket.id, { status });
+    const status = resolved.value
+      ? "resolved"
+      : "unresolved";
+
+    await updateSupportTicket(ticket.id, {
+      status,
+    });
+
     onDialogOK(true);
   } finally {
     loading.value = false;
   }
 };
-</script>
+</script>

+ 96 - 56
src/pages/unit/components/AddEditHistoryDialog.vue

@@ -4,52 +4,63 @@
       class="q-dialog-plugin dialog-form-card"
       style="width: 560px; max-width: 95vw"
     >
-      <DefaultDialogHeader :title="dialogTitle" @close="onDialogCancel" />
-
-      <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+      <DefaultDialogHeader
+        :title="dialogTitle"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
+        <q-scroll-area
+          ref="scrollAreaRef"
+          class="dialog-form-scroll dialog-form-scroll--sm"
+        >
           <q-card-section class="q-pt-none">
-          <div class="column q-gutter-sm">
-            <DefaultInput
-              v-model="form.title"
-              :error="!!validationErrors.title"
-              :error-message="validationErrors.title"
-              label="Título"
-              outlined
-              :rules="[inputRules.required]"
-            />
-
-            <q-input
-              v-model="form.content"
-              autogrow
-              label="Conteúdo"
-              outlined
-              rows="5"
-              type="textarea"
-              :error="!!validationErrors.content"
-              :error-message="validationErrors.content"
-              @update:model-value="validationErrors.content = null"
-            />
-
-            <q-toggle
-              v-model="form.visible_to_franchisee"
-              color="positive"
-              label="Visível ao franqueado"
-            />
-          </div>
+            <div class="column q-gutter-sm">
+              <DefaultInput
+                v-model="form.title"
+                v-model:error="validationErrors.title"
+                label="Título"
+                outlined
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInput
+                v-model="form.content"
+                v-model:error="validationErrors.content"
+                autogrow
+                label="Conteúdo"
+                outlined
+                rows="5"
+                type="textarea"
+              />
+
+              <q-toggle
+                v-model="form.visible_to_franchisee"
+                color="positive"
+                label="Visível ao franqueado"
+              />
+            </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-pa-md">
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
           <q-btn
             color="primary"
             label="Cancelar"
+            no-caps
             outline
             @click="onDialogCancel"
           />
 
           <q-btn
-            color="primary-2"
+            color="primary"
+            no-caps
             type="submit"
             :label="history ? 'Salvar' : 'Adicionar'"
             :loading="loading"
@@ -62,52 +73,81 @@
 
 <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 { useTemplateRef } from "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";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { history, unitId } = defineProps({
-  history: { type: Object, default: null },
-  unitId: { type: Number, required: true },
+  history: {
+    type: Object,
+    default: null,
+  },
+  unitId: {
+    type: Number,
+    required: true,
+  },
 });
 
-const { inputRules } = useInputRules();
-
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const dialogTitle = history ? "Editar Histórico" : "Novo Histórico";
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
 
-const formRef = ref(null);
-const form = ref({
+const dialogTitle = history
+  ? "Editar Histórico"
+  : "Novo Histórico";
+
+const { form, getUpdatedFields } = useForm({
   content: history?.content ?? "",
   title: history?.title ?? "",
-  visible_to_franchisee: history?.visible_to_franchisee ?? false,
+
+  visible_to_franchisee:
+    history?.visible_to_franchisee ?? false,
 });
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
   formRef,
-  onSuccess: (result) => onDialogOK(result),
+  onSuccess: (result) => {
+    onDialogOK(result);
+  },
+  scrollFn: scrollToComponent,
 });
 
-async function onOKClick() {
+const onOKClick = async () => {
   await execute(() => {
-    const payload = {
-      content: form.value.content,
-      title: form.value.title,
+    if (history) {
+      return updateHistory(
+        history.id,
+        { ...getUpdatedFields.value },
+      );
+    }
+
+    return createHistory({
+      content: form.content,
+      title: form.title,
       unit_id: unitId,
-      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>

+ 290 - 236
src/pages/unit/components/AddEditPartnerDialog.vue

@@ -4,214 +4,223 @@
       class="q-dialog-plugin dialog-form-card"
       style="width: 860px; max-width: 95vw"
     >
-      <DefaultDialogHeader :title="dialogTitle" @close="onDialogCancel" />
-
-      <DefaultForm ref="formRef" @submit="onOKClick">
+      <DefaultDialogHeader
+        :title="dialogTitle"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
         <q-scroll-area class="dialog-form-scroll">
           <q-card-section class="q-pt-none">
-          <div class="column items-center q-mb-md">
-            <AvatarImageComponent
-              ref="avatarRef"
-              @update:file="onAvatarChange"
-            />
-          </div>
-
-          <div class="row q-col-gutter-sm">
-            <DefaultInput
-              v-model="form.name"
-              :error="!!validationErrors.name"
-              :error-message="validationErrors.name"
-              class="col-8"
-              label="Nome completo"
-              outlined
-              :rules="[inputRules.required]"
-            />
-
-            <DefaultInput
-              v-model="form.role"
-              :error="!!validationErrors.role"
-              :error-message="validationErrors.role"
-              class="col-4"
-              label="Função"
-              outlined
-            />
-
-            <DefaultInput
-              v-model="form.social_name"
-              :error="!!validationErrors.social_name"
-              :error-message="validationErrors.social_name"
-              class="col-6"
-              label="Nome social"
-              outlined
-            />
-
-            <DefaultInput
-              v-model="form.cpf"
-              :error="!!validationErrors.cpf"
-              :error-message="validationErrors.cpf"
-              class="col-3"
-              label="CPF"
-              outlined
-              :mask="masks.Brasil.cpf"
-              :rules="[inputRules.required, inputRules.cpf]"
-            />
-
-            <DefaultInput
-              v-model="form.rg"
-              :error="!!validationErrors.rg"
-              :error-message="validationErrors.rg"
-              class="col-3"
-              label="RG"
-              outlined
-            />
-
-            <DefaultInput
-              v-model="birthDateDisplay"
-              :error="!!validationErrors.birth_date"
-              :error-message="validationErrors.birth_date"
-              class="col-3"
-              label="Data de Nascimento"
-              placeholder="DD/MM/AAAA"
-              outlined
-              :mask="masks.Brasil.date"
-            />
-
-            <DefaultInput
-              v-model="form.participation"
-              :error="!!validationErrors.participation"
-              :error-message="validationErrors.participation"
-              class="col-3"
-              label="Participação (%)"
-              max="100"
-              min="0"
-              outlined
-              type="number"
-            />
-
-            <DefaultInput
-              v-model="form.email"
-              :error="!!validationErrors.email"
-              :error-message="validationErrors.email"
-              class="col-6"
-              label="E-mail"
-              outlined
-              :rules="[inputRules.email]"
-            />
-
-            <DefaultInput
-              v-model="form.secondary_email"
-              :error="!!validationErrors.secondary_email"
-              :error-message="validationErrors.secondary_email"
-              class="col-6"
-              label="E-mail Secundário"
-              outlined
-              :rules="[inputRules.email]"
-            />
-
-            <DefaultInput
-              v-model="form.phone_number"
-              :error="!!validationErrors.phone_number"
-              :error-message="validationErrors.phone_number"
-              class="col-6"
-              label="Telefone"
-              outlined
-              :mask="masks.Brasil.telefone"
-            />
-
-            <DefaultInput
-              v-model="form.cell_number"
-              :error="!!validationErrors.cell_number"
-              :error-message="validationErrors.cell_number"
-              class="col-6"
-              label="Celular"
-              outlined
-              :mask="masks.Brasil.celular"
-            />
-
-            <DefaultCepInput
-              v-model="form.postal_code"
-              :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"
-              :error="!!validationErrors.street"
-              :error-message="validationErrors.street"
-              class="col-6"
-              label="Endereço"
-              outlined
-            />
-
-            <DefaultInput
-              v-model="form.address_number"
-              :error="!!validationErrors.address_number"
-              :error-message="validationErrors.address_number"
-              class="col-6"
-              label="Número"
-              outlined
-            />
-
-            <DefaultInput
-              v-model="form.neighborhood"
-              :error="!!validationErrors.neighborhood"
-              :error-message="validationErrors.neighborhood"
-              class="col-4"
-              label="Bairro"
-              outlined
-            />
-
-            <StateSelect
-              ref="stateSelectRef"
-              v-model="selectedState"
-              :error="!!validationErrors.state_id"
-              :error-message="validationErrors.state_id"
-              class="col-4"
-              label="Estado"
-              outlined
-            />
-
-            <CitySelect
-              ref="citySelectRef"
-              v-model="selectedCity"
-              :error="!!validationErrors.city_id"
-              :error-message="validationErrors.city_id"
-              class="col-4"
-              label="Cidade"
-              outlined
-              :state="selectedState"
-            />
-
-            <DefaultInput
-              v-model="form.complement"
-              :error="!!validationErrors.complement"
-              :error-message="validationErrors.complement"
-              class="col-12"
-              label="Complemento"
-              outlined
-            />
-          </div>
+            <div class="column items-center q-mb-md">
+              <AvatarImageComponent
+                ref="avatarRef"
+                @update:file="onAvatarChange"
+              />
+            </div>
+
+            <div class="row q-col-gutter-sm">
+              <DefaultInput
+                v-model="form.name"
+                class="col-8"
+                label="Nome completo"
+                outlined
+                :error="!!validationErrors.name"
+                :error-message="validationErrors.name"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInput
+                v-model="form.role"
+                class="col-4"
+                label="Função"
+                outlined
+                :error="!!validationErrors.role"
+                :error-message="validationErrors.role"
+              />
+
+              <DefaultInput
+                v-model="form.social_name"
+                class="col-6"
+                label="Nome social"
+                outlined
+                :error="!!validationErrors.social_name"
+                :error-message="validationErrors.social_name"
+              />
+
+              <DefaultInput
+                v-model="form.cpf"
+                class="col-3"
+                label="CPF"
+                outlined
+                :error="!!validationErrors.cpf"
+                :error-message="validationErrors.cpf"
+                :mask="masks.Brasil.cpf"
+                :rules="[inputRules.required, inputRules.cpf]"
+              />
+
+              <DefaultInput
+                v-model="form.rg"
+                class="col-3"
+                label="RG"
+                outlined
+                :error="!!validationErrors.rg"
+                :error-message="validationErrors.rg"
+              />
+
+              <DefaultInput
+                v-model="birthDateDisplay"
+                class="col-3"
+                label="Data de Nascimento"
+                outlined
+                placeholder="DD/MM/AAAA"
+                :error="!!validationErrors.birth_date"
+                :error-message="validationErrors.birth_date"
+                :mask="masks.Brasil.date"
+              />
+
+              <DefaultInput
+                v-model="form.participation"
+                class="col-3"
+                label="Participação (%)"
+                max="100"
+                min="0"
+                outlined
+                type="number"
+                :error="!!validationErrors.participation"
+                :error-message="validationErrors.participation"
+              />
+
+              <DefaultInput
+                v-model="form.email"
+                class="col-6"
+                label="E-mail"
+                outlined
+                :error="!!validationErrors.email"
+                :error-message="validationErrors.email"
+                :rules="[inputRules.email]"
+              />
+
+              <DefaultInput
+                v-model="form.secondary_email"
+                class="col-6"
+                label="E-mail Secundário"
+                outlined
+                :error="!!validationErrors.secondary_email"
+                :error-message="validationErrors.secondary_email"
+                :rules="[inputRules.email]"
+              />
+
+              <DefaultInput
+                v-model="form.phone_number"
+                class="col-6"
+                label="Telefone"
+                outlined
+                :error="!!validationErrors.phone_number"
+                :error-message="validationErrors.phone_number"
+                :mask="masks.Brasil.telefone"
+              />
+
+              <DefaultInput
+                v-model="form.cell_number"
+                class="col-6"
+                label="Celular"
+                outlined
+                :error="!!validationErrors.cell_number"
+                :error-message="validationErrors.cell_number"
+                :mask="masks.Brasil.celular"
+              />
+
+              <DefaultCepInput
+                v-model="form.postal_code"
+                class="col-6"
+                outlined
+                :error="!!validationErrors.postal_code"
+                :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"
+                class="col-6"
+                label="Endereço"
+                outlined
+                :error="!!validationErrors.street"
+                :error-message="validationErrors.street"
+              />
+
+              <DefaultInput
+                v-model="form.address_number"
+                class="col-6"
+                label="Número"
+                outlined
+                :error="!!validationErrors.address_number"
+                :error-message="validationErrors.address_number"
+              />
+
+              <DefaultInput
+                v-model="form.neighborhood"
+                class="col-4"
+                label="Bairro"
+                outlined
+                :error="!!validationErrors.neighborhood"
+                :error-message="validationErrors.neighborhood"
+              />
+
+              <StateSelect
+                ref="stateSelectRef"
+                v-model="selectedState"
+                class="col-4"
+                label="Estado"
+                outlined
+                :error="!!validationErrors.state_id"
+                :error-message="validationErrors.state_id"
+              />
+
+              <CitySelect
+                ref="citySelectRef"
+                v-model="selectedCity"
+                class="col-4"
+                label="Cidade"
+                outlined
+                :error="!!validationErrors.city_id"
+                :error-message="validationErrors.city_id"
+                :state="selectedState"
+              />
+
+              <DefaultInput
+                v-model="form.complement"
+                class="col-12"
+                label="Complemento"
+                outlined
+                :error="!!validationErrors.complement"
+                :error-message="validationErrors.complement"
+              />
+            </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions>
-          <q-space />
-
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
           <q-btn
-            color="negative"
+            color="primary"
             label="Cancelar"
+            no-caps
             outline
             @click="onDialogCancel"
           />
 
           <q-btn
-            color="primary-2"
+            color="primary"
+            no-caps
             type="submit"
             :label="partner ? 'Salvar' : 'Adicionar'"
             :loading="loading"
@@ -224,22 +233,29 @@
 
 <script setup>
 import { createPartner, updatePartner } from "src/api/unit_partner";
-import { formatDateDMYtoYMD, formatDateYMDtoDMY } from "src/helpers/utils";
+
+import {
+  formatDateDMYtoYMD,
+  formatDateYMDtoDMY,
+} from "src/helpers/utils";
+
 import { onMounted, ref, watch } from "vue";
 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 masks from "src/helpers/masks";
 
-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 masks from "src/helpers/masks";
 
 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 StateSelect from "src/components/selects/StateSelect.vue";
+
+import DefaultCepInput from "src/components/defaults/DefaultCepInput.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";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -258,14 +274,37 @@ const { offlineMode, partner, unitId } = defineProps({
   },
 });
 
-const { inputRules } = useInputRules();
-
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const dialogTitle = partner ? "Editar Sócio" : "Adicionar Sócio";
+const { inputRules } = useInputRules();
+
+const avatarRef = ref(null);
+const citySelectRef = ref(null);
+const formRef = ref(null);
+const stateSelectRef = ref(null);
 
-const { form, getFormAsFormData } = useFormUpdateTracker({
+const avatarChanged = ref(false);
+const avatarFile = ref(null);
+
+const birthDateDisplay = ref(
+  partner?.birth_date
+    ? formatDateYMDtoDMY(partner.birth_date)
+    : null,
+);
+
+const selectedCity = ref(null);
+const selectedState = ref(null);
+
+const dialogTitle = partner
+  ? "Editar Sócio"
+  : "Adicionar Sócio";
+
+const {
+  form,
+  getFormAsFormData,
+  getUpdatedFieldsAsFormData,
+} = useForm({
   address_number: partner?.address_number ?? null,
   birth_date: partner?.birth_date ?? null,
   cell_number: partner?.cell_number ?? null,
@@ -287,31 +326,27 @@ const { form, getFormAsFormData } = useFormUpdateTracker({
   unit_id: unitId,
 });
 
-const avatarRef = ref(null);
-const avatarChanged = ref(false);
-const avatarFile = ref(null);
-const birthDateDisplay = ref(
-  partner?.birth_date ? formatDateYMDtoDMY(partner.birth_date) : null,
-);
-const stateSelectRef = ref(null);
-const citySelectRef = ref(null);
-const formRef = ref(null);
-const selectedState = ref(null);
-const selectedCity = ref(null);
-
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   formRef,
-  onSuccess: (result) => onDialogOK(result),
+  onSuccess: (result) => {
+    onDialogOK(result);
+  },
 });
 
-function onAvatarChange(file) {
+const onAvatarChange = (file) => {
   avatarChanged.value = true;
   avatarFile.value = file;
-}
+};
 
-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:")) {
@@ -320,7 +355,9 @@ async function onOKClick() {
 
       partnerData.avatar = avatarFile.value;
 
-      partnerData.avatar_url = URL.createObjectURL(avatarFile.value);
+      partnerData.avatar_url = URL.createObjectURL(
+        avatarFile.value,
+      );
     } else if (partner?.avatar_url) {
       partnerData.avatar_url = partner.avatar_url;
     }
@@ -331,23 +368,34 @@ async function onOKClick() {
   }
 
   await execute(() => {
-    const formData = getFormAsFormData();
+    const formData = partner
+      ? getUpdatedFieldsAsFormData()
+      : getFormAsFormData();
 
     if (avatarChanged.value) {
-      formData.append("avatar", avatarFile.value ?? "");
+      formData.append(
+        "avatar",
+        avatarFile.value ?? "",
+      );
     }
 
     if (partner) {
-      return updatePartner(partner.id, formData);
+      return updatePartner(
+        partner.id,
+        formData,
+      );
     }
 
     return createPartner(formData);
   });
-}
+};
 
-watch(birthDateDisplay, (val) => {
+watch(birthDateDisplay, (value) => {
   try {
-    form.birth_date = val?.length === 10 ? formatDateDMYtoYMD(val) : null;
+    form.birth_date =
+      value?.length === 10
+        ? formatDateDMYtoYMD(value)
+        : null;
   } catch {
     form.birth_date = null;
   }
@@ -365,15 +413,21 @@ onMounted(() => {
   if (!partner) return;
 
   if (partner.avatar_url) {
-    avatarRef.value?.setImageUrl(partner.avatar_url);
+    avatarRef.value?.setImageUrl(
+      partner.avatar_url,
+    );
   }
 
   if (partner.state_id) {
-    stateSelectRef.value?.selectStateById(partner.state_id);
+    stateSelectRef.value?.selectStateById(
+      partner.state_id,
+    );
   }
 
   if (partner.city_id) {
-    citySelectRef.value?.selectCityById(partner.city_id);
+    citySelectRef.value?.selectCityById(
+      partner.city_id,
+    );
   }
 });
-</script>
+</script>

+ 61 - 40
src/pages/unit/components/AddMediaDialog.vue

@@ -4,50 +4,57 @@
       class="q-dialog-plugin dialog-form-card"
       style="width: 480px; max-width: 95vw"
     >
-      <DefaultDialogHeader title="Adicionar Mídia" @close="onDialogCancel" />
+      <DefaultDialogHeader
+        title="Adicionar Mídia"
+        @close="onDialogCancel"
+      />
 
-      <DefaultForm ref="formRef" @submit="onOKClick">
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
         <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
           <q-card-section class="q-pt-none">
-          <div class="column q-gutter-sm">
-            <DefaultInput
-              v-model="form.title"
-              :error="!!validationErrors.title"
-              :error-message="validationErrors.title"
-              label="Título"
-              outlined
-              :rules="[inputRules.required]"
-            />
-
-            <q-file
-              v-model="selectedFile"
-              accept="image/*,video/*,.pdf"
-              label="Arquivo"
-              outlined
-              :error="!!validationErrors.file"
-              :error-message="validationErrors.file"
-              :rules="[inputRules.required]"
-              @update:model-value="validationErrors.file = null"
-            >
-              <template #prepend>
-                <q-icon name="attach_file" />
-              </template>
-            </q-file>
-          </div>
+            <div class="column q-gutter-sm">
+              <DefaultInput
+                v-model="form.title"
+                label="Título"
+                outlined
+                :error="!!validationErrors.title"
+                :error-message="validationErrors.title"
+                :rules="[inputRules.required]"
+              />
+
+              <CustomFileInput
+                v-model="selectedFile"
+                accept="image/*,video/*,.pdf"
+                label="Arquivo"
+                outlined
+                :error="!!validationErrors.file"
+                :error-message="validationErrors.file"
+                :rules="[inputRules.required]"
+                @update:model-value="validationErrors.file = null"
+              />
+            </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-pa-md">
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
           <q-btn
             color="primary"
             label="Cancelar"
+            no-caps
             outline
             @click="onDialogCancel"
           />
 
           <q-btn
-            color="primary-2"
+            color="primary"
             label="Adicionar"
+            no-caps
             type="submit"
             :loading="loading"
           />
@@ -64,42 +71,56 @@ import { useDialogPluginComponent } from "quasar";
 import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
+import CustomFileInput from "src/components/defaults/CustomFileInput.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";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { unitId } = defineProps({
-  unitId: { type: Number, required: true },
+  unitId: {
+    type: Number,
+    required: true,
+  },
 });
 
-const { inputRules } = useInputRules();
-
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
+const { inputRules } = useInputRules();
+
 const formRef = ref(null);
-const selectedFile = ref(null);
+
 const form = ref({
   title: "",
   visible_to_franchisee: true,
 });
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const selectedFile = ref(null);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   formRef,
-  onSuccess: (result) => onDialogOK(result),
+  onSuccess: (result) => {
+    onDialogOK(result);
+  },
 });
 
-async function onOKClick() {
+const onOKClick = async () => {
   await execute(() => {
     const formData = new FormData();
 
-    formData.append("unit_id", unitId);
-    formData.append("title", form.value.title);
     formData.append("file", selectedFile.value);
+    formData.append("title", form.value.title);
+    formData.append("unit_id", unitId);
     formData.append("visible_to_franchisee", 1);
 
     return createMedia(formData);
   });
-}
-</script>
+};
+</script>

+ 7 - 7
src/pages/unit/components/ViewContractDialog.vue

@@ -33,13 +33,13 @@
 
           <DefaultInput
             :model-value="unitData.franchisee_document"
+            :mask="masks.Brasil.cpf"
+            :rules="[inputRules.cpf]"
             class="col-md-3 col-12"
             color="secondary"
             disable
             label="CPF"
             label-color="secondary"
-            :mask="masks.Brasil.cpf"
-            :rules="[inputRules.cpf]"
           />
 
           <DefaultInput
@@ -179,21 +179,21 @@
 </template>
 
 <script setup>
+import { getFranchiseeContractTaxHistory } from "src/api/franchisee_contract";
+import { getInhabitantClassificationsForSelect } from "src/api/inhabitant_classification";
+import { getUnitMe } from "src/api/unit";
 import { onMounted, reactive, ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+
 import masks from "src/helpers/masks";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import { useInputRules } from "src/composables/useInputRules";
 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 { getFranchiseeContractTaxHistory } from "src/api/franchisee_contract";
-import { getInhabitantClassificationsForSelect } from "src/api/inhabitant_classification";
-import { getUnitMe } from "src/api/unit";
-
 defineEmits([...useDialogPluginComponent.emits]);
 
 const props = defineProps({

+ 350 - 272
src/pages/unit/tabs/UnitDataTab.vue

@@ -1,222 +1,245 @@
 <template>
   <div class="q-pa-md">
-    <DefaultForm ref="formRef" @submit="onSave">
-      <q-scroll-area ref="scrollAreaRef" class="unit-data-scroll-area">
+    <DefaultForm
+      ref="formRef"
+      @submit="onSave"
+    >
+      <q-scroll-area
+        ref="scrollAreaRef"
+        class="unit-data-scroll-area"
+      >
         <div class="column justify-center items-center q-pr-sm">
-        <AvatarImageComponent ref="avatarRef" @update:file="onAvatarChange" />
-
-        <div class="row full-width q-mt-md q-col-gutter-sm">
-          <DefaultInput
-            :model-value="form.name"
-            :error="!!validationErrors.name"
-            :error-message="validationErrors.name"
-            class="col-12"
-            disable
-            label="Nome da Unidade"
-            outlined
+          <AvatarImageComponent
+            ref="avatarRef"
+            @update:file="onAvatarChange"
           />
 
-          <DefaultInput
-            :model-value="form.social_reason"
-            :error="!!validationErrors.social_reason"
-            :error-message="validationErrors.social_reason"
-            class="col-12"
-            disable
-            label="Razão Social"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.fantasy_name"
-            :error="!!validationErrors.fantasy_name"
-            :error-message="validationErrors.fantasy_name"
-            class="col-12"
-            disable
-            label="Nome Fantasia"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.cnpj"
-            :error="!!validationErrors.cnpj"
-            :error-message="validationErrors.cnpj"
-            class="col-4"
-            disable
-            label="CNPJ"
-            outlined
-            :mask="masks.Brasil.cnpj"
-            :rules="[inputRules.cnpj]"
-          />
-
-          <DefaultInput
-            :model-value="form.state_registration"
-            :error="!!validationErrors.state_registration"
-            :error-message="validationErrors.state_registration"
-            class="col-4"
-            disable
-            label="Inscrição Estadual"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.name_responsible"
-            :error="!!validationErrors.name_responsible"
-            :error-message="validationErrors.name_responsible"
-            class="col-4"
-            disable
-            label="Franqueado Operador"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.postal_code"
-            :error="!!validationErrors.postal_code"
-            :error-message="validationErrors.postal_code"
-            class="col-3"
-            disable
-            label="CEP"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.street"
-            :error="!!validationErrors.street"
-            :error-message="validationErrors.street"
-            class="col-6"
-            disable
-            label="Endereço"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.address_number"
-            :error="!!validationErrors.address_number"
-            :error-message="validationErrors.address_number"
-            class="col-3"
-            disable
-            label="Número"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <DefaultInput
-            :model-value="form.neighborhood"
-            :error="!!validationErrors.neighborhood"
-            :error-message="validationErrors.neighborhood"
-            class="col-4"
-            disable
-            label="Bairro"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="stateName"
-            :error="!!validationErrors.state_id"
-            :error-message="validationErrors.state_id"
-            class="col-4"
-            disable
-            label="Estado"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="cityName"
-            :error="!!validationErrors.city_id"
-            :error-message="validationErrors.city_id"
-            class="col-4"
-            disable
-            label="Cidade"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.complement"
-            :error="!!validationErrors.complement"
-            :error-message="validationErrors.complement"
-            class="col-12"
-            disable
-            label="Complemento"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.email"
-            :error="!!validationErrors.email"
-            :error-message="validationErrors.email"
-            class="col-6"
-            disable
-            label="E-mail Principal"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.secondary_email"
-            :error="!!validationErrors.secondary_email"
-            :error-message="validationErrors.secondary_email"
-            class="col-6"
-            disable
-            label="E-mail Administrativo"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.phone_number"
-            :error="!!validationErrors.phone_number"
-            :error-message="validationErrors.phone_number"
-            class="col-6"
-            disable
-            label="Telefone"
-            outlined
-          />
-
-          <DefaultInput
-            :model-value="form.cell_number"
-            :error="!!validationErrors.cell_number"
-            :error-message="validationErrors.cell_number"
-            class="col-6"
-            disable
-            label="Celular"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <div class="col-12 q-mt-sm">
-            <div class="text-subtitle2 text-grey-7 q-mb-sm">Alterar Senha</div>
+          <div class="row full-width q-mt-md q-col-gutter-sm">
+            <DefaultInput
+              :model-value="form.name"
+              :error="!!validationErrors.name"
+              :error-message="validationErrors.name"
+              class="col-12"
+              disable
+              label="Nome da Unidade"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.social_reason"
+              :error="!!validationErrors.social_reason"
+              :error-message="validationErrors.social_reason"
+              class="col-12"
+              disable
+              label="Razão Social"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.fantasy_name"
+              :error="!!validationErrors.fantasy_name"
+              :error-message="validationErrors.fantasy_name"
+              class="col-12"
+              disable
+              label="Nome Fantasia"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.cnpj"
+              :error="!!validationErrors.cnpj"
+              :error-message="validationErrors.cnpj"
+              :mask="masks.Brasil.cnpj"
+              :rules="[inputRules.cnpj]"
+              class="col-4"
+              disable
+              label="CNPJ"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.state_registration"
+              :error="!!validationErrors.state_registration"
+              :error-message="validationErrors.state_registration"
+              class="col-4"
+              disable
+              label="Inscrição Estadual"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.name_responsible"
+              :error="!!validationErrors.name_responsible"
+              :error-message="validationErrors.name_responsible"
+              class="col-4"
+              disable
+              label="Franqueado Operador"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.postal_code"
+              :error="!!validationErrors.postal_code"
+              :error-message="validationErrors.postal_code"
+              class="col-3"
+              disable
+              label="CEP"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.street"
+              :error="!!validationErrors.street"
+              :error-message="validationErrors.street"
+              class="col-6"
+              disable
+              label="Endereço"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.address_number"
+              :error="!!validationErrors.address_number"
+              :error-message="validationErrors.address_number"
+              :rules="[inputRules.required]"
+              class="col-3"
+              disable
+              label="Número"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.neighborhood"
+              :error="!!validationErrors.neighborhood"
+              :error-message="validationErrors.neighborhood"
+              class="col-4"
+              disable
+              label="Bairro"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="stateName"
+              :error="!!validationErrors.state_id"
+              :error-message="validationErrors.state_id"
+              class="col-4"
+              disable
+              label="Estado"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="cityName"
+              :error="!!validationErrors.city_id"
+              :error-message="validationErrors.city_id"
+              class="col-4"
+              disable
+              label="Cidade"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.complement"
+              :error="!!validationErrors.complement"
+              :error-message="validationErrors.complement"
+              class="col-12"
+              disable
+              label="Complemento"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.email"
+              :error="!!validationErrors.email"
+              :error-message="validationErrors.email"
+              class="col-6"
+              disable
+              label="E-mail Principal"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.secondary_email"
+              :error="!!validationErrors.secondary_email"
+              :error-message="validationErrors.secondary_email"
+              class="col-6"
+              disable
+              label="E-mail Administrativo"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.phone_number"
+              :error="!!validationErrors.phone_number"
+              :error-message="validationErrors.phone_number"
+              class="col-6"
+              disable
+              label="Telefone"
+              outlined
+            />
+
+            <DefaultInput
+              :model-value="form.cell_number"
+              :error="!!validationErrors.cell_number"
+              :error-message="validationErrors.cell_number"
+              :rules="[inputRules.required]"
+              class="col-6"
+              disable
+              label="Celular"
+              outlined
+            />
+
+            <div class="col-12 q-mt-sm">
+              <div class="text-subtitle2 text-grey-7 q-mb-sm">
+                Alterar Senha
+              </div>
+            </div>
+
+            <DefaultPasswordInput
+              v-model="form.password"
+              class="col-6"
+              label="Nova Senha"
+              outlined
+              :error="!!validationErrors.password"
+              :error-message="validationErrors.password"
+              :rules="
+                form.password
+                  ? [inputRules.password]
+                  : []
+              "
+            />
+
+            <DefaultPasswordInput
+              v-model="form.password_confirmation"
+              class="col-6"
+              label="Confirmar Nova Senha"
+              outlined
+              :error="
+                !!validationErrors.password_confirmation
+              "
+              :error-message="
+                validationErrors.password_confirmation
+              "
+              :rules="
+                form.password
+                  ? [
+                      inputRules.samePassword(
+                        form.password,
+                      ),
+                    ]
+                  : []
+              "
+            />
           </div>
-
-          <DefaultPasswordInput
-            v-model="form.password"
-            :error="!!validationErrors.password"
-            :error-message="validationErrors.password"
-            class="col-6"
-            label="Nova Senha"
-            outlined
-            :rules="form.password ? [inputRules.password] : []"
-          />
-
-          <DefaultPasswordInput
-            v-model="form.password_confirmation"
-            :error="!!validationErrors.password_confirmation"
-            :error-message="validationErrors.password_confirmation"
-            class="col-6"
-            label="Confirmar Nova Senha"
-            outlined
-            :rules="
-              form.password
-                ? [inputRules.samePassword(form.password)]
-                : []
-            "
-          />
-        </div>
-
         </div>
       </q-scroll-area>
 
       <div class="row justify-end q-mt-md items-end full-width q-px-xs">
         <q-btn
           v-if="canEdit"
-          color="primary-2"
+          color="primary"
           label="Salvar"
+          no-caps
           type="submit"
           :disable="!hasChanges"
           :loading="loading"
@@ -227,131 +250,186 @@
 </template>
 
 <script setup>
-import { computed, onMounted, ref, useTemplateRef } from "vue";
+import {
+  computed,
+  onMounted,
+  ref,
+  useTemplateRef,
+} from "vue";
+
 import { getUnitMe, updateUnitMe } from "src/api/unit";
+import { permissionStore } from "src/stores/permission";
 import { updateUserMe } from "src/api/user.js";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+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 DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
-import { permissionStore } from "src/stores/permission";
 
-const permissions = permissionStore();
-const canEdit = computed(() => permissions.getAccess("franchisee_unit", "edit"));
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 
 defineProps({
-  unitId: { type: Number, default: null },
+  unitId: {
+    type: Number,
+    default: null,
+  },
 });
 
 const { inputRules } = useInputRules();
 const { scrollToComponent } = useScroll();
 
+const permissions = permissionStore();
+
 const avatarRef = useTemplateRef("avatarRef");
 const formRef = useTemplateRef("formRef");
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
 const cityName = ref("");
 const newAvatarFile = ref(null);
 const stateName = ref("");
 
-const { form, hasUpdatedFields, setUpdateFormAsOriginal } =
-  useFormUpdateTracker(
-    {
-      name: null,
-      address_number: null,
-      cell_number: null,
-      city_id: null,
-      cnpj: null,
-      complement: null,
-      email: null,
-      fantasy_name: "",
-      name_responsible: null,
-      neighborhood: null,
-      password: null,
-      password_confirmation: null,
-      phone_number: null,
-      postal_code: null,
-      secondary_email: null,
-      social_reason: null,
-      state_id: null,
-      state_registration: null,
-      street: null,
-    },
-    { containerRef: scrollAreaRef },
-  );
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_unit", "edit"),
+);
+
+const {
+  form,
+  getUpdatedFields,
+  hasUpdatedFields,
+  setUpdateFormAsOriginal,
+} = useForm({
+  address_number: null,
+  cell_number: null,
+  city_id: null,
+  cnpj: null,
+  complement: null,
+  email: null,
+  fantasy_name: "",
+  name: null,
+  name_responsible: null,
+  neighborhood: null,
+  password: null,
+  password_confirmation: null,
+  phone_number: null,
+  postal_code: null,
+  secondary_email: null,
+  social_reason: null,
+  state_id: null,
+  state_registration: null,
+  street: null,
+});
 
 const hasChanges = computed(
-  () => !!newAvatarFile.value || hasUpdatedFields.value,
+  () =>
+    !!newAvatarFile.value ||
+    hasUpdatedFields.value,
 );
 
-const { loading, validationErrors, execute } = useSubmitHandler({
-  formRef,
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => {
+    setUpdateFormAsOriginal();
+  },
   scrollFn: scrollToComponent,
-  onSuccess: () => setUpdateFormAsOriginal(),
 });
 
-function onAvatarChange(file) {
+const onAvatarChange = (file) => {
   newAvatarFile.value = file;
-}
+};
 
-async function onSave() {
+const onSave = async () => {
   await execute(async () => {
     if (newAvatarFile.value) {
       const formData = new FormData();
 
-      formData.append("avatar", newAvatarFile.value);
+      formData.append(
+        "avatar",
+        newAvatarFile.value,
+      );
 
       await updateUnitMe(formData);
 
       newAvatarFile.value = null;
     }
 
+    const changedFields = {
+      ...getUpdatedFields.value,
+    };
+
+    delete changedFields.password;
+    delete changedFields.password_confirmation;
+
+    if (Object.keys(changedFields).length) {
+      await updateUnitMe(changedFields);
+    }
+
     if (form.password) {
-      await updateUserMe({ password: form.password });
+      await updateUserMe({
+        password: form.password,
+      });
 
       form.password = null;
-
       form.password_confirmation = null;
     }
   });
-}
+};
 
 onMounted(async () => {
   try {
     const unit = await getUnitMe();
-    form.value.name = unit.name ?? null;
-    const fantasyName = String(unit.fantasy_name ?? "").trim();
-    form.value.fantasy_name = ["null", "undefined"].includes(
-      fantasyName.toLowerCase(),
-    )
+
+    const fantasyName = String(
+      unit.fantasy_name ?? "",
+    ).trim();
+
+    form.address_number = unit.address_number;
+    form.cell_number = unit.cell_number;
+    form.cnpj = unit.cnpj;
+    form.complement = unit.complement;
+    form.email = unit.email;
+
+    form.fantasy_name = [
+      "null",
+      "undefined",
+    ].includes(fantasyName.toLowerCase())
       ? ""
       : fantasyName;
-    form.value.social_reason = unit.social_reason;
-    form.value.cnpj = unit.cnpj;
-    form.value.state_registration = unit.state_registration;
-    form.value.name_responsible = unit.name_responsible;
-    form.value.street = unit.street;
-    form.value.address_number = unit.address_number;
-    form.value.postal_code = unit.postal_code;
-    form.value.neighborhood = unit.neighborhood;
-    form.value.complement = unit.complement;
-    form.value.email = unit.email;
-    form.value.secondary_email = unit.secondary_email;
-    form.value.phone_number = unit.phone_number;
-    form.value.cell_number = unit.cell_number;
-    stateName.value = unit.state?.name ?? "";
+
+    form.name = unit.name ?? null;
+    form.name_responsible = unit.name_responsible;
+    form.neighborhood = unit.neighborhood;
+    form.phone_number = unit.phone_number;
+    form.postal_code = unit.postal_code;
+    form.secondary_email = unit.secondary_email;
+    form.social_reason = unit.social_reason;
+
+    form.state_registration =
+      unit.state_registration;
+
+    form.street = unit.street;
+
     cityName.value = unit.city?.name ?? "";
+    stateName.value = unit.state?.name ?? "";
+
     if (unit.avatar_url) {
-      avatarRef.value?.setImageUrl(unit.avatar_url);
+      avatarRef.value?.setImageUrl(
+        unit.avatar_url,
+      );
     }
+
     setUpdateFormAsOriginal();
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   }
 });
 </script>

+ 255 - 88
src/pages/users/UserActionPage.vue

@@ -1,103 +1,129 @@
 <template>
   <div>
-    <DefaultHeaderPage :title="isEdit ? 'Editar Usuário' : 'Cadastro de Usuário'" />
+    <DefaultHeaderPage
+      :title="
+        isEdit
+          ? 'Editar Usuário'
+          : 'Cadastro de Usuário'
+      "
+    />
 
     <div class="q-pa-md">
-      <DefaultForm ref="formRef">
+      <DefaultForm
+        ref="formRef"
+        @submit="onSave"
+      >
         <div class="column items-center q-mb-lg">
           <AvatarImageComponent
             ref="avatarRef"
-            @update:file="(f) => (avatarFile = f)"
+            @update:file="avatarFile = $event"
           />
         </div>
 
         <div class="row q-col-gutter-sm">
           <DefaultInput
             v-model="form.name"
-            :error="!!validationErrors.name"
-            :error-message="validationErrors.name"
-            label="Nome completo"
             class="col-6"
+            label="Nome completo"
             outlined
+            :error="!!validationErrors.name"
+            :error-message="validationErrors.name"
             :rules="[inputRules.required]"
           />
 
           <DefaultInput
             v-model="form.cpf"
-            :error="!!validationErrors.cpf"
-            :error-message="validationErrors.cpf"
-            label="CPF"
             class="col-6"
+            label="CPF"
             outlined
+            :error="!!validationErrors.cpf"
+            :error-message="validationErrors.cpf"
             :mask="masks.Brasil.cpf"
             :rules="[inputRules.cpf]"
           />
 
           <UserTypeSelect
             v-model="form.user_type"
-            :error="!!validationErrors.user_type"
-            :error-message="validationErrors.user_type"
             class="col-6"
-            outlined
             label="Função"
-            :rules="[inputRules.required]"
+            outlined
             :disable="!canChangeUserType"
+            :error="!!validationErrors.user_type"
+            :error-message="validationErrors.user_type"
             :exclude-types="['ADMIN']"
+            :rules="[inputRules.required]"
           />
 
           <DefaultInput
             v-model="form.phone"
-            :error="!!validationErrors.phone"
-            :error-message="validationErrors.phone"
-            label="Telefone"
             class="col-6"
+            label="Telefone"
             outlined
+            :error="!!validationErrors.phone"
+            :error-message="validationErrors.phone"
             :mask="masks.Brasil.celular"
           />
 
           <DefaultInput
             v-model="form.email"
-            :error="!!validationErrors.email"
-            :error-message="validationErrors.email"
-            label="E-mail"
             class="col-6"
+            label="E-mail"
             outlined
             type="email"
-            :rules="[inputRules.required, inputRules.email]"
+            :error="!!validationErrors.email"
+            :error-message="validationErrors.email"
+            :rules="[
+              inputRules.required,
+              inputRules.email,
+            ]"
           />
 
           <DefaultPasswordInput
             v-model="form.password"
-            :error="!!validationErrors.password"
-            :error-message="validationErrors.password"
-            label="Senha"
             class="col-6"
+            label="Senha"
             outlined
+            :error="!!validationErrors.password"
+            :error-message="validationErrors.password"
             :rules="
               isEdit
-                ? [(v) => !v || v.length >= 8 || 'Mínimo 8 caracteres']
-                : [inputRules.required, inputRules.min(8)]
+                ? [
+                    (value) =>
+                      !value ||
+                      value.length >= 8 ||
+                      'Mínimo 8 caracteres',
+                  ]
+                : [
+                    inputRules.required,
+                    inputRules.min(8),
+                  ]
             "
           />
 
-          <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>
 
         <div class="row justify-end q-mt-lg q-gutter-sm">
           <q-btn
-            label="Cancelar"
             color="primary"
+            label="Cancelar"
+            no-caps
             outline
             @click="router.push({ name: 'UsersPage' })"
           />
+
           <q-btn
             v-if="canSave"
-            label="Salvar"
             color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
             :loading="loading"
-            @click="onSave"
           />
         </div>
       </DefaultForm>
@@ -106,101 +132,242 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
-import { useRouter, useRoute } from "vue-router";
+import {
+  computed,
+  onMounted,
+  ref,
+} from "vue";
+
+import {
+  createUser,
+  getUserById,
+  updateUser,
+} from "src/api/user";
+
+import { permissionStore } from "src/stores/permission";
 import { storeToRefs } from "pinia";
-import { useQuasar } from "quasar";
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
-import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
-import UserTypeSelect from "src/components/selects/UserTypeSelect.vue";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
+import { useQuasar } from "quasar";
+
+import {
+  useRoute,
+  useRouter,
+} from "vue-router";
+
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createUser, updateUser, getUserById } from "src/api/user";
 import { userStore } from "src/stores/user";
-import { permissionStore } from "src/stores/permission";
+
 import masks from "src/helpers/masks";
 
-const router = useRouter();
-const route = useRoute();
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+
+import UserTypeSelect from "src/components/selects/UserTypeSelect.vue";
+
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
 const $q = useQuasar();
+const route = useRoute();
+const router = useRouter();
+
 const { inputRules } = useInputRules();
-const store = userStore();
+
 const permissions = permissionStore();
+const store = userStore();
+
 const { user } = storeToRefs(store);
 
-const formRef = ref(null);
 const avatarRef = ref(null);
+const formRef = ref(null);
+
 const avatarFile = ref(null);
 const originalUserType = ref(null);
 
-const isEdit = computed(() => !!route.params.id);
+const canChangeUserType = computed(
+  () =>
+    user.value?.user_type === "ADMIN_FRANCHISEE",
+);
+
 const canSave = computed(() =>
-  permissions.getAccess("franchisee_users", isEdit.value ? "edit" : "add"),
+  permissions.getAccess(
+    "franchisee_users",
+    isEdit.value ? "edit" : "add",
+  ),
 );
-const isEditingSelf = computed(() => isEdit.value && parseInt(route.params.id) === user.value?.id);
-const canChangeUserType = computed(() => user.value?.user_type === "ADMIN_FRANCHISEE");
 
-const form = ref({
-  name: null,
+const isEditingSelf = computed(
+  () =>
+    isEdit.value &&
+    Number.parseInt(route.params.id) ===
+      user.value?.id,
+);
+
+const isEdit = computed(
+  () => !!route.params.id,
+);
+
+const {
+  form,
+  getUpdatedFields,
+  setUpdateFormAsOriginal,
+} = useForm({
   cpf: null,
-  phone: null,
-  user_type: null,
   email: null,
+  name: null,
   password: null,
+  phone: null,
+  user_type: null,
 });
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   formRef,
-  onSuccess: () => router.push({ name: "UsersPage" }),
+  onSuccess: () => {
+    router.push({
+      name: "UsersPage",
+    });
+  },
 });
 
-onMounted(async () => {
-  if (!isEdit.value) return;
-  try {
-    const data = await getUserById(route.params.id);
-    form.value.name = data.name;
-    form.value.email = data.email;
-    form.value.cpf = data.cpf;
-    form.value.phone = data.phone;
-    form.value.user_type = data.user_type;
-    originalUserType.value = data.user_type;
-    if (data.avatar_url) avatarRef.value?.setImageUrl(data.avatar_url);
-  } catch (error) {
-    console.error("Failed to load user:", error);
+const buildPayload = () => {
+  const formData = new FormData();
+
+  if (avatarFile.value) {
+    formData.append(
+      "avatar",
+      avatarFile.value,
+    );
   }
-});
 
-function buildPayload() {
-  const fd = new FormData();
+  if (store.selectedUnit?.id) {
+    formData.append(
+      "unit_id",
+      store.selectedUnit.id,
+    );
+  }
+
+  if (form.cpf) {
+    formData.append("cpf", form.cpf);
+  }
+
+  if (form.email) {
+    formData.append("email", form.email);
+  }
+
+  if (form.name) {
+    formData.append("name", form.name);
+  }
+
+  if (form.password) {
+    formData.append(
+      "password",
+      form.password,
+    );
+  }
 
-  if (avatarFile.value) fd.append("avatar", avatarFile.value);
+  if (form.phone) {
+    formData.append("phone", form.phone);
+  }
 
-  if (store.selectedUnit?.id) fd.append("unit_id", store.selectedUnit.id);
-  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.phone) fd.append("phone", form.value.phone);
-  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);
+  if (form.user_type) {
+    formData.append(
+      "user_type",
+      form.user_type,
+    );
+  }
 
-  return fd;
-}
+  return formData;
+};
 
-async function onSave() {
-  if (isEditingSelf.value && form.value.user_type !== originalUserType.value) {
+const onSave = async () => {
+  if (
+    isEditingSelf.value &&
+    form.user_type !== originalUserType.value
+  ) {
     $q.notify({
+      message:
+        "Não é possível alterar o seu próprio tipo de usuário. Entre em contato com o suporte.",
       type: "warning",
-      message: "Não é possível alterar o seu próprio tipo de usuário. Entre em contato com o suporte.",
     });
+
+    return;
+  }
+
+  if (!isEdit.value) {
+    await execute(() =>
+      createUser(buildPayload()),
+    );
+
     return;
   }
 
-  if (isEdit.value) {
-    await execute(() => updateUser(buildPayload(), route.params.id));
-  } else {
-    await execute(() => createUser(buildPayload()));
+  await execute(() => {
+    const changedFields = {
+      ...getUpdatedFields.value,
+    };
+
+    const formData = new FormData();
+
+    for (const [key, value] of Object.entries(
+      changedFields,
+    )) {
+      if (
+        value !== null &&
+        value !== undefined &&
+        value !== ""
+      ) {
+        formData.append(key, value);
+      }
+    }
+
+    if (avatarFile.value) {
+      formData.append(
+        "avatar",
+        avatarFile.value,
+      );
+    }
+
+    return updateUser(
+      formData,
+      route.params.id,
+    );
+  });
+};
+
+onMounted(async () => {
+  if (!isEdit.value) return;
+
+  try {
+    const data = await getUserById(
+      route.params.id,
+    );
+
+    form.cpf = data.cpf;
+    form.email = data.email;
+    form.name = data.name;
+    form.phone = data.phone;
+    form.user_type = data.user_type;
+
+    originalUserType.value = data.user_type;
+
+    setUpdateFormAsOriginal();
+
+    if (data.avatar_url) {
+      avatarRef.value?.setImageUrl(
+        data.avatar_url,
+      );
+    }
+  } catch (error) {
+    console.error(
+      "Failed to load user:",
+      error,
+    );
   }
-}
-</script>
+});
+</script>

+ 143 - 88
src/pages/users/components/AddEditUserDialog.vue

@@ -1,104 +1,144 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin dialog-form-card" style="width: 800px">
-      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
-      <DefaultForm ref="formRef" @submit="onOKClick">
+    <q-card
+      class="q-dialog-plugin dialog-form-card"
+      style="width: 800px"
+    >
+      <DefaultDialogHeader
+        :title="title"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
         <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
           <q-card-section class="row q-col-gutter-sm q-pt-none">
-          <DefaultInput
-            v-model="form.name"
-            :error="!!validationErrors.name"
-            :error-message="validationErrors.name"
-            :rules="[inputRules.required]"
-            :label="$t('common.terms.name')"
-            :placeholder="$t('user.profile.name_and_surname')"
-            class="col-md-6 col-12"
-          />
-          <UserTypeSelect
-            v-model="selectedUserType"
-            :error="!!validationErrors.type"
-            :error-message="validationErrors.type"
-            :rules="[inputRules.required]"
-            :type="form.type"
-            :label="$t('common.ui.misc.type')"
-            :placeholder="'O tipo delimita as permissões'"
-            class="col-md-6 col-12"
-          />
-          <DefaultInput
-            v-model="form.email"
-            :error="!!validationErrors.email"
-            :error-message="validationErrors.email"
-            :rules="[inputRules.email, inputRules.required]"
-            label="Email"
-            :placeholder="'Ex. email@email.com'"
-            class="col-12"
-          />
-          <DefaultPasswordInput
-            v-model="form.password"
-            :error="!!validationErrors.password"
-            :error-message="validationErrors.password"
-            :rules="
-              user
-                ? [inputRules.password]
-                : [inputRules.required, inputRules.password]
-            "
-            :label="$t('common.terms.password')"
-            :placeholder="'Digite uma senha segura'"
-            class="col-md-6 col-12"
-          />
-          <DefaultPasswordInput
-            v-model="confirmPassword"
-            :error="!!validationErrors.password_confirmation"
-            :error-message="validationErrors.password_confirmation"
-            :rules="
-              user
-                ? [inputRules.samePassword(form.password)]
-                : [inputRules.required, inputRules.samePassword(form.password)]
-            "
-            :label="$t('auth.confirm_password')"
-            class="col-md-6 col-12"
-          />
+            <DefaultInput
+              v-model="form.name"
+              class="col-md-6 col-12"
+              :error="!!validationErrors.name"
+              :error-message="validationErrors.name"
+              :label="$t('common.terms.name')"
+              :placeholder="$t('user.profile.name_and_surname')"
+              :rules="[inputRules.required]"
+            />
+
+            <UserTypeSelect
+              v-model="selectedUserType"
+              class="col-md-6 col-12"
+              :error="!!validationErrors.type"
+              :error-message="validationErrors.type"
+              :label="$t('common.ui.misc.type')"
+              :placeholder="'O tipo delimita as permissões'"
+              :rules="[inputRules.required]"
+              :type="form.type"
+            />
+
+            <DefaultInput
+              v-model="form.email"
+              class="col-12"
+              label="Email"
+              :error="!!validationErrors.email"
+              :error-message="validationErrors.email"
+              :placeholder="'Ex. email@email.com'"
+              :rules="[
+                inputRules.email,
+                inputRules.required,
+              ]"
+            />
+
+            <DefaultPasswordInput
+              v-model="form.password"
+              class="col-md-6 col-12"
+              :error="!!validationErrors.password"
+              :error-message="validationErrors.password"
+              :label="$t('common.terms.password')"
+              :placeholder="'Digite uma senha segura'"
+              :rules="
+                user
+                  ? [inputRules.password]
+                  : [
+                      inputRules.required,
+                      inputRules.password,
+                    ]
+              "
+            />
+
+            <DefaultPasswordInput
+              v-model="confirmPassword"
+              class="col-md-6 col-12"
+              :error="!!validationErrors.password_confirmation"
+              :error-message="
+                validationErrors.password_confirmation
+              "
+              :label="$t('auth.confirm_password')"
+              :rules="
+                user
+                  ? [
+                      inputRules.samePassword(
+                        form.password,
+                      ),
+                    ]
+                  : [
+                      inputRules.required,
+                      inputRules.samePassword(
+                        form.password,
+                      ),
+                    ]
+              "
+            />
           </q-card-section>
         </q-scroll-area>
-        <q-card-actions>
-          <q-space />
+
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
           <q-btn
+            color="primary"
+            no-caps
             outline
-            color="negative"
             :label="$t('common.actions.cancel')"
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
-            :label="user ? $t('common.actions.save') : $t('common.actions.add')"
-            :type="'submit'"
-            :loading="loading"
+            no-caps
+            type="submit"
             :disable="!hasUpdatedFields"
+            :label="
+              user
+                ? $t('common.actions.save')
+                : $t('common.actions.add')
+            "
+            :loading="loading"
           />
         </q-card-actions>
       </DefaultForm>
     </q-card>
   </q-dialog>
 </template>
+
 <script setup>
+import { createUser, updateUser } from "src/api/user";
 import { ref, useTemplateRef, watch } from "vue";
-import { useInputRules } from "src/composables/useInputRules";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useI18n } from "vue-i18n";
-import { createUser, updateUser } from "src/api/user";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 import UserTypeSelect from "src/components/selects/UserTypeSelect.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 DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 
-defineEmits([
-  // REQUIRED; need to specify some events that your
-  // component will emit through useDialogPluginComponent()
-  ...useDialogPluginComponent.emits,
-]);
+defineEmits([...useDialogPluginComponent.emits]);
 
 const { user, title } = defineProps({
   user: {
@@ -111,41 +151,56 @@ const { user, title } = defineProps({
   },
 });
 
-const { inputRules } = useInputRules();
-
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
+const { inputRules } = useInputRules();
+
 const formRef = useTemplateRef("formRef");
 
-const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
-  name: user ? user?.name : "",
-  email: user ? user?.email : "",
-  type: user ? user?.type : "",
+const confirmPassword = ref("");
+const selectedUserType = ref(null);
+
+const {
+  form,
+  getUpdatedFields,
+  hasUpdatedFields,
+} = useForm({
+  email: user?.email ?? "",
+  name: user?.name ?? "",
   password: "",
+  type: user?.type ?? "",
 });
 
-const selectedUserType = ref(null);
-const confirmPassword = ref("");
-
 const {
   loading,
   validationErrors,
   execute: submitForm,
 } = useSubmitHandler({
-  onSuccess: () => onDialogOK(true),
-  formRef: formRef,
+  formRef,
+  onSuccess: () => {
+    onDialogOK(true);
+  },
 });
 
 const onOKClick = async () => {
   if (user) {
-    await submitForm(() => updateUser(getUpdatedFields.value, user.id));
-  } else {
-    await submitForm(() => createUser({ ...form }));
+    await submitForm(() =>
+      updateUser(
+        getUpdatedFields.value,
+        user.id,
+      ),
+    );
+
+    return;
   }
+
+  await submitForm(() =>
+    createUser({ ...form }),
+  );
 };
 
-watch(selectedUserType, () => {
-  form.type = selectedUserType.value.value;
+watch(selectedUserType, (selectedType) => {
+  form.type = selectedType?.value ?? "";
 });
 </script>

Some files were not shown because too many files changed in this diff