Gustavo Mantovani hace 1 mes
padre
commit
c6b4d309ed

+ 27 - 0
src/api/studentContract.js

@@ -0,0 +1,27 @@
+import api from "src/api";
+
+export const getStudentContracts = async (studentId) => {
+  const { data } = await api.get("/student-contract", { params: { student_id: studentId } });
+  return data.payload;
+};
+
+export const createStudentContract = async (payload) => {
+  const { data } = await api.post("/student-contract", payload);
+  return data.payload;
+};
+
+export const updateStudentContract = async (id, payload) => {
+  const { data } = await api.put(`/student-contract/${id}`, payload);
+  return data.payload;
+};
+
+export const attachContractFile = async (id, formData) => {
+  const { data } = await api.post(`/student-contract/${id}/file`, formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteStudentContract = async (id) => {
+  await api.delete(`/student-contract/${id}`);
+};

+ 20 - 0
src/api/student_media.js

@@ -0,0 +1,20 @@
+import api from "src/api";
+
+export const getStudentMedias = async (studentId) => {
+  const { data } = await api.get("/media", {
+    params: { origem: "student", origem_id: studentId },
+  });
+  return data.payload;
+};
+
+export const createStudentMedia = async (formData) => {
+  const { data } = await api.post("/media", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteStudentMedia = async (id) => {
+  const { data } = await api.delete(`/media/${id}`);
+  return data;
+};

+ 15 - 15
src/components/defaults/DefaultInputDatePicker.vue

@@ -117,19 +117,8 @@ watch(date, (value) => {
   treatedDate.value = formatDate(value);
 });
 
-watch(treatedDate, (value) => {
-  if (!value) {
-    date.value = null;
-    untreatedDate.value = null;
-    treatedDate.value = null;
-    activePanel.value = "date";
-    return;
-  }
-  date.value = unformatDate(value);
-});
-
 watch(
-  untreatedDate,
+  treatedDate,
   (value) => {
     if (!value) {
       date.value = null;
@@ -138,9 +127,20 @@ watch(
       activePanel.value = "date";
       return;
     }
-    date.value = value;
-    treatedDate.value = formatDate(value);
+    date.value = unformatDate(value);
   },
-  { immediate: true }
+  { immediate: true },
 );
+
+watch(untreatedDate, (value) => {
+  if (!value) {
+    date.value = null;
+    untreatedDate.value = null;
+    treatedDate.value = null;
+    activePanel.value = "date";
+    return;
+  }
+  date.value = value;
+  treatedDate.value = formatDate(value);
+});
 </script>

+ 65 - 77
src/pages/students/components/AddEditStudentDialog.vue

@@ -2,62 +2,37 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card
       class="q-dialog-plugin overflow-hidden"
-      style="width: 900px; max-width: 95vw"
+      style="width: 100%; max-width: 1100px"
     >
-      <DefaultDialogHeader title="Cadastro de Aluno" @close="onDialogCancel" />
+      <DefaultDialogHeader title="Cadastrar Aluno" @close="onDialogCancel" />
 
       <q-form ref="formRef" @submit="onOKClick">
         <q-card-section class="q-pt-none">
-          <div class="text-subtitle2 q-mb-sm">Dados do Aluno</div>
+          <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"
-              label="Nome do aluno"
-              class="col-md-5 col-12"
+              label="Nome do Aluno"
+              class="col-6"
               :rules="[inputRules.required]"
             />
 
             <DefaultInputDatePicker
               v-model="form.birthdate"
               label="Data de Nascimento"
-              class="col-md-5 col-12"
+              class="col-6"
             />
 
-            <div class="col-md-2 col-12 flex justify-center items-start">
-              <div style="position: relative; display: inline-block">
-                <q-avatar size="72px" color="grey-3">
-                  <img v-if="avatarPreview" :src="avatarPreview" />
-                  <q-icon
-                    v-else
-                    name="mdi-account"
-                    size="42px"
-                    color="grey-6"
-                  />
-                </q-avatar>
-                <q-btn
-                  round
-                  dense
-                  color="primary"
-                  icon="mdi-camera"
-                  size="xs"
-                  style="position: absolute; bottom: 0; right: 0"
-                  @click="triggerFileInput"
-                />
-                <input
-                  ref="fileInputRef"
-                  type="file"
-                  accept="image/*"
-                  style="display: none"
-                  @change="onAvatarChange"
-                />
-              </div>
-            </div>
-
             <DefaultInput
               v-model="form.cpf"
-              label="CPF / CNH"
-              class="col-md-6 col-12"
+              label="CPF"
+              class="col-6"
               :mask="masks.Brasil.cpf"
               :rules="[inputRules.cpf]"
             />
@@ -65,7 +40,7 @@
             <DefaultSelect
               v-model="form.gender"
               label="Gênero"
-              class="col-md-6 col-12"
+              class="col-6"
               emit-value
               map-options
               :options="genderOptions"
@@ -74,7 +49,7 @@
             <DefaultInput
               v-model="form.email"
               label="E-mail"
-              class="col-md-6 col-12"
+              class="col-6"
               type="email"
               :rules="[inputRules.email]"
             />
@@ -82,59 +57,60 @@
             <DefaultInput
               v-model="form.phone"
               label="Celular com DDD"
-              class="col-md-6 col-12"
+              class="col-6"
               :mask="masks.Brasil.celular"
             />
 
             <DefaultCepInput
               v-model="form.cep"
-              class="col-md-3 col-12"
-              :disable="false"
+              class="col-4"
               :rules="[inputRules.cep]"
               @rua="(v) => (form.address = v)"
               @bairro="(v) => (form.neighborhood = v)"
               @uf="(v) => stateSelectRef?.selectStateByCode(v)"
-              @numero="() => {}"
-              @complemento="() => {}"
+              @cidade="(v) => citySelectRef?.selectCityByName(v)"
             />
 
             <DefaultInput
               v-model="form.address"
               label="Endereço"
-              class="col-md-6 col-12"
+              class="col-5"
             />
 
             <DefaultInput
               v-model="form.address_number"
               label="Número"
-              class="col-md-3 col-12"
+              class="col-3"
             />
 
             <DefaultInput
               v-model="form.neighborhood"
               label="Bairro"
-              class="col-md-6 col-12"
+              class="col-4"
+            />
+
+            <CitySelect
+              ref="citySelectRef"
+              v-model="selectedCity"
+              label="Cidade"
+              class="col-4"
+              :state="selectedState"
             />
 
             <StateSelect
               ref="stateSelectRef"
-              v-model="form.state"
+              v-model="selectedState"
               label="Estado"
-              class="col-md-6 col-12"
-              outlined
+              class="col-4"
             />
 
             <DefaultInput
               v-model="form.complement"
               label="Complemento"
-              class="col-md-6 col-12"
+              class="col-6"
             />
 
-            <DefaultInput
-              v-model="form.payer"
-              label="Pagador"
-              class="col-md-6 col-12"
-            />
+            <DefaultInput v-model="form.payer" label="Pagador" class="col-6" />
 
             <DefaultSelect
               v-model="form.how_found"
@@ -150,6 +126,7 @@
               label="Observações"
               class="col-12"
               type="textarea"
+              :input-style="{ minHeight: '120px' }"
               autogrow
             />
           </div>
@@ -175,7 +152,7 @@
 </template>
 
 <script setup>
-import { ref, useTemplateRef } from "vue";
+import { ref, watch, useTemplateRef } from "vue";
 import { useDialogPluginComponent } from "quasar";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
@@ -183,7 +160,9 @@ 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 AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
 import StateSelect from "src/components/selects/StateSelect.vue";
+import CitySelect from "src/components/selects/CitySelect.vue";
 import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import { createStudent } from "src/api/student";
@@ -198,10 +177,12 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
 const { inputRules } = useInputRules();
 
 const formRef = useTemplateRef("formRef");
-const fileInputRef = useTemplateRef("fileInputRef");
 const stateSelectRef = useTemplateRef("stateSelectRef");
-const avatarPreview = ref(null);
+const citySelectRef = useTemplateRef("citySelectRef");
+
 const avatarFile = ref(null);
+const selectedState = ref(null);
+const selectedCity = ref(null);
 
 const form = ref({
   name: null,
@@ -214,13 +195,22 @@ const form = ref({
   address: null,
   address_number: null,
   neighborhood: null,
-  state: null,
+  state_id: null,
+  city_id: null,
   complement: null,
   payer: null,
   how_found: null,
   notes: null,
 });
 
+watch(selectedState, (state) => {
+  form.value.state_id = state?.value ?? null;
+});
+
+watch(selectedCity, (city) => {
+  form.value.city_id = city?.value ?? null;
+});
+
 const genderOptions = ref([
   { label: "Prefiro não informar", value: "no_preference" },
   { label: "Masculino", value: "male" },
@@ -242,6 +232,10 @@ const { loading, execute } = useSubmitHandler({
   },
 });
 
+function onAvatarChange(file) {
+  avatarFile.value = file;
+}
+
 function buildPayload() {
   if (avatarFile.value) {
     const formData = new FormData();
@@ -257,8 +251,11 @@ function buildPayload() {
     formData.append("street", form.value.address ?? "");
     formData.append("address_number", form.value.address_number ?? "");
     formData.append("neighborhood", form.value.neighborhood ?? "");
-    if (form.value.state?.value) {
-      formData.append("state_id", form.value.state.value);
+    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 ?? "");
@@ -270,7 +267,9 @@ function buildPayload() {
 
   return {
     name: form.value.name,
-    birth_date: form.value.birthdate ? formatDateDMYtoYMD(form.value.birthdate) : null,
+    birth_date: form.value.birthdate
+      ? formatDateDMYtoYMD(form.value.birthdate)
+      : null,
     document_number: form.value.cpf,
     gender: form.value.gender,
     email: form.value.email || null,
@@ -279,7 +278,8 @@ function buildPayload() {
     street: form.value.address,
     address_number: form.value.address_number,
     neighborhood: form.value.neighborhood,
-    state_id: form.value.state?.value ?? null,
+    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,
@@ -287,18 +287,6 @@ function buildPayload() {
   };
 }
 
-function triggerFileInput() {
-  fileInputRef.value?.click();
-}
-
-function onAvatarChange(event) {
-  const file = event.target.files[0];
-  if (file) {
-    avatarFile.value = file;
-    avatarPreview.value = URL.createObjectURL(file);
-  }
-}
-
 async function onOKClick() {
   await execute(() => createStudent(buildPayload()));
 }

+ 75 - 0
src/pages/students/components/AddStudentMediaDialog.vue

@@ -0,0 +1,75 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin" style="width: 480px; max-width: 95vw">
+      <DefaultDialogHeader title="Adicionar Mídia" @close="onDialogCancel" />
+
+      <q-form ref="formRef" @submit="onSubmit">
+        <q-card-section class="q-pt-none">
+          <div class="column q-gutter-sm">
+            <DefaultInput
+              v-model="form.name"
+              label="Nome do documento"
+              :rules="[inputRules.required]"
+            />
+
+            <q-file
+              v-model="selectedFile"
+              label="Arquivo"
+              outlined
+              accept="image/*,video/*,.pdf"
+              :rules="[inputRules.required]"
+            >
+              <template #prepend>
+                <q-icon name="mdi-paperclip" />
+              </template>
+            </q-file>
+          </div>
+        </q-card-section>
+
+        <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>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+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 DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { studentId } = defineProps({
+  studentId: { type: Number, required: true },
+});
+
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const formRef = ref(null);
+const selectedFile = ref(null);
+const form = ref({ name: "" });
+
+const { loading, execute } = useSubmitHandler({
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+});
+
+async function onSubmit() {
+  await execute(() => {
+    const formData = new FormData();
+    formData.append("name", form.value.name);
+    formData.append("origem", "student");
+    formData.append("origem_id", studentId);
+    formData.append("file", selectedFile.value);
+    return createStudentMedia(formData);
+  });
+}
+</script>

+ 414 - 0
src/pages/students/components/CreateContractDialog.vue

@@ -0,0 +1,414 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 100%; max-width: 1100px"
+    >
+      <DefaultDialogHeader :title="props.contract ? 'Editar Contrato' : 'Novo Contrato'" @close="onDialogCancel" />
+
+      <q-card-section class="q-pt-sm" style="height: 65vh; overflow-y: auto">
+        <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="props.student.name"
+              label="Aluno"
+              disable
+            />
+          </div>
+
+          <div class="col-6">
+            <DefaultInput
+              :model-value="props.student.document_number"
+              label="CPF"
+              disable
+            />
+          </div>
+
+          <div class="col-6">
+            <DefaultInput
+              :model-value="formattedBirthDate"
+              label="Data de Nascimento"
+              disable
+            />
+          </div>
+        </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 v-model="form.protocol" label="Protocolo" />
+          </div>
+
+          <div class="col-4">
+            <DefaultInputDatePicker
+              v-model="form.signature_date"
+              label="Data Assinatura"
+            />
+          </div>
+
+          <div class="col-4">
+            <DefaultInputDatePicker
+              v-model="form.end_date"
+              label="Data Encerramento"
+            />
+          </div>
+
+          <div class="col-5">
+            <DefaultSelect
+              v-model="form.package_id"
+              label="Pacote de Aulas"
+              :options="packages"
+              option-value="id"
+              option-label="name"
+              emit-value
+              map-options
+            />
+          </div>
+
+          <div class="col-7">
+            <DefaultInput
+              v-model="form.class_quantity"
+              label="Qtd. Aulas"
+              type="number"
+              disable
+            />
+          </div>
+
+          <div class="col-4">
+            <DefaultSelect
+              v-model="form.weekday"
+              label="Dia da Semana"
+              :options="weekdays"
+              option-value="value"
+              option-label="label"
+              emit-value
+              map-options
+            />
+          </div>
+
+          <div class="col-4">
+            <DefaultInput
+              v-model="form.start_time"
+              label="Hora de Início"
+              mask="##:##"
+            >
+              <template #append>
+                <q-icon name="mdi-clock-outline" />
+              </template>
+            </DefaultInput>
+          </div>
+
+          <div class="col-4">
+            <DefaultInput
+              v-model="form.end_time"
+              label="Hora de Término"
+              mask="##:##"
+            >
+              <template #append>
+                <q-icon name="mdi-clock-outline" />
+              </template>
+            </DefaultInput>
+          </div>
+
+          <div class="col-4">
+            <DefaultSelect
+              v-model="form.second_weekday"
+              label="2° Dia da Semana"
+              :options="weekdays"
+              option-value="value"
+              option-label="label"
+              emit-value
+              map-options
+            />
+          </div>
+
+          <div class="col-4">
+            <DefaultInput
+              v-model="form.second_start_time"
+              label="Hora de Início"
+              mask="##:##"
+            >
+              <template #append>
+                <q-icon name="mdi-clock-outline" />
+              </template>
+            </DefaultInput>
+          </div>
+
+          <div class="col-4">
+            <DefaultInput
+              v-model="form.second_end_time"
+              label="Hora de Término"
+              mask="##:##"
+            >
+              <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="row q-col-gutter-sm">
+          <div class="col-4">
+            <DefaultInputDatePicker
+              v-model="form.due_date"
+              label="Vencimento"
+            />
+          </div>
+
+          <div class="col-4">
+            <DefaultCurrencyInput
+              v-model="form.enrollment_fee"
+              label="Taxa de Matrícula"
+              disable
+            />
+          </div>
+
+          <div class="col-4">
+            <DefaultInput
+              v-model="form.total_classes"
+              label="Total de Aulas"
+              type="number"
+              disable
+            />
+          </div>
+
+          <div class="col-3">
+            <DefaultCurrencyInput
+              v-model="form.down_payment"
+              label="Entrada"
+            />
+          </div>
+
+          <div class="col-3">
+            <DefaultSelect
+              v-model="form.installments"
+              label="Parcelas"
+              :options="installmentOptions"
+              option-value="value"
+              option-label="label"
+              emit-value
+              map-options
+            />
+          </div>
+
+          <div class="col-6">
+            <DefaultInput
+              v-model="form.early_payment_discount"
+              label="Desconto até o vencimento (%)"
+              type="number"
+            />
+          </div>
+
+          <div class="col-3">
+            <DefaultCurrencyInput
+              v-model="form.material_value"
+              label="Valor dos Materiais"
+            />
+          </div>
+
+          <div class="col-3">
+            <DefaultSelect
+              v-model="form.material_installments"
+              label="Parcelas"
+              :options="installmentOptions"
+              option-value="value"
+              option-label="label"
+              emit-value
+              map-options
+            />
+          </div>
+
+          <div class="col-6">
+            <DefaultInput
+              v-model="form.interest_rate"
+              label="Juros (%) a.m"
+              type="number"
+            />
+          </div>
+
+          <div class="col-6">
+            <DefaultSelect
+              v-model="form.payment_method"
+              label="Forma de Pagamento"
+              :options="paymentMethods"
+              option-value="value"
+              option-label="label"
+              emit-value
+              map-options
+            />
+          </div>
+
+          <div class="col-6">
+            <DefaultInput
+              v-model="form.late_fee"
+              label="Multa (%)"
+              type="number"
+            />
+          </div>
+        </div>
+      </q-card-section>
+
+      <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>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, ref, watch, onMounted } 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 DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { formatDateYMDtoDMY, formatDateDMYtoYMD } from "src/helpers/utils";
+import { getUnitPackages } from "src/api/package";
+import { createStudentContract, updateStudentContract } from "src/api/studentContract";
+
+const props = defineProps({
+  student: {
+    type: Object,
+    required: true,
+  },
+  contract: {
+    type: Object,
+    default: null,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const trimTime = (t) => (t ? t.slice(0, 5) : null);
+
+const { form } = useFormUpdateTracker({
+  protocol:               props.contract?.protocol ?? null,
+  signature_date:         props.contract?.signature_date ?? null,
+  end_date:               props.contract?.end_date ?? null,
+  package_id:             props.contract?.class_package_unit_id ?? null,
+  class_quantity:         props.contract?.class_quantity ?? null,
+  weekday:                props.contract?.weekday ?? null,
+  start_time:             trimTime(props.contract?.start_time),
+  end_time:               trimTime(props.contract?.end_time),
+  second_weekday:         props.contract?.second_weekday ?? null,
+  second_start_time:      trimTime(props.contract?.second_start_time),
+  second_end_time:        trimTime(props.contract?.second_end_time),
+  due_date:               props.contract?.due_date ?? null,
+  enrollment_fee:         props.contract?.tax_register ?? null,
+  total_classes:          props.contract?.class_quantity ?? null,
+  down_payment:           props.contract?.down_payment ?? null,
+  installments:           props.contract?.installments ?? null,
+  early_payment_discount: props.contract?.early_payment_discount ?? null,
+  material_value:         props.contract?.material_value ?? null,
+  material_installments:  props.contract?.material_installments ?? null,
+  interest_rate:          props.contract?.interest_rate ?? null,
+  payment_method:         props.contract?.payment_method ?? null,
+  late_fee:               props.contract?.fine_cancelled ?? null,
+});
+
+const installmentOptions = Array.from({ length: 12 }, (_, i) => ({
+  value: i + 1,
+  label: `${i + 1}x`,
+}));
+
+const paymentMethods = [
+  { value: "pix", label: "Pix" },
+  { value: "credit_card", label: "Cartão de Crédito" },
+  { value: "debit_card", label: "Cartão de Débito" },
+];
+
+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" },
+];
+
+const packages = ref([]);
+
+onMounted(async () => {
+  packages.value = await getUnitPackages();
+});
+
+watch(
+  () => form.package_id,
+  (id) => {
+    const pkg = packages.value.find((p) => p.id === id);
+    if (!pkg) return;
+    form.class_quantity = pkg.quantity_classes;
+    form.total_classes = pkg.quantity_classes;
+    form.enrollment_fee = pkg.contract_register_value;
+  },
+);
+
+const formattedBirthDate = computed(() =>
+  props.student.birth_date ? formatDateYMDtoDMY(props.student.birth_date) : "",
+);
+
+const { loading: saving, execute } = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+});
+
+function buildPayload() {
+  return {
+    student_id:              props.student.id,
+    protocol:                form.protocol,
+    signature_date:          form.signature_date ? formatDateDMYtoYMD(form.signature_date) : null,
+    end_date:                form.end_date ? formatDateDMYtoYMD(form.end_date) : null,
+    class_package_unit_id:   form.package_id,
+    class_quantity:          form.class_quantity,
+    weekday:                 form.weekday,
+    start_time:              form.start_time,
+    end_time:                form.end_time,
+    second_weekday:          form.second_weekday,
+    second_start_time:       form.second_start_time,
+    second_end_time:         form.second_end_time,
+    due_date:                form.due_date ? formatDateDMYtoYMD(form.due_date) : null,
+    tax_register:            form.enrollment_fee,
+    down_payment:            form.down_payment ?? 0,
+    installments:            form.installments,
+    early_payment_discount:  form.early_payment_discount,
+    material_value:          form.material_value,
+    material_installments:   form.material_installments,
+    interest_rate:           form.interest_rate,
+    payment_method:          form.payment_method,
+    fine_cancelled:          form.late_fee,
+  };
+}
+
+async function handleSave() {
+  const payload = buildPayload();
+  await execute(() =>
+    props.contract
+      ? updateStudentContract(props.contract.id, payload)
+      : createStudentContract(payload),
+  );
+}
+</script>

+ 61 - 11
src/pages/students/components/EditStudentDialog.vue

@@ -2,11 +2,11 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card
       class="q-dialog-plugin overflow-hidden"
-      style="width: 1000px; max-width: 97vw"
+      style="width: 100%; max-width: 1100px"
     >
       <DefaultDialogHeader title="Dados do Aluno" @close="onDialogCancel" />
 
-      <q-card-section class="q-pt-sm" style="max-height: 75vh; overflow-y: auto">
+      <q-card-section class="q-pt-sm" style="height: 65vh; overflow-y: auto">
         <CustomTabComponent
           v-model:active-tab="currentTab"
           :tabs="tabs"
@@ -14,15 +14,18 @@
         />
 
         <div v-show="currentTab === 'profile'">
-          <StudentDataTab :student="props.student" @saved="onStudentSaved" />
+          <StudentDataTab ref="studentDataTabRef" :student="props.student" />
         </div>
 
         <div v-show="currentTab === 'responsible'">
-          <ResponsibleTab :student-id="props.student.id" />
+          <ResponsibleTab
+            ref="responsibleTabRef"
+            :student-id="props.student.id"
+          />
         </div>
 
         <div v-show="currentTab === 'contracts'">
-          <ContractTab />
+          <ContractTab :student="props.student" />
         </div>
 
         <div v-show="currentTab === 'history'">
@@ -30,24 +33,37 @@
         </div>
 
         <div v-show="currentTab === 'media'">
-          <MediaTab />
+          <MediaTab :student-id="props.student.id" />
         </div>
       </q-card-section>
 
       <q-separator />
 
       <q-card-actions align="right">
-        <q-btn outline color="primary" label="FECHAR" no-caps @click="onDialogCancel" />
+        <q-btn
+          outline
+          color="primary"
+          label="CANCELAR"
+          @click="onDialogCancel"
+        />
+        <q-btn
+          color="primary"
+          label="SALVAR"
+          :loading="saving"
+          @click="handleSave"
+        />
       </q-card-actions>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, defineAsyncComponent } from "vue";
+import { ref, useTemplateRef, defineAsyncComponent } from "vue";
 import { useDialogPluginComponent } 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";
 
 const StudentDataTab = defineAsyncComponent(
   () => import("src/pages/students/tabs/StudentDataTab.vue"),
@@ -77,17 +93,51 @@ defineEmits([...useDialogPluginComponent.emits]);
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
+const studentDataTabRef = useTemplateRef("studentDataTabRef");
+const responsibleTabRef = useTemplateRef("responsibleTabRef");
+
 const currentTab = ref("profile");
 
+const { loading: saving, execute } = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+});
+
 const tabs = [
   { name: "profile", label: "Perfil do Aluno" },
   { name: "responsible", label: "Responsáveis" },
   { name: "contracts", label: "Contratos" },
-  { name: "history", label: "Histórico" },
+  { name: "history", label: "Frequência" },
   { name: "media", label: "Mídias" },
 ];
 
-function onStudentSaved() {
-  onDialogOK(true);
+function mergePayloads(studentPayload, responsiblePayload) {
+  if (studentPayload instanceof FormData) {
+    Object.entries(responsiblePayload).forEach(([key, value]) => {
+      studentPayload.append(`responsible[${key}]`, value ?? "");
+    });
+    return studentPayload;
+  }
+  return { ...studentPayload, responsible: responsiblePayload };
+}
+
+async function handleSave() {
+  const [studentValid, responsibleValid] = await Promise.all([
+    studentDataTabRef.value?.validate() ?? true,
+    responsibleTabRef.value?.validate() ?? true,
+  ]);
+
+  if (!studentValid || !responsibleValid) return;
+
+  const studentPayload = studentDataTabRef.value.buildPayload();
+  const responsiblePayload = responsibleTabRef.value?.buildPayload();
+
+  await execute(() =>
+    updateStudent(
+      responsiblePayload
+        ? mergePayloads(studentPayload, responsiblePayload)
+        : studentPayload,
+      props.student.id,
+    ),
+  );
 }
 </script>

+ 105 - 16
src/pages/students/tabs/ContractTab.vue

@@ -1,5 +1,13 @@
 <template>
   <div>
+    <input
+      ref="fileInputRef"
+      type="file"
+      accept="image/*,video/*,.pdf"
+      style="display: none"
+      @change="onFileSelected"
+    />
+
     <DefaultTable
       v-model:rows="rows"
       title="Contratos"
@@ -7,9 +15,12 @@
       descricao="contratos"
       :feminino="false"
       no-api-call
+      add-item
+      :show-search-field="false"
+      @on-add-item="handleAdd"
     >
       <template #body-cell-period="{ row }">
-        <q-td>{{ row.started_date }} — {{ row.end_date }}</q-td>
+        <q-td>{{ row.signature_date }} — {{ row.end_date }}</q-td>
       </template>
 
       <template #body-cell-status="{ row }">
@@ -21,23 +32,41 @@
         </q-td>
       </template>
 
-      <template #body-cell-actions>
+      <template #body-cell-actions="{ row }">
         <q-td auto-width>
-          <q-item-section class="no-wrap" style="flex-direction: row">
+          <q-item-section class="no-wrap" style="flex-direction: row; gap: 4px">
             <q-btn
               outline
               icon="mdi-pencil-outline"
               style="width: 36px"
-              class="q-mr-sm"
-              @click.prevent.stop="() => {}"
+              @click.prevent.stop="handleEdit(row)"
             />
             <q-btn
               outline
-              icon="mdi-trash-can-outline"
+              icon="mdi-paperclip"
               style="width: 36px"
-              class="q-mr-sm"
-              @click.prevent.stop="() => {}"
+              :loading="uploadingId === row.id"
+              @click.prevent.stop="triggerFileInput(row)"
             />
+            <q-btn
+              outline
+              icon="mdi-dots-vertical"
+              style="width: 36px"
+              @click.prevent.stop
+            >
+              <q-menu>
+                <q-list style="min-width: 170px">
+                  <q-item
+                    v-close-popup
+                    clickable
+                    :disable="!row.file_url"
+                    @click="openFile(row.file_url)"
+                  >
+                    <q-item-section>Visualizar arquivo</q-item-section>
+                  </q-item>
+                </q-list>
+              </q-menu>
+            </q-btn>
           </q-item-section>
         </q-td>
       </template>
@@ -46,20 +75,80 @@
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import CreateContractDialog from "src/pages/students/components/CreateContractDialog.vue";
+import { getStudentContracts, attachContractFile } from "src/api/studentContract";
 
+const props = defineProps({
+  student: {
+    type: Object,
+    required: true,
+  },
+});
+
+const $q = useQuasar();
 const rows = ref([]);
+const fileInputRef = ref(null);
+const uploadingId = ref(null);
+let pendingContractId = null;
+
+async function loadContracts() {
+  rows.value = await getStudentContracts(props.student.id);
+}
+
+onMounted(loadContracts);
+
+function openDialog(contract = null) {
+  $q.dialog({
+    component: CreateContractDialog,
+    componentProps: { student: props.student, contract },
+  }).onOk(loadContracts);
+}
+
+function handleAdd() {
+  openDialog();
+}
+
+function handleEdit(contract) {
+  openDialog(contract);
+}
+
+function triggerFileInput(row) {
+  pendingContractId = row.id;
+  fileInputRef.value.value = "";
+  fileInputRef.value.click();
+}
+
+async function onFileSelected(event) {
+  const file = event.target.files?.[0];
+  if (!file || !pendingContractId) return;
+
+  uploadingId.value = pendingContractId;
+  try {
+    const formData = new FormData();
+    formData.append("file", file);
+    const updated = await attachContractFile(pendingContractId, formData);
+    const idx = rows.value.findIndex((r) => r.id === pendingContractId);
+    if (idx !== -1) rows.value[idx] = updated;
+  } catch (e) {
+    console.error(e);
+    $q.notify({ type: "negative", message: "Erro ao anexar arquivo." });
+  } finally {
+    uploadingId.value = null;
+    pendingContractId = null;
+  }
+}
+
+function openFile(url) {
+  window.open(url, "_blank");
+}
 
 const columns = ref([
   { name: "contract", label: "Contrato", field: "protocol", align: "left" },
-  { name: "period", label: "Data Inicial - Final", field: null, align: "left" },
-  {
-    name: "status",
-    label: "Status Contrato",
-    field: "status",
-    align: "center",
-  },
+  { name: "period", label: "Data Assinatura - Encerramento", field: null, align: "left" },
+  { name: "status", label: "Status", field: "status", align: "center" },
   { name: "actions", label: "Ações", field: null, align: "center" },
 ]);
 </script>

+ 81 - 139
src/pages/students/tabs/HistoryTab.vue

@@ -1,157 +1,99 @@
 <template>
   <div>
-    <div v-show="currentView === null">
-      <DefaultTable
-        v-model:rows="contractRows"
-        title="Contratos"
-        :columns="contractColumns"
-        descricao="contratos"
-        :feminino="false"
-        no-api-call
+    <div class="row items-center q-mb-lg" style="gap: 8px">
+      <span class="text-subtitle1 text-weight-medium"
+        >Relatório de Presença</span
       >
-        <template #body-cell-period="{ row }">
-          <q-td>{{ row.started_date }} — {{ row.end_date }}</q-td>
-        </template>
-
-        <template #body-cell-status="{ row }">
-          <q-td align="center">
-            <q-badge
-              :color="row.status === 'active' ? 'positive' : 'warning'"
-              :label="row.status === 'active' ? 'Ativo' : 'Inativo'"
-            />
-          </q-td>
-        </template>
-
-        <template #body-cell-actions>
-          <q-td auto-width>
-            <q-item-section class="no-wrap" style="flex-direction: row">
-              <q-btn
-                outline
-                icon="mdi-pencil-outline"
-                style="width: 36px"
-                class="q-mr-sm"
-                @click.prevent.stop="() => {}"
-              />
-              <q-btn
-                outline
-                icon="mdi-trash-can-outline"
-                style="width: 36px"
-                class="q-mr-sm"
-                @click.prevent.stop="() => {}"
-              />
-            </q-item-section>
-          </q-td>
-        </template>
-      </DefaultTable>
-
-      <div class="row q-mt-lg" style="gap: 1rem">
-        <div class="col">
-          <div class="text-subtitle1 q-mb-sm">Frequência</div>
-          <div style="height: 280px">
-            <BarChart
-              :data="frequencyChartData"
-              data-set-label="Presenças"
-              label-x="Mês"
-              label-y="Aulas"
-              title="Frequência"
-            />
-          </div>
-        </div>
-
-        <div class="col-md-4 col-12">
-          <div class="text-subtitle1 q-mb-sm">Ocorrências</div>
-          <div class="column" style="gap: 8px">
-            <q-btn
-              color="primary-2"
-              label="Notas"
-              no-caps
-              unelevated
-              @click="currentView = 'notes'"
-            />
-            <q-btn
-              color="primary-2"
-              label="Relatório de Presença"
-              no-caps
-              unelevated
-              @click="currentView = 'attendance'"
-            />
-            <q-btn
-              color="primary-2"
-              label="Arquivos em Anexo"
-              no-caps
-              unelevated
-              @click="currentView = 'files'"
-            />
-          </div>
-        </div>
-      </div>
     </div>
 
-    <div v-show="currentView === 'notes'">
-      <div class="row items-center q-mb-md" style="gap: 8px">
-        <q-btn flat round dense icon="mdi-arrow-left" @click="currentView = null" />
-        <span class="text-h6">Notas</span>
-      </div>
-      <DefaultSelect
-        v-model="selectedCourse"
-        label="Curso"
-        :options="[]"
-        class="q-mb-md"
-      />
-      <q-list bordered separator>
-        <q-item v-if="notes.length === 0">
-          <q-item-section class="text-grey">Nenhuma nota adicionada.</q-item-section>
-        </q-item>
-        <q-item v-for="(note, i) in notes" :key="i">
-          <q-item-section>{{ note }}</q-item-section>
-        </q-item>
-      </q-list>
-    </div>
-
-    <div v-show="currentView === 'attendance'">
-      <div class="row items-center q-mb-md" style="gap: 8px">
-        <q-btn flat round dense icon="mdi-arrow-left" @click="currentView = null" />
-        <span class="text-h6">Relatório de Presença</span>
+    <div v-for="course in courses" :key="course.id" class="q-mb-sm">
+      <div
+        class="row items-center q-px-md q-py-sm rounded-borders"
+        style="border: 1px solid #e0e0e0; cursor: default"
+      >
+        <span class="col text-body2" style="color: #e8825a">{{
+          course.name
+        }}</span>
+        <q-btn
+          flat
+          round
+          dense
+          :icon="
+            expandedCourses.includes(course.id) ? 'mdi-eye' : 'mdi-eye-outline'
+          "
+          @click="toggleCourse(course.id)"
+        />
       </div>
-      <q-list bordered separator>
-        <q-item v-if="attendanceItems.length === 0">
-          <q-item-section class="text-grey">Nenhum registro encontrado.</q-item-section>
-        </q-item>
-      </q-list>
-    </div>
 
-    <div v-show="currentView === 'files'">
-      <div class="row items-center q-mb-md" style="gap: 8px">
-        <q-btn flat round dense icon="mdi-arrow-left" @click="currentView = null" />
-        <span class="text-h6">Arquivos em Anexo</span>
+      <div v-if="expandedCourses.includes(course.id)">
+        <q-markup-table flat bordered class="q-mt-xs">
+          <thead>
+            <tr>
+              <th class="text-left">Item</th>
+              <th class="text-left">Data</th>
+              <th class="text-left">Status</th>
+              <th class="text-right">Justificativa</th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="(record, index) in course.attendances" :key="index">
+              <td>{{ record.item }}</td>
+              <td>
+                <q-badge
+                  :label="record.date"
+                  style="
+                    background-color: #6ed4c8;
+                    color: #fff;
+                    font-size: 0.75rem;
+                    padding: 4px 8px;
+                    border-radius: 12px;
+                  "
+                />
+              </td>
+              <td>{{ record.status }}</td>
+              <td class="text-right">{{ record.justification }}</td>
+            </tr>
+          </tbody>
+        </q-markup-table>
       </div>
-      <q-list bordered separator>
-        <q-item v-if="attachedFiles.length === 0">
-          <q-item-section class="text-grey">Nenhum arquivo anexado.</q-item-section>
-        </q-item>
-      </q-list>
     </div>
   </div>
 </template>
 
 <script setup>
 import { ref } from "vue";
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import BarChart from "src/components/charts/normal/BarChart.vue";
 
-const contractRows = ref([]);
-const contractColumns = ref([
-  { name: "contract", label: "Contrato", field: "protocol", align: "left" },
-  { name: "period", label: "Data Inicial - Final", field: null, align: "left" },
-  { name: "status", label: "Status Contrato", field: "status", align: "center" },
-  { name: "actions", label: "Ações", field: null, align: "center" },
+const expandedCourses = ref([1]);
+
+const courses = ref([
+  {
+    id: 1,
+    name: "Inteligência extrema",
+    attendances: [
+      { item: 1, date: "15/02", status: "Presente", justification: "-" },
+      { item: 2, date: "18/02", status: "Presente", justification: "-" },
+      { item: 3, date: "20/02", status: "Ausente", justification: "Sim" },
+      { item: 3, date: "20/02", status: "Ausente", justification: "Não" },
+    ],
+  },
+  {
+    id: 2,
+    name: "Inteligência extrema II",
+    attendances: [],
+  },
+  {
+    id: 3,
+    name: "Inteligência extrema III",
+    attendances: [],
+  },
 ]);
 
-const currentView = ref(null);
-const selectedCourse = ref(null);
-const notes = ref([]);
-const attendanceItems = ref([]);
-const attachedFiles = ref([]);
-const frequencyChartData = ref({ chart_data: [] });
+function toggleCourse(id) {
+  const idx = expandedCourses.value.indexOf(id);
+  if (idx === -1) {
+    expandedCourses.value.push(id);
+  } else {
+    expandedCourses.value.splice(idx, 1);
+  }
+}
 </script>

+ 91 - 5
src/pages/students/tabs/MediaTab.vue

@@ -1,11 +1,97 @@
 <template>
-  <div class="flex justify-center items-center q-pa-xl text-grey-6">
-    <div class="column items-center" style="gap: 8px">
-      <q-icon name="mdi-image-multiple-outline" size="48px" />
-      <span>Mídias em breve.</span>
-    </div>
+  <div>
+    <DefaultTable
+      v-model:rows="rows"
+      :columns="columns"
+      no-api-call
+      add-item
+      :show-search-field="false"
+      hide-no-data-label
+      @on-add-item="openAddDialog"
+    >
+      <template #body-cell-item="{ rowIndex }">
+        <q-td>{{ rowIndex + 1 }}</q-td>
+      </template>
+
+      <template #body-cell-actions="{ row }">
+        <q-btn
+          flat
+          round
+          dense
+          icon="mdi-eye-outline"
+          class="q-mr-xs"
+          :disable="!row.file_url"
+          @click.stop="openFile(row.file_url)"
+        />
+        <q-btn
+          flat
+          round
+          dense
+          icon="mdi-trash-can-outline"
+          @click.stop="confirmDelete(row)"
+        />
+      </template>
+    </DefaultTable>
   </div>
 </template>
 
 <script setup>
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import AddStudentMediaDialog from "src/pages/students/components/AddStudentMediaDialog.vue";
+import { getStudentMedias, deleteStudentMedia } from "src/api/student_media";
+
+const props = defineProps({
+  studentId: { type: Number, required: true },
+});
+
+const $q = useQuasar();
+const rows = ref([]);
+
+const columns = [
+  { name: "item", label: "Item", field: "id", align: "left" },
+  { name: "date", label: "Data", field: "created_at", align: "left" },
+  { name: "name", label: "Nome", field: "name", align: "left" },
+  { name: "actions", label: "Ações", field: null, align: "right" },
+];
+
+async function fetchMedias() {
+  try {
+    rows.value = await getStudentMedias(props.studentId);
+  } catch (e) {
+    console.error(e);
+  }
+}
+
+function openAddDialog() {
+  $q.dialog({
+    component: AddStudentMediaDialog,
+    componentProps: { studentId: props.studentId },
+  }).onOk((result) => {
+    rows.value.unshift(result);
+  });
+}
+
+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);
+    }
+  });
+}
+
+onMounted(fetchMedias);
 </script>

+ 53 - 90
src/pages/students/tabs/ResponsibleTab.vue

@@ -1,34 +1,32 @@
 <template>
   <div>
-    <div class="text-h6 q-mb-md">Dados do Responsável</div>
-
     <q-form ref="formRef" @submit="onSave">
       <div class="row q-col-gutter-sm">
         <DefaultInput
-          :model-value="String(props.studentId)"
-          label="ID do Aluno"
-          class="col-md-3 col-12"
-          readonly
+          v-model="form.name"
+          label="Nome"
+          class="col-6"
+          :rules="[inputRules.required]"
         />
 
         <DefaultInput
-          v-model="form.name"
-          label="Nome"
-          class="col-md-9 col-12"
+          v-model="form.degree"
+          label="Grau de Parentesco"
+          class="col-6"
           :rules="[inputRules.required]"
         />
 
         <DefaultInputDatePicker
           v-model="form.birth_date"
           label="Data de Nascimento"
-          class="col-md-4 col-12"
+          class="col-4"
           :rules="[inputRules.required]"
         />
 
         <DefaultInput
           v-model="form.cpf"
           label="CPF"
-          class="col-md-4 col-12"
+          class="col-4"
           :mask="masks.Brasil.cpf"
           :rules="[inputRules.required]"
         />
@@ -36,23 +34,16 @@
         <DefaultSelect
           v-model="form.gender"
           label="Gênero"
-          class="col-md-4 col-12"
+          class="col-4"
           emit-value
           map-options
           :options="genderOptions"
         />
 
-        <DefaultInput
-          v-model="form.degree"
-          label="Grau de Parentesco"
-          class="col-md-6 col-12"
-          :rules="[inputRules.required]"
-        />
-
         <DefaultInput
           v-model="form.email"
           label="E-mail"
-          class="col-md-6 col-12"
+          class="col-6"
           type="email"
           :rules="[inputRules.email]"
         />
@@ -60,93 +51,75 @@
         <DefaultInput
           v-model="form.phone"
           label="Telefone"
-          class="col-md-6 col-12"
+          class="col-6"
           :mask="masks.Brasil.celular"
           :rules="[inputRules.required]"
         />
 
         <DefaultCepInput
           v-model="form.postal_code"
-          class="col-md-6 col-12"
+          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"
           label="Endereço"
-          class="col-md-6 col-12"
+          class="col-5"
         />
 
         <DefaultInput
           v-model="form.address_number"
           label="Número"
-          class="col-md-6 col-12"
+          class="col-3"
         />
 
         <DefaultInput
           v-model="form.neighborhood"
           label="Bairro"
-          class="col-md-6 col-12"
+          class="col-4"
         />
 
         <CitySelect
           ref="citySelectRef"
-          v-model="form.city"
+          v-model="selectedCity"
           label="Cidade"
-          class="col-md-6 col-12"
-          outlined
-          :state="form.state"
-          :initial-id="form.city_id ?? null"
+          class="col-4"
+          :state="selectedState"
         />
 
         <StateSelect
           ref="stateSelectRef"
-          v-model="form.state"
+          v-model="selectedState"
           label="Estado"
-          class="col-md-6 col-12"
-          outlined
-          :initial-id="form.state_id ?? null"
+          class="col-4"
         />
 
         <DefaultInput
           v-model="form.complement"
           label="Complemento"
-          class="col-md-6 col-12"
+          class="col-12"
         />
 
         <DefaultInput
           v-model="form.notes"
-          label="Observação"
+          label="Observações"
           class="col-12"
           type="textarea"
+          :input-style="{ minHeight: '120px' }"
           autogrow
         />
       </div>
 
-      <div class="row justify-end q-mt-md" style="gap: 8px">
-        <q-btn
-          outline
-          color="primary"
-          label="Cancelar"
-          no-caps
-          @click="onCancel"
-        />
-        <q-btn
-          color="primary"
-          label="Salvar"
-          no-caps
-          type="submit"
-          :loading="loading"
-        />
-      </div>
     </q-form>
   </div>
 </template>
 
 <script setup>
-import { ref, useTemplateRef, onMounted } from "vue";
+import { ref, watch, onMounted, useTemplateRef } from "vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
@@ -154,12 +127,7 @@ import DefaultCepInput from "src/components/defaults/DefaultCepInput.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 {
-  getStudentResponsible,
-  createStudentResponsible,
-  updateStudentResponsible,
-} from "src/api/studentResponsible";
+import { getStudentResponsible } from "src/api/studentResponsible";
 import masks from "src/helpers/masks";
 import { formatDateYMDtoDMY, formatDateDMYtoYMD } from "src/helpers/utils";
 
@@ -175,7 +143,8 @@ const formRef = useTemplateRef("formRef");
 const stateSelectRef = useTemplateRef("stateSelectRef");
 const citySelectRef = useTemplateRef("citySelectRef");
 
-const responsibleId = ref(null);
+const selectedState = ref(null);
+const selectedCity = ref(null);
 
 const genderOptions = [
   { label: "Masculino", value: "male" },
@@ -196,28 +165,29 @@ const emptyForm = () => ({
   street: null,
   address_number: null,
   neighborhood: null,
-  city: null,
-  city_id: null,
-  state: null,
   state_id: null,
+  city_id: null,
   complement: null,
   notes: null,
 });
 
 const form = ref(emptyForm());
 
-const { loading, execute } = useSubmitHandler({ formRef });
+watch(selectedState, (state) => {
+  form.value.state_id = state?.value ?? null;
+});
+
+watch(selectedCity, (city) => {
+  form.value.city_id = city?.value ?? null;
+});
 
 async function loadResponsible() {
   try {
     const data = await getStudentResponsible(props.studentId);
     if (data) {
-      responsibleId.value = data.id;
       form.value = {
         name: data.name ?? null,
-        birth_date: data.birth_date
-          ? formatDateYMDtoDMY(data.birth_date)
-          : null,
+        birth_date: data.birth_date ? formatDateYMDtoDMY(data.birth_date) : null,
         cpf: data.cpf ?? null,
         gender: data.gender ?? "no_preference",
         degree: data.degree ?? null,
@@ -227,13 +197,18 @@ async function loadResponsible() {
         street: data.street ?? null,
         address_number: data.address_number ?? null,
         neighborhood: data.neighborhood ?? null,
-        city: null,
-        city_id: data.city_id ?? null,
-        state: null,
         state_id: data.state_id ?? null,
+        city_id: data.city_id ?? null,
         complement: data.complement ?? null,
         notes: data.notes ?? null,
       };
+
+      if (data.state_id) {
+        stateSelectRef.value?.selectStateById(data.state_id);
+      }
+      if (data.city_id) {
+        citySelectRef.value?.selectCityById(data.city_id);
+      }
     }
   } catch (error) {
     console.error("Failed to load responsible:", error);
@@ -242,7 +217,6 @@ async function loadResponsible() {
 
 function buildPayload() {
   return {
-    student_id: props.studentId,
     name: form.value.name,
     birth_date: form.value.birth_date
       ? formatDateDMYtoYMD(form.value.birth_date)
@@ -256,28 +230,17 @@ function buildPayload() {
     street: form.value.street,
     address_number: form.value.address_number,
     neighborhood: form.value.neighborhood,
-    city_id: form.value.city?.value ?? form.value.city_id ?? null,
-    state_id: form.value.state?.value ?? form.value.state_id ?? null,
+    city_id: form.value.city_id,
+    state_id: form.value.state_id,
     complement: form.value.complement,
     notes: form.value.notes,
   };
 }
 
-async function onSave() {
-  await execute(async () => {
-    const payload = buildPayload();
-    if (responsibleId.value) {
-      await updateStudentResponsible(responsibleId.value, payload);
-    } else {
-      const created = await createStudentResponsible(payload);
-      responsibleId.value = created.id;
-    }
-  });
-}
-
-function onCancel() {
-  loadResponsible();
-}
-
 onMounted(loadResponsible);
+
+defineExpose({
+  validate: () => formRef.value?.validate(),
+  buildPayload,
+});
 </script>

+ 77 - 71
src/pages/students/tabs/StudentDataTab.vue

@@ -1,54 +1,34 @@
 <template>
   <q-form ref="formRef" @submit="onSave">
+    <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"
-        label="Nome do aluno"
-        class="col-md-5 col-12"
+        label="Nome do Aluno"
+        class="col-6"
+        :rules="[inputRules.required]"
       />
 
       <DefaultInputDatePicker
         v-model="form.birthdate"
         label="Data de Nascimento"
-        class="col-md-5 col-12"
+        class="col-6"
       />
 
-      <div class="col-md-2 col-12 flex justify-center items-start">
-        <div style="position: relative; display: inline-block">
-          <q-avatar size="72px" color="grey-3">
-            <img v-if="avatarPreview" :src="avatarPreview" />
-            <q-icon v-else name="mdi-account" size="42px" color="grey-6" />
-          </q-avatar>
-          <q-btn
-            round
-            dense
-            color="primary"
-            icon="mdi-camera"
-            size="xs"
-            style="position: absolute; bottom: 0; right: 0"
-            @click="triggerFileInput"
-          />
-          <input
-            ref="fileInputRef"
-            type="file"
-            accept="image/*"
-            style="display: none"
-            @change="onAvatarChange"
-          />
-        </div>
-      </div>
-
       <DefaultInput
         v-model="form.cpf"
         label="CPF / CNH"
-        class="col-md-6 col-12"
+        class="col-6"
         :mask="masks.Brasil.cpf"
       />
 
       <DefaultSelect
         v-model="form.gender"
         label="Gênero"
-        class="col-md-6 col-12"
+        class="col-6"
         emit-value
         map-options
         :options="genderOptions"
@@ -57,61 +37,69 @@
       <DefaultInput
         v-model="form.email"
         label="E-mail"
-        class="col-md-6 col-12"
+        class="col-6"
+        type="email"
       />
 
       <DefaultInput
         v-model="form.phone"
         label="Celular com DDD"
-        class="col-md-6 col-12"
+        class="col-6"
         :mask="masks.Brasil.celular"
       />
 
       <DefaultCepInput
         v-model="form.cep"
-        class="col-md-3 col-12"
+        class="col-4"
         @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"
         label="Endereço"
-        class="col-md-6 col-12"
+        class="col-5"
       />
 
       <DefaultInput
         v-model="form.address_number"
         label="Número"
-        class="col-md-3 col-12"
+        class="col-3"
       />
 
       <DefaultInput
         v-model="form.neighborhood"
         label="Bairro"
-        class="col-md-6 col-12"
+        class="col-4"
+      />
+
+      <CitySelect
+        ref="citySelectRef"
+        v-model="selectedCity"
+        label="Cidade"
+        class="col-4"
+        :state="selectedState"
       />
 
       <StateSelect
         ref="stateSelectRef"
-        v-model="form.state"
+        v-model="selectedState"
         label="Estado"
-        class="col-md-6 col-12"
-        outlined
-        :initial-id="props.student.state_id ?? null"
+        class="col-4"
       />
 
       <DefaultInput
         v-model="form.complement"
         label="Complemento"
-        class="col-md-6 col-12"
+        class="col-6"
       />
 
       <DefaultInput
         v-model="form.payer"
         label="Pagador"
-        class="col-md-6 col-12"
+        class="col-6"
       />
 
       <DefaultSelect
@@ -128,25 +116,24 @@
         label="Observações"
         class="col-12"
         type="textarea"
+        :input-style="{ minHeight: '120px' }"
         autogrow
       />
     </div>
 
-    <div class="row justify-end q-mt-md" style="gap: 8px">
-      <q-btn color="primary" label="Salvar" no-caps type="submit" :loading="loading" />
-    </div>
   </q-form>
 </template>
 
 <script setup>
-import { ref, useTemplateRef } from "vue";
+import { ref, watch, onMounted, useTemplateRef } from "vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
 import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
 import StateSelect from "src/components/selects/StateSelect.vue";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { updateStudent } from "src/api/student";
+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";
 
@@ -157,14 +144,16 @@ const props = defineProps({
   },
 });
 
-const emit = defineEmits(["saved"]);
+const { inputRules } = useInputRules();
 
 const formRef = useTemplateRef("formRef");
-const fileInputRef = useTemplateRef("fileInputRef");
+const avatarRef = useTemplateRef("avatarRef");
 const stateSelectRef = useTemplateRef("stateSelectRef");
+const citySelectRef = useTemplateRef("citySelectRef");
 
-const avatarPreview = ref(props.student.photo_url ?? null);
 const avatarFile = ref(null);
+const selectedState = ref(null);
+const selectedCity = ref(null);
 
 const genderOptions = [
   { label: "Prefiro não informar", value: "no_preference" },
@@ -182,7 +171,9 @@ const howFoundOptions = [
 
 const form = ref({
   name: props.student.name ?? null,
-  birthdate: props.student.birth_date ? formatDateYMDtoDMY(props.student.birth_date) : null,
+  birthdate: props.student.birth_date
+    ? formatDateYMDtoDMY(props.student.birth_date)
+    : null,
   cpf: props.student.document_number ?? null,
   gender: props.student.gender ?? "no_preference",
   email: props.student.email ?? null,
@@ -191,28 +182,36 @@ const form = ref({
   address: props.student.street ?? null,
   address_number: props.student.address_number ?? null,
   neighborhood: props.student.neighborhood ?? null,
-  state: null,
+  state_id: props.student.state_id ?? null,
+  city_id: props.student.city_id ?? null,
   complement: props.student.complement ?? null,
   payer: props.student.payer_name ?? null,
   how_found: props.student.how_did_you_know_us ?? null,
   notes: props.student.notes ?? null,
 });
 
-const { loading, execute } = useSubmitHandler({
-  formRef,
-  onSuccess: () => emit("saved"),
+watch(selectedState, (state) => {
+  form.value.state_id = state?.value ?? null;
 });
 
-function triggerFileInput() {
-  fileInputRef.value?.click();
-}
+watch(selectedCity, (city) => {
+  form.value.city_id = city?.value ?? null;
+});
 
-function onAvatarChange(event) {
-  const file = event.target.files[0];
-  if (file) {
-    avatarFile.value = file;
-    avatarPreview.value = URL.createObjectURL(file);
+onMounted(() => {
+  if (props.student.photo_url) {
+    avatarRef.value?.setImageUrl(props.student.photo_url);
   }
+  if (props.student.state_id) {
+    stateSelectRef.value?.selectStateById(props.student.state_id);
+  }
+  if (props.student.city_id) {
+    citySelectRef.value?.selectCityById(props.student.city_id);
+  }
+});
+
+function onAvatarChange(file) {
+  avatarFile.value = file;
 }
 
 function buildPayload() {
@@ -230,8 +229,11 @@ function buildPayload() {
     formData.append("street", form.value.address ?? "");
     formData.append("address_number", form.value.address_number ?? "");
     formData.append("neighborhood", form.value.neighborhood ?? "");
-    if (form.value.state?.value) {
-      formData.append("state_id", form.value.state.value);
+    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 ?? "");
@@ -243,7 +245,9 @@ function buildPayload() {
 
   return {
     name: form.value.name,
-    birth_date: form.value.birthdate ? formatDateDMYtoYMD(form.value.birthdate) : null,
+    birth_date: form.value.birthdate
+      ? formatDateDMYtoYMD(form.value.birthdate)
+      : null,
     document_number: form.value.cpf,
     gender: form.value.gender,
     email: form.value.email || null,
@@ -252,7 +256,8 @@ function buildPayload() {
     street: form.value.address,
     address_number: form.value.address_number,
     neighborhood: form.value.neighborhood,
-    state_id: form.value.state?.value ?? null,
+    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,
@@ -260,7 +265,8 @@ function buildPayload() {
   };
 }
 
-async function onSave() {
-  await execute(() => updateStudent(buildPayload(), props.student.id));
-}
+defineExpose({
+  validate: () => formRef.value?.validate(),
+  buildPayload,
+});
 </script>