瀏覽代碼

feat: adiciona edit de estudante, remove alguns middlewares para teste e adiciona rota de responsavel.

ebagabee 1 周之前
父節點
當前提交
748384d283

+ 5 - 0
src/api/student.js

@@ -5,6 +5,11 @@ export const getStudents = async () => {
   return data.payload;
 };
 
+export const getStudent = async (id) => {
+  const { data } = await api.get(`/student/${id}`);
+  return data.payload;
+};
+
 export const createStudent = async (student) => {
   const { data } = await api.post("/student", student);
   return data.payload;

+ 21 - 0
src/api/studentResponsible.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getStudentResponsible = async (studentId) => {
+  const { data } = await api.get(`/student/${studentId}/responsible`);
+  return data.payload;
+};
+
+export const createStudentResponsible = async (payload) => {
+  const { data } = await api.post("/student-responsible", payload);
+  return data.payload;
+};
+
+export const updateStudentResponsible = async (id, payload) => {
+  const { data } = await api.put(`/student-responsible/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteStudentResponsible = async (id) => {
+  const { data } = await api.delete(`/student-responsible/${id}`);
+  return data.payload;
+};

+ 54 - 256
src/pages/students/components/EditStudentDialog.vue

@@ -2,175 +2,68 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card
       class="q-dialog-plugin overflow-hidden"
-      style="width: 900px; max-width: 95vw"
+      style="width: 1000px; max-width: 97vw"
     >
       <DefaultDialogHeader title="Dados do Aluno" @close="onDialogCancel" />
 
-      <q-form ref="formRef" @submit="onSave">
-        <q-card-section class="q-pt-sm" style="max-height: 65vh; overflow-y: auto">
-          <div class="text-subtitle2 q-mb-sm">Dados do Aluno</div>
+      <q-card-section class="q-pt-sm" style="max-height: 75vh; overflow-y: auto">
+        <CustomTabComponent
+          v-model:active-tab="currentTab"
+          :tabs="tabs"
+          class="q-mb-md"
+        />
 
-          <div class="row q-col-gutter-sm">
-            <DefaultInput
-              v-model="form.name"
-              label="Nome do aluno"
-              class="col-md-5 col-12"
-            />
+        <div v-show="currentTab === 'profile'">
+          <StudentDataTab :student="props.student" @saved="onStudentSaved" />
+        </div>
 
-            <DefaultInputDatePicker
-              v-model="form.birthdate"
-              label="Data de Nascimento"
-              class="col-md-5 col-12"
-            />
+        <div v-show="currentTab === 'responsible'">
+          <ResponsibleTab :student-id="props.student.id" />
+        </div>
 
-            <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>
+        <div v-show="currentTab === 'contracts'">
+          <ContractTab />
+        </div>
 
-            <DefaultInput
-              v-model="form.cpf"
-              label="CPF / CNH"
-              class="col-md-6 col-12"
-              :mask="masks.Brasil.cpf"
-            />
+        <div v-show="currentTab === 'history'">
+          <HistoryTab />
+        </div>
 
-            <DefaultSelect
-              v-model="form.gender"
-              label="Gênero"
-              class="col-md-6 col-12"
-              emit-value
-              map-options
-              :options="genderOptions"
-            />
+        <div v-show="currentTab === 'media'">
+          <MediaTab />
+        </div>
+      </q-card-section>
 
-            <DefaultInput
-              v-model="form.email"
-              label="E-mail"
-              class="col-md-6 col-12"
-            />
+      <q-separator />
 
-            <DefaultInput
-              v-model="form.phone"
-              label="Celular com DDD"
-              class="col-md-6 col-12"
-              :mask="masks.Brasil.celular"
-            />
-
-            <DefaultCepInput
-              v-model="form.cep"
-              class="col-md-3 col-12"
-              :disable="false"
-              @rua="(v) => (form.address = v)"
-              @bairro="(v) => (form.neighborhood = v)"
-              @uf="(v) => stateSelectRef?.selectStateByCode(v)"
-              @numero="() => {}"
-              @complemento="() => {}"
-            />
-
-            <DefaultInput
-              v-model="form.address"
-              label="Endereço"
-              class="col-md-6 col-12"
-            />
-
-            <DefaultInput
-              v-model="form.address_number"
-              label="Número"
-              class="col-md-3 col-12"
-            />
-
-            <DefaultInput
-              v-model="form.neighborhood"
-              label="Bairro"
-              class="col-md-6 col-12"
-            />
-
-            <StateSelect
-              ref="stateSelectRef"
-              v-model="form.state"
-              label="Estado"
-              class="col-md-6 col-12"
-              outlined
-              :initial-id="props.student.state_id ?? null"
-            />
-
-            <DefaultInput
-              v-model="form.complement"
-              label="Complemento"
-              class="col-md-6 col-12"
-            />
-
-            <DefaultInput
-              v-model="form.payer"
-              label="Pagador"
-              class="col-md-6 col-12"
-            />
-
-            <DefaultSelect
-              v-model="form.how_found"
-              label="Como nos conheceu?"
-              class="col-12"
-              emit-value
-              map-options
-              :options="howFoundOptions"
-            />
-
-            <DefaultInput
-              v-model="form.notes"
-              label="Observações"
-              class="col-12"
-              type="textarea"
-              autogrow
-            />
-          </div>
-        </q-card-section>
-
-        <q-separator />
-
-        <q-card-actions align="right">
-          <q-btn outline color="primary" label="CANCELAR" no-caps @click="onDialogCancel" />
-          <q-btn color="primary" label="SALVAR" no-caps type="submit" :loading="loading" />
-        </q-card-actions>
-      </q-form>
+      <q-card-actions align="right">
+        <q-btn outline color="primary" label="FECHAR" no-caps @click="onDialogCancel" />
+      </q-card-actions>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, useTemplateRef } from "vue";
+import { ref, defineAsyncComponent } 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 DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
-import StateSelect from "src/components/selects/StateSelect.vue";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { updateStudent } from "src/api/student";
-import masks from "src/helpers/masks";
-import { formatDateYMDtoDMY, formatDateDMYtoYMD } from "src/helpers/utils";
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+
+const StudentDataTab = defineAsyncComponent(
+  () => import("src/pages/students/tabs/StudentDataTab.vue"),
+);
+const ResponsibleTab = defineAsyncComponent(
+  () => import("src/pages/students/tabs/ResponsibleTab.vue"),
+);
+const ContractTab = defineAsyncComponent(
+  () => import("src/pages/students/tabs/ContractTab.vue"),
+);
+const HistoryTab = defineAsyncComponent(
+  () => import("src/pages/students/tabs/HistoryTab.vue"),
+);
+const MediaTab = defineAsyncComponent(
+  () => import("src/pages/students/tabs/MediaTab.vue"),
+);
 
 const props = defineProps({
   student: {
@@ -184,112 +77,17 @@ defineEmits([...useDialogPluginComponent.emits]);
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const formRef = useTemplateRef("formRef");
-const fileInputRef = useTemplateRef("fileInputRef");
-const stateSelectRef = useTemplateRef("stateSelectRef");
-
-const avatarPreview = ref(props.student.photo_url ?? null);
-const avatarFile = ref(null);
+const currentTab = ref("profile");
 
-const genderOptions = [
-  { label: "Prefiro não informar", value: "no_preference" },
-  { label: "Masculino", value: "male" },
-  { label: "Feminino", value: "female" },
-  { label: "Outro", value: "other" },
+const tabs = [
+  { name: "profile", label: "Perfil do Aluno" },
+  { name: "responsible", label: "Responsáveis" },
+  { name: "contracts", label: "Contratos" },
+  { name: "history", label: "Histórico" },
+  { name: "media", label: "Mídias" },
 ];
 
-const howFoundOptions = [
-  { label: "Indicação", value: "referral" },
-  { label: "Redes Sociais", value: "social_media" },
-  { label: "Google", value: "google" },
-  { label: "Outro", value: "other" },
-];
-
-const form = ref({
-  name: props.student.name ?? 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,
-  phone: props.student.phone ?? null,
-  cep: props.student.postal_code ?? null,
-  address: props.student.street ?? null,
-  address_number: props.student.address_number ?? null,
-  neighborhood: props.student.neighborhood ?? null,
-  state: 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: () => {
-    onDialogOK(true);
-  },
-});
-
-function triggerFileInput() {
-  fileInputRef.value?.click();
-}
-
-function onAvatarChange(event) {
-  const file = event.target.files[0];
-  if (file) {
-    avatarFile.value = file;
-    avatarPreview.value = URL.createObjectURL(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?.value) {
-      formData.append("state_id", form.value.state.value);
-    }
-    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;
-  }
-
-  return {
-    name: form.value.name,
-    birth_date: form.value.birthdate ? formatDateDMYtoYMD(form.value.birthdate) : null,
-    document_number: form.value.cpf,
-    gender: form.value.gender,
-    email: form.value.email || null,
-    phone: form.value.phone,
-    postal_code: form.value.cep,
-    street: form.value.address,
-    address_number: form.value.address_number,
-    neighborhood: form.value.neighborhood,
-    state_id: form.value.state?.value ?? null,
-    complement: form.value.complement,
-    payer_name: form.value.payer,
-    how_did_you_know_us: form.value.how_found,
-    notes: form.value.notes,
-  };
-}
-
-async function onSave() {
-  await execute(() => updateStudent(buildPayload(), props.student.id));
+function onStudentSaved() {
+  onDialogOK(true);
 }
 </script>

+ 65 - 0
src/pages/students/tabs/ContractTab.vue

@@ -0,0 +1,65 @@
+<template>
+  <div>
+    <DefaultTable
+      v-model:rows="rows"
+      title="Contratos"
+      :columns
+      descricao="contratos"
+      :feminino="false"
+      no-api-call
+    >
+      <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>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+
+const rows = ref([]);
+
+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: "actions", label: "Ações", field: null, align: "center" },
+]);
+</script>

+ 157 - 0
src/pages/students/tabs/HistoryTab.vue

@@ -0,0 +1,157 @@
+<template>
+  <div>
+    <div v-show="currentView === null">
+      <DefaultTable
+        v-model:rows="contractRows"
+        title="Contratos"
+        :columns="contractColumns"
+        descricao="contratos"
+        :feminino="false"
+        no-api-call
+      >
+        <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>
+      <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>
+      <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 currentView = ref(null);
+const selectedCourse = ref(null);
+const notes = ref([]);
+const attendanceItems = ref([]);
+const attachedFiles = ref([]);
+const frequencyChartData = ref({ chart_data: [] });
+</script>

+ 11 - 0
src/pages/students/tabs/MediaTab.vue

@@ -0,0 +1,11 @@
+<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>
+</template>
+
+<script setup>
+</script>

+ 283 - 0
src/pages/students/tabs/ResponsibleTab.vue

@@ -0,0 +1,283 @@
+<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
+        />
+
+        <DefaultInput
+          v-model="form.name"
+          label="Nome"
+          class="col-md-9 col-12"
+          :rules="[inputRules.required]"
+        />
+
+        <DefaultInputDatePicker
+          v-model="form.birth_date"
+          label="Data de Nascimento"
+          class="col-md-4 col-12"
+          :rules="[inputRules.required]"
+        />
+
+        <DefaultInput
+          v-model="form.cpf"
+          label="CPF"
+          class="col-md-4 col-12"
+          :mask="masks.Brasil.cpf"
+          :rules="[inputRules.required]"
+        />
+
+        <DefaultSelect
+          v-model="form.gender"
+          label="Gênero"
+          class="col-md-4 col-12"
+          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"
+          type="email"
+          :rules="[inputRules.email]"
+        />
+
+        <DefaultInput
+          v-model="form.phone"
+          label="Telefone"
+          class="col-md-6 col-12"
+          :mask="masks.Brasil.celular"
+          :rules="[inputRules.required]"
+        />
+
+        <DefaultCepInput
+          v-model="form.postal_code"
+          class="col-md-6 col-12"
+          @rua="(v) => (form.street = v)"
+          @bairro="(v) => (form.neighborhood = v)"
+          @uf="(v) => stateSelectRef?.selectStateByCode(v)"
+        />
+
+        <DefaultInput
+          v-model="form.street"
+          label="Endereço"
+          class="col-md-6 col-12"
+        />
+
+        <DefaultInput
+          v-model="form.address_number"
+          label="Número"
+          class="col-md-6 col-12"
+        />
+
+        <DefaultInput
+          v-model="form.neighborhood"
+          label="Bairro"
+          class="col-md-6 col-12"
+        />
+
+        <CitySelect
+          ref="citySelectRef"
+          v-model="form.city"
+          label="Cidade"
+          class="col-md-6 col-12"
+          outlined
+          :state="form.state"
+          :initial-id="form.city_id ?? null"
+        />
+
+        <StateSelect
+          ref="stateSelectRef"
+          v-model="form.state"
+          label="Estado"
+          class="col-md-6 col-12"
+          outlined
+          :initial-id="form.state_id ?? null"
+        />
+
+        <DefaultInput
+          v-model="form.complement"
+          label="Complemento"
+          class="col-md-6 col-12"
+        />
+
+        <DefaultInput
+          v-model="form.notes"
+          label="Observação"
+          class="col-12"
+          type="textarea"
+          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 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 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 masks from "src/helpers/masks";
+import { formatDateYMDtoDMY, formatDateDMYtoYMD } from "src/helpers/utils";
+
+const props = defineProps({
+  studentId: {
+    type: Number,
+    required: true,
+  },
+});
+
+const { inputRules } = useInputRules();
+const formRef = useTemplateRef("formRef");
+const stateSelectRef = useTemplateRef("stateSelectRef");
+const citySelectRef = useTemplateRef("citySelectRef");
+
+const responsibleId = ref(null);
+
+const genderOptions = [
+  { label: "Masculino", value: "male" },
+  { label: "Feminino", value: "female" },
+  { label: "Outro", value: "other" },
+  { label: "Prefiro não informar", value: "no_preference" },
+];
+
+const emptyForm = () => ({
+  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: null,
+  city_id: null,
+  state: null,
+  state_id: null,
+  complement: null,
+  notes: null,
+});
+
+const form = ref(emptyForm());
+
+const { loading, execute } = useSubmitHandler({ formRef });
+
+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,
+        cpf: data.cpf ?? null,
+        gender: data.gender ?? "no_preference",
+        degree: data.degree ?? null,
+        email: data.email ?? null,
+        phone: data.phone ?? null,
+        postal_code: data.postal_code ?? null,
+        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,
+        complement: data.complement ?? null,
+        notes: data.notes ?? null,
+      };
+    }
+  } catch (error) {
+    console.error("Failed to load responsible:", error);
+  }
+}
+
+function buildPayload() {
+  return {
+    student_id: props.studentId,
+    name: form.value.name,
+    birth_date: form.value.birth_date
+      ? formatDateDMYtoYMD(form.value.birth_date)
+      : null,
+    cpf: form.value.cpf,
+    gender: form.value.gender,
+    degree: form.value.degree,
+    email: form.value.email,
+    phone: form.value.phone,
+    postal_code: form.value.postal_code,
+    street: form.value.street,
+    address_number: form.value.address_number,
+    neighborhood: form.value.neighborhood,
+    city_id: form.value.city?.value ?? form.value.city_id ?? null,
+    state_id: form.value.state?.value ?? form.value.state_id ?? null,
+    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);
+</script>

+ 266 - 0
src/pages/students/tabs/StudentDataTab.vue

@@ -0,0 +1,266 @@
+<template>
+  <q-form ref="formRef" @submit="onSave">
+    <div class="row q-col-gutter-sm">
+      <DefaultInput
+        v-model="form.name"
+        label="Nome do aluno"
+        class="col-md-5 col-12"
+      />
+
+      <DefaultInputDatePicker
+        v-model="form.birthdate"
+        label="Data de Nascimento"
+        class="col-md-5 col-12"
+      />
+
+      <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"
+        :mask="masks.Brasil.cpf"
+      />
+
+      <DefaultSelect
+        v-model="form.gender"
+        label="Gênero"
+        class="col-md-6 col-12"
+        emit-value
+        map-options
+        :options="genderOptions"
+      />
+
+      <DefaultInput
+        v-model="form.email"
+        label="E-mail"
+        class="col-md-6 col-12"
+      />
+
+      <DefaultInput
+        v-model="form.phone"
+        label="Celular com DDD"
+        class="col-md-6 col-12"
+        :mask="masks.Brasil.celular"
+      />
+
+      <DefaultCepInput
+        v-model="form.cep"
+        class="col-md-3 col-12"
+        @rua="(v) => (form.address = v)"
+        @bairro="(v) => (form.neighborhood = v)"
+        @uf="(v) => stateSelectRef?.selectStateByCode(v)"
+      />
+
+      <DefaultInput
+        v-model="form.address"
+        label="Endereço"
+        class="col-md-6 col-12"
+      />
+
+      <DefaultInput
+        v-model="form.address_number"
+        label="Número"
+        class="col-md-3 col-12"
+      />
+
+      <DefaultInput
+        v-model="form.neighborhood"
+        label="Bairro"
+        class="col-md-6 col-12"
+      />
+
+      <StateSelect
+        ref="stateSelectRef"
+        v-model="form.state"
+        label="Estado"
+        class="col-md-6 col-12"
+        outlined
+        :initial-id="props.student.state_id ?? null"
+      />
+
+      <DefaultInput
+        v-model="form.complement"
+        label="Complemento"
+        class="col-md-6 col-12"
+      />
+
+      <DefaultInput
+        v-model="form.payer"
+        label="Pagador"
+        class="col-md-6 col-12"
+      />
+
+      <DefaultSelect
+        v-model="form.how_found"
+        label="Como nos conheceu?"
+        class="col-12"
+        emit-value
+        map-options
+        :options="howFoundOptions"
+      />
+
+      <DefaultInput
+        v-model="form.notes"
+        label="Observações"
+        class="col-12"
+        type="textarea"
+        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 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 StateSelect from "src/components/selects/StateSelect.vue";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { updateStudent } from "src/api/student";
+import masks from "src/helpers/masks";
+import { formatDateYMDtoDMY, formatDateDMYtoYMD } from "src/helpers/utils";
+
+const props = defineProps({
+  student: {
+    type: Object,
+    required: true,
+  },
+});
+
+const emit = defineEmits(["saved"]);
+
+const formRef = useTemplateRef("formRef");
+const fileInputRef = useTemplateRef("fileInputRef");
+const stateSelectRef = useTemplateRef("stateSelectRef");
+
+const avatarPreview = ref(props.student.photo_url ?? null);
+const avatarFile = ref(null);
+
+const genderOptions = [
+  { label: "Prefiro não informar", value: "no_preference" },
+  { label: "Masculino", value: "male" },
+  { label: "Feminino", value: "female" },
+  { label: "Outro", value: "other" },
+];
+
+const howFoundOptions = [
+  { label: "Indicação", value: "referral" },
+  { label: "Redes Sociais", value: "social_media" },
+  { label: "Google", value: "google" },
+  { label: "Outro", value: "other" },
+];
+
+const form = ref({
+  name: props.student.name ?? 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,
+  phone: props.student.phone ?? null,
+  cep: props.student.postal_code ?? null,
+  address: props.student.street ?? null,
+  address_number: props.student.address_number ?? null,
+  neighborhood: props.student.neighborhood ?? null,
+  state: 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"),
+});
+
+function triggerFileInput() {
+  fileInputRef.value?.click();
+}
+
+function onAvatarChange(event) {
+  const file = event.target.files[0];
+  if (file) {
+    avatarFile.value = file;
+    avatarPreview.value = URL.createObjectURL(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?.value) {
+      formData.append("state_id", form.value.state.value);
+    }
+    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;
+  }
+
+  return {
+    name: form.value.name,
+    birth_date: form.value.birthdate ? formatDateDMYtoYMD(form.value.birthdate) : null,
+    document_number: form.value.cpf,
+    gender: form.value.gender,
+    email: form.value.email || null,
+    phone: form.value.phone,
+    postal_code: form.value.cep,
+    street: form.value.address,
+    address_number: form.value.address_number,
+    neighborhood: form.value.neighborhood,
+    state_id: form.value.state?.value ?? null,
+    complement: form.value.complement,
+    payer_name: form.value.payer,
+    how_did_you_know_us: form.value.how_found,
+    notes: form.value.notes,
+  };
+}
+
+async function onSave() {
+  await execute(() => updateStudent(buildPayload(), props.student.id));
+}
+</script>

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

@@ -0,0 +1,9 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Editar Unidade" />
+  </div>
+</template>
+
+<script setup>
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+</script>

+ 25 - 0
src/router/routes/unit.route.js

@@ -0,0 +1,25 @@
+export default [
+  {
+    path: "/unit/:id/edit",
+    name: "UnitEditPage",
+    component: () => import("pages/unit/UnitActionPage.vue"),
+    meta: {
+      title: {
+        value: "Editar Unidade",
+        translate: false,
+      },
+      requireAuth: true,
+      requiredPermission: "unit",
+      breadcrumbs: [
+        {
+          name: "FranchiseePage",
+          title: "Franqueados",
+        },
+        {
+          name: "UnitEditPage",
+          title: "Editar Unidade",
+        },
+      ],
+    },
+  },
+];

+ 9 - 10
src/stores/navigation.js

@@ -4,14 +4,6 @@ import { permissionStore } from "src/stores/permission";
 
 export const navigationStore = defineStore("navigation", () => {
   const navigationStructure = Object.freeze([
-    // {
-    //   type: "single",
-    //   title: "ui.navigation.home",
-    //   name: "HomePage",
-    //   icon: "mdi-home-outline",
-    //   disable: false,
-    //   permission: true,
-    // },
     {
       type: "single",
       title: "ui.navigation.dashboard",
@@ -34,8 +26,15 @@ export const navigationStore = defineStore("navigation", () => {
       name: "UsersPage",
       icon: "mdi-account-multiple-outline",
       disable: false,
-      permission: false,
-      permissionScope: "config.user",
+      permission: true,
+    },
+    {
+      type: "single",
+      title: "Dados da Unidade",
+      name: "UnitEditPage",
+      icon: "mdi-cog-outline",
+      disable: false,
+      permission: true,
     },
   ]);