Răsfoiți Sursa

feat: adiciona tab nova para alunos

ebagabee 2 săptămâni în urmă
părinte
comite
7419ccfcbb

+ 52 - 0
src/components/shared/CustomTabComponent.vue

@@ -0,0 +1,52 @@
+<template>
+  <div class="row no-wrap bg-secondary-3" style="border-radius: 8px; gap: 4px">
+    <div
+      v-for="tab in tabs"
+      :key="tab.name"
+      class="col flex items-center justify-center cursor-pointer tab-item"
+      :class="activeTab === tab.name ? 'tab-active' : 'tab-inactive'"
+      style="height: 36px; border-radius: 6px"
+      @click="emit('update:activeTab', tab.name)"
+    >
+      <span class="text-center text-weight-medium" style="font-size: 13px">
+        {{ tab.label }}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  tabs: {
+    type: Array,
+    required: true,
+  },
+  activeTab: {
+    type: String,
+    default: null,
+  },
+});
+
+const emit = defineEmits(["update:activeTab"]);
+</script>
+
+<style scoped>
+.tab-item {
+  transition:
+    background-color 0.2s,
+    color 0.2s;
+}
+
+.tab-active {
+  background-color: #ff8340;
+}
+
+.tab-inactive {
+  background-color: transparent;
+  color: #555;
+}
+
+.tab-inactive:hover {
+  background-color: rgba(0, 0, 0, 0.06);
+}
+</style>

+ 15 - 2
src/pages/students/StudentPage.vue

@@ -30,7 +30,7 @@
           </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-btn
@@ -38,7 +38,7 @@
                 icon="mdi-account-edit-outline"
                 style="width: 36px"
                 class="q-mr-sm"
-                @click.prevent.stop="() => {}"
+                @click.prevent.stop="onEditStudent(row)"
               />
               <q-btn
                 outline
@@ -66,6 +66,10 @@ const AddEditStudentDialog = defineAsyncComponent(
   () => import("src/pages/students/components/AddEditStudentDialog.vue"),
 );
 
+const EditStudentDialog = defineAsyncComponent(
+  () => import("src/pages/students/components/EditStudentDialog.vue"),
+);
+
 const $q = useQuasar();
 
 const rows = ref([]);
@@ -117,5 +121,14 @@ function onAddStudent() {
   });
 }
 
+function onEditStudent(student) {
+  $q.dialog({
+    component: EditStudentDialog,
+    componentProps: { student },
+  }).onOk(() => {
+    loadStudents();
+  });
+}
+
 onMounted(loadStudents);
 </script>

+ 366 - 0
src/pages/students/components/EditStudentDialog.vue

