Преглед изворни кода

feat: modifica para jogar validacao dentro das tabs ao inves de jogar para o componente pai + endpoint de rascunho para student

Gustavo Mantovani пре 19 часа
родитељ
комит
30bcb8bf6e

+ 13 - 0
src/api/student.js

@@ -20,13 +20,26 @@ export const createStudent = async (student) => {
   return data.payload;
 };
 
+export const createStudentRegistrationDraft = async (student) => {
+  const { data } = await api.post(
+    "/franchisee/students/registration-drafts",
+    student,
+  );
+
+  return data.payload;
+};
+
 export const updateStudent = async (payload, id) => {
   if (payload instanceof FormData) {
     payload.append('_method', 'PUT');
+
     const { data } = await api.post(`/franchisee/students/${id}`, payload);
+
     return data.payload;
   }
+
   const { data } = await api.put(`/franchisee/students/${id}`, payload);
+
   return data.payload;
 };
 

+ 11 - 3
src/components/shared/CustomTabComponent.vue

@@ -6,10 +6,13 @@
     <div
       v-for="tab in tabs"
       :key="tab.name"
-      class="col flex items-center justify-center cursor-pointer tab-item"
-      :class="activeTab === tab.name ? 'tab-active' : 'tab-inactive'"
+      class="col flex items-center justify-center tab-item"
+      :class="[
+        activeTab === tab.name ? 'tab-active' : 'tab-inactive',
+        tab.disable ? 'tab-disabled' : 'cursor-pointer',
+      ]"
       style="height: 36px; border-radius: 6px"
-      @click="emit('update:activeTab', tab.name)"
+      @click="!tab.disable && emit('update:activeTab', tab.name)"
     >
       <span class="text-center text-weight-medium" style="font-size: 13px">
         {{ tab.label }}
@@ -57,4 +60,9 @@ const emit = defineEmits(["update:activeTab"]);
 .tab-inactive:hover {
   background-color: rgba(0, 0, 0, 0.06);
 }
+
+.tab-disabled {
+  cursor: not-allowed;
+  opacity: 0.55;
+}
 </style>

+ 144 - 94
src/composables/useScroll.js

@@ -1,119 +1,169 @@
 import { nextTick, unref } from "vue";
 