@@ -0,0 +1,366 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 900px; max-width: 95vw"
+    >
+      <DefaultDialogHeader title="Editar dados do Aluno" @close="onDialogCancel" />
+
+      <!-- Tab navigation -->
+      <div class="q-px-md q-pt-sm q-pb-xs">
+        <CustomTabComponent v-model:active-tab="activeTab" :tabs="tabs" />
+      </div>
+
+      <!-- ─── TAB: PERFIL DO ALUNO ─── -->
+      <q-form v-if="activeTab === 'perfil'" 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>
+
+          <div class="row q-col-gutter-sm">
+            <DefaultInput
+              v-model="form.name"
+              label="Nome do aluno"
+              class="col-md-5 col-12"
+              :rules="[inputRules.required]"
+            />
+
+            <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"
+              :rules="[inputRules.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"
+              type="email"
+              :rules="[inputRules.email]"
+            />
+
+            <DefaultInput
+              v-model="form.phone"
+              label="Celular com DDD"
+              class="col-md-6 col-12"
+              :mask="masks.Brasil.celular"
+            />
+
+            <DefaultInput
+              v-model="form.cep"
+              label="CEP"
+              class="col-md-3 col-12"
+              :mask="masks.Brasil.cep"
+              :rules="[inputRules.cep]"
+            />
+
+            <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="Cidade / Estado"
+              class="col-md-6 col-12"
+              outlined
+            />
+
+            <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
+            unelevated
+            color="primary"
+            label="SALVAR"
+            no-caps
+            type="submit"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </q-form>
+
+      <!-- ─── TAB: RESPONSÁVEL ─── -->
+      <template v-else-if="activeTab === 'responsavel'">
+        <q-card-section style="min-height: 300px" class="flex flex-center column q-gutter-sm">
+          <q-icon name="mdi-account-supervisor-outline" size="64px" color="grey-4" />
+          <div class="text-subtitle1 text-grey-6">Responsável</div>
+          <div class="text-caption text-grey-5 text-center">
+            Funcionalidade em desenvolvimento.
+          </div>
+        </q-card-section>
+        <q-separator />
+        <q-card-actions align="right">
+          <q-btn outline color="primary" label="FECHAR" no-caps @click="onDialogCancel" />
+        </q-card-actions>
+      </template>
+
+      <!-- ─── TAB: CONTATOS ─── -->
+      <template v-else-if="activeTab === 'contatos'">
+        <q-card-section style="min-height: 300px" class="flex flex-center column q-gutter-sm">
+          <q-icon name="mdi-contacts-outline" size="64px" color="grey-4" />
+          <div class="text-subtitle1 text-grey-6">Contatos</div>
+          <div class="text-caption text-grey-5 text-center">
+            Funcionalidade em desenvolvimento.
+          </div>
+        </q-card-section>
+        <q-separator />
+        <q-card-actions align="right">
+          <q-btn outline color="primary" label="FECHAR" no-caps @click="onDialogCancel" />
+        </q-card-actions>
+      </template>
+
+      <!-- ─── TAB: HISTÓRICO ─── -->
+      <template v-else-if="activeTab === 'historico'">
+        <q-card-section style="min-height: 300px" class="flex flex-center column q-gutter-sm">
+          <q-icon name="mdi-history" size="64px" color="grey-4" />
+          <div class="text-subtitle1 text-grey-6">Histórico</div>
+          <div class="text-caption text-grey-5 text-center">
+            Funcionalidade em desenvolvimento.
+          </div>
+        </q-card-section>
+        <q-separator />
+        <q-card-actions align="right">
+          <q-btn outline color="primary" label="FECHAR" no-caps @click="onDialogCancel" />
+        </q-card-actions>
+      </template>
+
+      <!-- ─── TAB: MÍDIAS ─── -->
+      <template v-else-if="activeTab === 'midias'">
+        <q-card-section style="min-height: 300px" class="flex flex-center column q-gutter-sm">
+          <q-icon name="mdi-image-multiple-outline" size="64px" color="grey-4" />
+          <div class="text-subtitle1 text-grey-6">Mídias</div>
+          <div class="text-caption text-grey-5 text-center">
+            Funcionalidade em desenvolvimento.
+          </div>
+        </q-card-section>
+        <q-separator />
+        <q-card-actions align="right">
+          <q-btn outline color="primary" label="FECHAR" no-caps @click="onDialogCancel" />
+        </q-card-actions>
+      </template>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, useTemplateRef, 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 DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import StateSelect from "src/components/selects/StateSelect.vue";
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { updateStudent } from "src/api/student";
+import masks from "src/helpers/masks";
+import { formatDateDMYtoYMD, formatDateYMDtoDMY } from "src/helpers/utils";
+
+const props = defineProps({
+  student: {
+    type: Object,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const { inputRules } = useInputRules();
+
+const formRef = useTemplateRef("formRef");
+const fileInputRef = useTemplateRef("fileInputRef");
+const stateSelectRef = useTemplateRef("stateSelectRef");
+const avatarPreview = ref(null);
+
+const activeTab = ref("perfil");
+
+const tabs = [
+  { name: "perfil", label: "Perfil do Aluno" },
+  { name: "responsavel", label: "Responsável" },
+  { name: "contatos", label: "Contatos" },
+  { name: "historico", label: "Histórico" },
+  { name: "midias", label: "Mídias" },
+];
+
+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: () => {
+    onDialogOK(true);
+  },
+});
+
+function buildPayload() {
+  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,
+  };
+}
+
+function triggerFileInput() {
+  fileInputRef.value?.click();
+}
+
+function onAvatarChange(event) {
+  const file = event.target.files[0];
+  if (file) {
+    avatarPreview.value = URL.createObjectURL(file);
+  }
+}
+
+async function onSave() {
+  await execute(() => updateStudent(buildPayload(), props.student.id));
+}
+
+onMounted(() => {
+  if (props.student.state_id) {
+    stateSelectRef.value?.selectStateById(props.student.state_id);
+  }
+});
+</script>