-export const useScroll = () => {
-  const getElement = (value) => {
-    const instance = unref(value);
-    return instance?.$el ?? instance ?? null;
-  };
+  export const useScroll = () => {
+    const resolveElement = (value) => {
+      const instance = unref(value);
+
+      return instance?.$el ?? instance ?? null;
+    };
+
+    const resolveContainer = (targetElement, containerRef) => {
+      const containerInstance = unref(containerRef);
+
+      if (typeof containerInstance?.setScrollPosition === "function") {
+        return {
+          instance: containerInstance,
+          element:
+            containerInstance.getScrollTarget?.() ??
+            containerInstance.$el?.querySelector?.(
+              ".q-scrollarea__container",
+            ) ??
+            containerInstance.$el,
+          isQuasar: true,
+        };
+      }
 
-  const getScrollableParent = (targetElement) => {
-    let parent = targetElement?.parentElement;
+      const providedElement = resolveElement(containerRef);
 
-    while (parent) {
-      const { overflowY } = window.getComputedStyle(parent);
-      if (
-        ["auto", "scroll"].includes(overflowY) &&
-        parent.scrollHeight > parent.clientHeight
-      ) {
-        return parent;
-      }
-      parent = parent.parentElement;
-    }
+      const detectedElement = targetElement?.closest?.(
+        ".q-scrollarea__container, .scroll, .scroll-y",
+      );
 
-    return document.scrollingElement || document.documentElement;
-  };
+      const element =
+        providedElement ??
+        detectedElement ??
+        document.scrollingElement ??
+        document.documentElement;
 
-  /**
-   * Scrolls to a target element within a scroll container.
-   * The function is agnostic to whether the container is a QScrollArea or a native DOM element.
-   *
-   * @param {Ref<Component|HTMLElement>} targetRef - Ref to the target component or DOM element.
-   * @param {Ref<Component|HTMLElement>} containerRef - Ref to the container (QScrollArea or a scrollable DOM element like body).
-   * @param {Object} options - Scroll options.
-   * @param {number} options.offset - Offset from the top in pixels (default: 50).
-   * @param {number} options.duration - Animation duration for QScrollArea (default: 150). Note: duration is not supported for native scrolling.
-   * @returns {boolean} - Whether the scroll was successful.
-   */
-  const scrollToComponent = (targetRef, containerRef, options = {}) => {
-    const { offset = 150, duration = 150 } = options;
-
-    const targetInstance = unref(targetRef);
-    const containerInstance = unref(containerRef);
-    const targetElement = getElement(targetInstance);
-    const containerElement =
-      getElement(containerInstance) || getScrollableParent(targetElement);
-
-    if (!targetElement || !containerElement) {
-      console.warn("useScroll: Target or container element not found");
-      return false;
-    }
+      return {
+        instance: element,
+        element,
+        isQuasar: false,
+      };
+    };
 
-    try {
+    const calculatePosition = (targetElement, containerElement, offset) => {
       const targetRect = targetElement.getBoundingClientRect();
+
+      const isDocumentContainer =
+        containerElement === document.body ||
+        containerElement === document.documentElement ||
+        containerElement === document.scrollingElement;
+
+      if (isDocumentContainer) {
+        return Math.max(
+          0,
+          targetRect.top + window.scrollY - offset,
+        );
+      }
+
       const containerRect = containerElement.getBoundingClientRect();
-      const targetPosition = Math.max(
+
+      return Math.max(
         0,
-        targetRect.top - containerRect.top + containerElement.scrollTop - offset,
+        targetRect.top -
+          containerRect.top +
+          containerElement.scrollTop -
+          offset,
       );
+    };
 
-      if (typeof containerInstance.setScrollPosition === "function") {
-        containerInstance.setScrollPosition(
-          "vertical",
-          targetPosition,
-          duration,
-        );
-      } else {
-        containerElement.scrollTo({
-          top: targetPosition,
-          behavior: "smooth",
-        });
+    const scrollToComponent = (targetRef, containerRef, options = {}) => {
+      const { offset = 150, duration = 150, behavior = "smooth" } = options;
+
+      const targetElement = resolveElement(targetRef);
+
+      if (!targetElement) {
+        console.warn("useScroll: Target element not found");
+        return false;
       }
 
-      return true;
-    } catch (error) {
-      console.error("useScroll: Error while scrolling", error);
-      return false;
-    }
-  };
+      const container = resolveContainer(targetElement, containerRef);
 
-  const scrollToFirstError = async (formRef, containerRef, options = {}) => {
-    await nextTick();
-    const forms = unref(formRef);
-    const formList = Array.isArray(forms) ? forms : [forms];
+      const containerElement = container.element;
 
-    for (const form of formList) {
-      const components = form?.getValidationComponents?.() ?? [];
-      const firstError = components.find((component) => component.hasError);
+      if (!containerElement) {
+        console.warn("useScroll: Container element not found");
+
+        return false;
+      }
+
+      const isDocumentContainer =
+        containerElement === document.body ||
+        containerElement === document.documentElement ||
+        containerElement === document.scrollingElement;
+
+      if (
+        !isDocumentContainer &&
+        !containerElement.contains(targetElement)
+      ) {
+        console.warn("useScroll: Target is not inside the container");
+        return false;
+      }
+
+      try {
+        const targetPosition = calculatePosition(
+          targetElement,
+          containerElement,
+          offset,
+        );
+
+        if (container.isQuasar) {
+          container.instance.setScrollPosition(
+            "vertical",
+            targetPosition,
+            duration,
+          );
+        } else if (isDocumentContainer) {
+          window.scrollTo({
+            top: targetPosition,
+            behavior,
+          });
+        } else {
+          containerElement.scrollTo({
+            top: targetPosition,
+            behavior,
+          });
+        }
 
-      if (firstError) {
-        firstError.focus?.();
-        const scrollFn = options.scrollFn || scrollToComponent;
-        scrollFn(firstError, unref(containerRef), options);
         return true;
+      } catch (error) {
+        console.error("useScroll: Error while scrolling", error);
+        return false;
       }
-    }
+    };
 
-    return false;
-  };
+    const scrollToFirstError = async (
+      formRef,
+      containerRef,
+      options = {},
+    ) => {
+      await nextTick();
 
-  const validateAndScroll = async (formRef, containerRef, options = {}) => {
-    const forms = unref(formRef);
-    const formList = (Array.isArray(forms) ? forms : [forms]).filter(Boolean);
-    const results = await Promise.all(
-      formList.map((form) => form.validate?.(true) ?? true),
-    );
-    const valid = results.every(Boolean);
+      const forms = unref(formRef);
 
-    if (!valid) {
-      await scrollToFirstError(formRef, containerRef, options);
-    }
+      const formInstances = Array.isArray(forms) ? forms : [forms];
 
-    return valid;
-  };
+      for (const formInstance of formInstances) {
+        const formElement = resolveElement(formInstance);
+
+        const errorElement =
+          formElement?.matches?.(".q-field--error")
+            ? formElement
+            : formElement?.querySelector?.(".q-field--error");
+
+        if (errorElement) {
+          const scrollFn = options.scrollFn ?? scrollToComponent;
+
+          return scrollFn(errorElement, containerRef, options);
+        }
+      }
+
+      return false;
+    };
 
-  return {
-    scrollToComponent,
-    scrollToFirstError,
-    validateAndScroll,
+    return {
+      scrollToComponent,
+      scrollToFirstError,
+    };
   };
-};

+ 28 - 0
src/helpers/utils.js

@@ -124,6 +124,33 @@ const formatUnitName = (unit, fallback = "Unidade sem nome") =>
   normalizeUnitName(unit?.social_reason) ||
   fallback;
 
+const isUnderage = (birthDate, referenceDate = new Date()) => {
+  if (!birthDate || typeof birthDate !== "string") return false;
+
+  const normalizedDate = birthDate.trim().split(" ")[0];
+
+  const parts = normalizedDate.includes("/")
+    ? normalizedDate.split("/").reverse()
+    : normalizedDate.split("-");
+
+  const [year, month, day] = parts.map(Number);
+
+  if (!year || !month || !day) return false;
+
+  const parsedBirthDate = new Date(year, month - 1, day);
+
+  const isValidDate =
+    parsedBirthDate.getFullYear() === year &&
+    parsedBirthDate.getMonth() === month - 1 &&
+    parsedBirthDate.getDate() === day;
+
+  if (!isValidDate) return false;
+
+  const eighteenthBirthday = new Date(year + 18, month - 1, day);
+
+  return eighteenthBirthday > referenceDate;
+};
+
 export {
   formatDateDMYtoYMD,
   formatDateYMDtoDMY,
@@ -132,4 +159,5 @@ export {
   formatToBRLCurrency,
   normalizeString,
   formatUnitName,
+  isUnderage,
 };

+ 1 - 0
src/pages/students/components/AddEditContractDialog.vue

@@ -115,6 +115,7 @@
               label="CPF"
               disable
               :mask="masks.Brasil.cpf"
+              :rules="[inputRules.cpf]"
             />
           </div>
 

+ 583 - 191
src/pages/students/components/AddEditStudentDialog.vue

@@ -7,166 +7,342 @@
       <DefaultDialogHeader title="Cadastrar Aluno" @close="onDialogCancel" />
 
       <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="dialog-form-scroll">
-          <q-card-section class="q-pt-none">
-          <div class="flex justify-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"
-              label="Nome do Aluno"
-              class="col-6"
-              :rules="[inputRules.required]"
-            />
-
-            <DefaultInputDatePicker
-              v-model="form.birthdate"
-              :error="!!validationErrors.birth_date"
-              :error-message="validationErrors.birth_date"
-              label="Data de Nascimento"
-              class="col-6"
-            />
-
-            <DefaultInput
-              v-model="form.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
-              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="Celular com DDD"
-              class="col-6"
-              :mask="masks.Brasil.celular"
-            />
-
-            <DefaultCepInput
-              v-model="form.cep"
-              :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)"
-            />
-
-            <DefaultInput
-              v-model="form.address"
-              :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"
-            />
-
-            <StateSelect
-              ref="stateSelectRef"
-              v-model="selectedState"
-              :error="!!validationErrors.state_id"
-              :error-message="validationErrors.state_id"
-              label="Estado"
-              class="col-4"
-            />
-
-            <DefaultInput
-              v-model="form.complement"
-              :error="!!validationErrors.complement"
-              :error-message="validationErrors.complement"
-              label="Complemento"
-              class="col-6"
-            />
-
-            <DefaultInput
-              v-model="form.payer"
-              label="Pagador"
-              class="col-6"
-              :error="!!validationErrors.payer_name"
-              :error-message="validationErrors.payer_name"
-            />
-
-            <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
-              map-options
-              :options="howFoundOptions"
+        <q-scroll-area ref="scrollAreaRef" class="dialog-form-scroll">
+          <q-card-section class="q-pt-sm">
+            <CustomTabComponent
+              :active-tab="activeTab"
+              :tabs="tabs"
+              class="q-mb-md"
+              @update:active-tab="handleTabChange"
             />
 
-            <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>
+            <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"
+              >
+                {{ validationErrors.registration_draft_token }}
+              </q-banner>
+
+              <div class="flex justify-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"
+                  label="Nome do Aluno"
+                  class="col-6"
+                  :rules="[inputRules.required]"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="form.birthdate"
+                  :error="!!validationErrors.birth_date"
+                  :error-message="validationErrors.birth_date"
+                  label="Data de Nascimento"
+                  class="col-6"
+                  :rules="[inputRules.required]"
+                />
+
+                <DefaultInput
+                  v-model="form.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
+                  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="Celular com DDD"
+                  class="col-6"
+                  :mask="masks.Brasil.celular"
+                />
+
+                <DefaultCepInput
+                  v-model="form.cep"
+                  :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)"
+                />
+
+                <DefaultInput
+                  v-model="form.address"
+                  :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"
+                />
+
+                <StateSelect
+                  ref="stateSelectRef"
+                  v-model="selectedState"
+                  :error="!!validationErrors.state_id"
+                  :error-message="validationErrors.state_id"
+                  label="Estado"
+                  class="col-4"
+                />
+
+                <DefaultInput
+                  v-model="form.complement"
+                  :error="!!validationErrors.complement"
+                  :error-message="validationErrors.complement"
+                  label="Complemento"
+                  class="col-6"
+                />
+
+                <DefaultInput
+                  v-model="form.payer"
+                  :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
+                  map-options
+                  :options="howFoundOptions"
+                />
+
+                <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>
+            </div>
+
+            <div
+              v-if="isStudentUnderage"
+              v-show="activeTab === 'responsible'"
+              ref="responsibleTabRef"
+            >
+              <q-banner rounded class="bg-orange-1 text-orange-10 q-mb-md">
+                O aluno é menor de 18 anos. Cadastre um responsável para
+                concluir.
+              </q-banner>
+
+              <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"
+                  :rules="
+                    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"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="responsibleForm.birth_date"
+                  :error="!!validationErrors['responsible.birth_date']"
+                  :error-message="validationErrors['responsible.birth_date']"
+                  label="Data de Nascimento"
+                  class="col-4"
+                />
+
+                <DefaultInput
+                  v-model="responsibleForm.cpf"
+                  :error="!!validationErrors['responsible.cpf']"
+                  :error-message="validationErrors['responsible.cpf']"
+                  label="CPF"
+                  class="col-4"
+                  :mask="masks.Brasil.cpf"
+                  :rules="
+                    activeTab === 'responsible'
+                      ? [inputRules.required, inputRules.cpf]
+                      : []
+                  "
+                />
+
+                <DefaultSelect
+                  v-model="responsibleForm.gender"
+                  :error="!!validationErrors['responsible.gender']"
+                  :error-message="validationErrors['responsible.gender']"
+                  label="Gênero"
+                  class="col-4"
+                  emit-value
+                  map-options
+                  :options="genderOptions"
+                />
+
+                <DefaultInput
+                  v-model="responsibleForm.email"
+                  :error="!!validationErrors['responsible.email']"
+                  :error-message="validationErrors['responsible.email']"
+                  label="E-mail"
+                  class="col-6"
+                  type="email"
+                  :rules="[inputRules.email]"
+                />
+
+                <DefaultInput
+                  v-model="responsibleForm.phone"
+                  :error="!!validationErrors['responsible.phone']"
+                  :error-message="validationErrors['responsible.phone']"
+                  label="Telefone"
+                  class="col-6"
+                  :mask="masks.Brasil.celular"
+                />
+
+                <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)"
+                />
+
+                <DefaultInput
+                  v-model="responsibleForm.street"
+                  :error="!!validationErrors['responsible.street']"
+                  :error-message="validationErrors['responsible.street']"
+                  label="Endereço"
+                  class="col-5"
+                />
+
+                <DefaultInput
+                  v-model="responsibleForm.address_number"
+                  :error="!!validationErrors['responsible.address_number']"
+                  :error-message="validationErrors['responsible.address_number']"
+                  label="Número"
+                  class="col-3"
+                />
+
+                <DefaultInput
+                  v-model="responsibleForm.neighborhood"
+                  :error="!!validationErrors['responsible.neighborhood']"
+                  :error-message="validationErrors['responsible.neighborhood']"
+                  label="Bairro"
+                  class="col-4"
+                />
+
+                <CitySelect
+                  ref="responsibleCitySelectRef"
+                  v-model="responsibleSelectedCity"
+                  :error="!!validationErrors['responsible.city_id']"
+                  :error-message="validationErrors['responsible.city_id']"
+                  label="Cidade"
+                  class="col-4"
+                  :state="responsibleSelectedState"
+                />
+
+                <StateSelect
+                  ref="responsibleStateSelectRef"
+                  v-model="responsibleSelectedState"
+                  :error="!!validationErrors['responsible.state_id']"
+                  :error-message="validationErrors['responsible.state_id']"
+                  label="Estado"
+                  class="col-4"
+                />
+
+                <DefaultInput
+                  v-model="responsibleForm.complement"
+                  :error="!!validationErrors['responsible.complement']"
+                  :error-message="validationErrors['responsible.complement']"
+                  label="Complemento"
+                  class="col-12"
+                />
+
+                <DefaultInput
+                  v-model="responsibleForm.notes"
+                  :error="!!validationErrors['responsible.notes']"
+                  :error-message="validationErrors['responsible.notes']"
+                  label="Observações"
+                  class="col-12"
+                  type="textarea"
+                  :input-style="{ minHeight: '120px' }"
+                  autogrow
+                />
+              </div>
+            </div>
           </q-card-section>
         </q-scroll-area>
 
@@ -179,7 +355,7 @@
           />
           <q-btn
             color="primary"
-            label="CADASTRAR"
+            :label="primaryActionLabel"
             type="submit"
             :loading="loading"
           />
@@ -190,7 +366,7 @@
 </template>
 
 <script setup>
-import { ref, watch, useTemplateRef } from "vue";
+import { computed, nextTick, ref, watch, useTemplateRef } from "vue";
 import { useDialogPluginComponent } from "quasar";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
@@ -199,13 +375,18 @@ 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 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 } from "src/api/student";
+import {
+  createStudent,
+  createStudentRegistrationDraft,
+} from "src/api/student";
 import masks from "src/helpers/masks";
-import { formatDateDMYtoYMD } from "src/helpers/utils";
+import { formatDateDMYtoYMD, isUnderage } from "src/helpers/utils";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -215,12 +396,29 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
 const { inputRules } = useInputRules();
 
 const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+const studentTabRef = useTemplateRef("studentTabRef");
+const responsibleTabRef = useTemplateRef("responsibleTabRef");
 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 avatarFile = ref(null);
 const selectedState = ref(null);
 const selectedCity = ref(null);
+const responsibleSelectedState = 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 form = ref({
   name: null,
@@ -241,6 +439,37 @@ const form = ref({
   notes: null,
 });
 
+const responsibleForm = ref({
+  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,
+  city_id: null,
+  state_id: null,
+  complement: null,
+  notes: null,
+});
+
+const isStudentUnderage = computed(() => isUnderage(form.value.birthdate));
+const tabs = computed(() => [
+  { name: "student", label: "Dados do Aluno" },
+  ...(isStudentUnderage.value
+    ? [{ name: "responsible", label: "Responsável" }]
+    : []),
+]);
+const primaryActionLabel = computed(() =>
+  isStudentUnderage.value && activeTab.value === "student"
+    ? "PRÓXIMO"
+    : "CADASTRAR",
+);
+
 watch(selectedState, (state) => {
   form.value.state_id = state?.value ?? null;
 });
@@ -249,6 +478,20 @@ 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";
+  }
+});
+
 const genderOptions = ref([
   { label: "Prefiro não informar", value: "no_preference" },
   { label: "Masculino", value: "male" },
@@ -263,46 +506,114 @@ const howFoundOptions = ref([
   { label: "Outro", value: "other" },
 ]);
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading: saving,
+  validationErrors: submitValidationErrors,
+  execute: executeSubmit,
+} = useSubmitHandler({
   formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToTabError,
   onSuccess: (student) => {
+    clearRegistrationDraftToken();
     onDialogOK(student);
   },
 });
 
+const {
+  loading: validatingStudent,
+  validationErrors: draftValidationErrors,
+  execute: executeDraft,
+} = useSubmitHandler({
+  formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToTabError,
+});
+
+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 buildPayload() {
-  if (avatarFile.value) {
-    const formData = new FormData();
-    formData.append("name", form.value.name ?? "");
-    if (form.value.birthdate) {
-      formData.append("birth_date", formatDateDMYtoYMD(form.value.birthdate));
-    }
-    formData.append("document_number", form.value.cpf ?? "");
-    formData.append("gender", form.value.gender ?? "");
-    formData.append("email", form.value.email ?? "");
-    formData.append("phone", form.value.phone ?? "");
-    formData.append("postal_code", form.value.cep ?? "");
-    formData.append("street", form.value.address ?? "");
-    formData.append("address_number", form.value.address_number ?? "");
-    formData.append("neighborhood", form.value.neighborhood ?? "");
-    if (form.value.state_id) {
-      formData.append("state_id", form.value.state_id);
-    }
-    if (form.value.city_id) {
-      formData.append("city_id", form.value.city_id);
-    }
-    formData.append("complement", form.value.complement ?? "");
-    formData.append("payer_name", form.value.payer ?? "");
-    formData.append("how_did_you_know_us", form.value.how_found ?? "");
-    formData.append("notes", form.value.notes ?? "");
-    formData.append("avatar", avatarFile.value);
-    return formData;
+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 responsible = buildResponsiblePayload();
+  Object.entries(responsible).forEach(([field, value]) => {
+    formData.append(`responsible[${field}]`, value ?? "");
+  });
+}
+
+function buildStudentPayload() {
   return {
     name: form.value.name,
     birth_date: form.value.birthdate
@@ -325,7 +636,88 @@ function buildPayload() {
   };
 }
 
+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 student = buildStudentPayload();
+  Object.entries(student).forEach(([field, value]) => {
+    formData.append(field, value ?? "");
+  });
+}
+
+function 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,
+      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 draft = await executeDraft(() =>
+    createStudentRegistrationDraft(buildRegistrationDraftPayload()),
+  );
+  const nextToken = draft?.registration_draft_token;
+
+  if (nextToken && nextToken !== registrationDraftToken.value) {
+    saveRegistrationDraftToken(nextToken);
+  }
+
+  await setActiveTab("responsible", true);
+}
+
+async function handleTabChange(tab) {
+  if (tab === activeTab.value) return;
+
+  if (tab === "responsible") {
+    await goToResponsible();
+    return;
+  }
+
+  await setActiveTab(tab, true);
+}
+
 async function onOKClick() {
-  await execute(() => createStudent(buildPayload()));
+  if (isStudentUnderage.value && activeTab.value === "student") {
+    await goToResponsible();
+    return;
+  }
+
+  await executeSubmit(() => createStudent(buildPayload()));
 }
 </script>

+ 24 - 2
src/pages/students/components/EditStudentDialog.vue

@@ -23,7 +23,11 @@
         </div>
 
         <div v-show="currentTab === 'responsible'">
-          <ResponsibleTab :student-id="props.student.id" />
+          <ResponsibleTab
+            ref="responsibleTabRef"
+            :student-id="props.student.id"
+            :required="studentIsUnderage"
+          />
         </div>
 
         <div v-if="canViewContracts" v-show="currentTab === 'contracts'">
@@ -62,12 +66,13 @@
 
 <script setup>
 import { computed, ref, watch, useTemplateRef, defineAsyncComponent } from "vue";
-import { useDialogPluginComponent } from "quasar";
+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"),
@@ -98,9 +103,12 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
 const studentDataTabRef = useTemplateRef("studentDataTabRef");
+const responsibleTabRef = useTemplateRef("responsibleTabRef");
 const mediaTabRef = useTemplateRef("mediaTabRef");
 
+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"),
@@ -133,6 +141,20 @@ async function handleSave() {
   const valid = await (studentDataTabRef.value?.validate() ?? true);
   if (!valid) return;
 
+  const underage = studentDataTabRef.value?.isUnderage() ?? false;
+  const hasRequiredResponsible = underage
+    ? await responsibleTabRef.value?.validateRequired(true)
+    : true;
+
+  if (!hasRequiredResponsible) {
+    currentTab.value = "responsible";
+    $q.notify({
+      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));
 }

+ 0 - 3
src/pages/students/components/ResponsibleDialog.vue

@@ -28,7 +28,6 @@
               :error-message="validationErrors.degree"
               label="Grau de Parentesco"
               class="col-6"
-              :rules="[inputRules.required]"
             />
 
             <DefaultInputDatePicker
@@ -37,7 +36,6 @@
               :error-message="validationErrors.birth_date"
               label="Data de Nascimento"
               class="col-4"
-              :rules="[inputRules.required]"
             />
 
             <DefaultInput
@@ -78,7 +76,6 @@
               label="Telefone"
               class="col-6"
               :mask="masks.Brasil.celular"
-              :rules="[inputRules.required]"
             />
 
             <DefaultCepInput

+ 3 - 0
src/pages/students/components/ViewContractDialog.vue

@@ -41,6 +41,7 @@
                   label="CPF"
                   disable
                   :mask="masks.Brasil.cpf"
+                  :rules="[inputRules.cpf]"
                 />
               </div>
 
@@ -197,6 +198,7 @@ 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";
 
@@ -210,6 +212,7 @@ const props = defineProps({
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+const { inputRules } = useInputRules();
 
 const contract = ref(null);
 const loading = ref(false);

+ 34 - 1
src/pages/students/tabs/ResponsibleTab.vue

@@ -1,5 +1,13 @@
 <template>
   <div>
+    <q-banner
+      v-if="required && !loading && rows.length === 0"
+      rounded
+      class="bg-orange-1 text-orange-10 q-mb-md"
+    >
+      Este aluno é menor de idade e precisa ter ao menos um responsável.
+    </q-banner>
+
     <DefaultTable
       v-model:rows="rows"
       title="Responsáveis"
@@ -51,6 +59,10 @@ const props = defineProps({
     type: Number,
     required: true,
   },
+  required: {
+    type: Boolean,
+    default: false,
+  },
 });
 
 const $q = useQuasar();
@@ -59,6 +71,7 @@ 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 columns = ref([
   { name: "name", label: "Nome", field: "name", align: "left" },
@@ -74,7 +87,12 @@ const columns = ref([
 ]);
 
 async function loadResponsibles() {
-  rows.value = await getStudentResponsible(props.studentId);
+  loading.value = true;
+  try {
+    rows.value = await getStudentResponsible(props.studentId);
+  } finally {
+    loading.value = false;
+  }
 }
 
 onMounted(loadResponsibles);
@@ -95,6 +113,14 @@ function handleEdit(responsible) {
 }
 
 function handleDelete(responsible) {
+  if (props.required && rows.value.length === 1) {
+    $q.notify({
+      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}"?`,
@@ -110,4 +136,11 @@ function handleDelete(responsible) {
     }
   });
 }
+
+async function validateRequired(required = props.required) {
+  await loadResponsibles();
+  return !required || rows.value.length > 0;
+}
+
+defineExpose({ validateRequired });
 </script>

+ 7 - 1
src/pages/students/tabs/StudentDataTab.vue

@@ -20,6 +20,7 @@
         :error-message="validationErrors.birth_date"
         label="Data de Nascimento"
         class="col-6"
+        :rules="[inputRules.required]"
       />
 
       <DefaultInput
@@ -170,7 +171,11 @@ import StateSelect from "src/components/selects/StateSelect.vue";
 import CitySelect from "src/components/selects/CitySelect.vue";
 import { useInputRules } from "src/composables/useInputRules";
 import masks from "src/helpers/masks";
-import { formatDateYMDtoDMY, formatDateDMYtoYMD } from "src/helpers/utils";
+import {
+  formatDateYMDtoDMY,
+  formatDateDMYtoYMD,
+  isUnderage,
+} from "src/helpers/utils";
 
 const props = defineProps({
   student: {
@@ -301,5 +306,6 @@ function buildPayload() {
 defineExpose({
   validate: () => formRef.value?.validate(),
   buildPayload,
+  isUnderage: () => isUnderage(form.value.birthdate),
 });
 </script>

+ 0 - 25
src/pages/unit/UnitActionPage.vue

@@ -11,9 +11,6 @@
     <div v-if="unitId" :key="unitId">
       <UnitDataTab
         v-show="activeTab === 'unit_data'"
-        v-model:form="form"
-        :get-form-as-form-data="getFormAsFormData"
-        :set-update-form-as-original="setUpdateFormAsOriginal"
         :unit-id="unitId"
       />
 
@@ -36,7 +33,6 @@
 
 <script setup>
 import { computed, ref } from "vue";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 import { userStore } from "src/stores/user";
 import { permissionStore } from "src/stores/permission";
 
@@ -56,27 +52,6 @@ const canViewFinancial = computed(() =>
   permissions.getAccess("franchisee_financial", "view"),
 );
 
-const { form, getFormAsFormData, 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,
-    phone_number: null,
-    postal_code: null,
-    secondary_email: null,
-    social_reason: null,
-    state_id: null,
-    state_registration: null,
-    street: null,
-  });
-
 const activeTab = ref("unit_data");
 
 const unitId = computed(() => store.selectedUnit?.id ?? null);

+ 3 - 0
src/pages/unit/components/ViewContractDialog.vue

@@ -39,6 +39,7 @@
             label="CPF"
             label-color="secondary"
             :mask="masks.Brasil.cpf"
+            :rules="[inputRules.cpf]"
           />
 
           <DefaultInput
@@ -184,6 +185,7 @@ 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";
@@ -206,6 +208,7 @@ const props = defineProps({
 });
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+const { inputRules } = useInputRules();
 
 const loadingHistory = ref(false);
 const inhabitantOptions = ref([]);

+ 78 - 33
src/pages/unit/tabs/UnitDataTab.vue

@@ -1,7 +1,8 @@
 <template>
   <div class="q-pa-md">
-    <DefaultForm ref="formRef">
-      <div class="column justify-center items-center q-mb-lg">
+    <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">
@@ -184,46 +185,54 @@
           </div>
 
           <DefaultPasswordInput
-            v-model="password"
+            v-model="form.password"
             :error="!!validationErrors.password"
             :error-message="validationErrors.password"
             class="col-6"
             label="Nova Senha"
             outlined
-            :rules="password ? [inputRules.password] : []"
+            :rules="form.password ? [inputRules.password] : []"
           />
 
           <DefaultPasswordInput
-            v-model="passwordConfirmation"
+            v-model="form.password_confirmation"
             :error="!!validationErrors.password_confirmation"
             :error-message="validationErrors.password_confirmation"
             class="col-6"
             label="Confirmar Nova Senha"
             outlined
-            :rules="password ? [inputRules.samePassword(password)] : []"
+            :rules="
+              form.password
+                ? [inputRules.samePassword(form.password)]
+                : []
+            "
           />
         </div>
 
-        <div class="row justify-end q-mt-md items-end full-width q-px-xs">
-          <q-btn
-            v-if="canEdit"
-            color="primary-2"
-            label="Salvar"
-            :disable="!hasChanges"
-            :loading="loading"
-            @click="onSave"
-          />
         </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"
+          label="Salvar"
+          type="submit"
+          :disable="!hasChanges"
+          :loading="loading"
+        />
       </div>
     </DefaultForm>
   </div>
 </template>
 
 <script setup>
-import { computed, onMounted, ref } from "vue";
+import { computed, onMounted, ref, useTemplateRef } from "vue";
 import { getUnitMe, updateUnitMe } from "src/api/unit";
 import { updateUserMe } from "src/api/user.js";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import masks from "src/helpers/masks";
 
@@ -235,27 +244,56 @@ import { permissionStore } from "src/stores/permission";
 const permissions = permissionStore();
 const canEdit = computed(() => permissions.getAccess("franchisee_unit", "edit"));
 
-const props = defineProps({
-  getFormAsFormData: { type: Function, required: true },
-  setUpdateFormAsOriginal: { type: Function, required: true },
+defineProps({
   unitId: { type: Number, default: null },
 });
 
-const form = defineModel("form", { type: Object, required: true });
-
 const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
 
-const avatarRef = ref(null);
+const avatarRef = useTemplateRef("avatarRef");
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
 const cityName = ref("");
-const formRef = ref(null);
 const newAvatarFile = ref(null);
-const password = ref(null);
-const passwordConfirmation = ref(null);
 const stateName = ref("");
 
-const hasChanges = computed(() => !!newAvatarFile.value || !!password.value);
-
-const { loading, validationErrors, execute } = useSubmitHandler({ formRef });
+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 hasChanges = computed(
+  () => !!newAvatarFile.value || hasUpdatedFields.value,
+);
+
+const { loading, validationErrors, execute } = useSubmitHandler({
+  formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
+  onSuccess: () => setUpdateFormAsOriginal(),
+});
 
 function onAvatarChange(file) {
   newAvatarFile.value = file;
@@ -273,12 +311,12 @@ async function onSave() {
       newAvatarFile.value = null;
     }
 
-    if (password.value) {
-      await updateUserMe({ password: password.value });
+    if (form.password) {
+      await updateUserMe({ password: form.password });
 
-      password.value = null;
+      form.password = null;
 
-      passwordConfirmation.value = null;
+      form.password_confirmation = null;
     }
   });
 }
@@ -311,9 +349,16 @@ onMounted(async () => {
     if (unit.avatar_url) {
       avatarRef.value?.setImageUrl(unit.avatar_url);
     }
-    props.setUpdateFormAsOriginal();
+    setUpdateFormAsOriginal();
   } catch (e) {
     console.error(e);
   }
 });
 </script>
+
+<style scoped>
+.unit-data-scroll-area {
+  height: calc(100vh - 310px);
+  min-height: 420px;
+}
+</style>