Просмотр исходного кода

feat(class): diálogos de nova atividade, presença e justificativa

Criação de atividade (pacote, horário, instrutor, sala, data),
registro de presença com marcação automática e justificativa
restrita a faltas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee 1 месяц назад
Родитель
Сommit
20351c9e42

+ 207 - 0
src/pages/classes/components/AddEditClassDialog.vue

@@ -0,0 +1,207 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 560px">
+      <q-card style="display: flex; flex-direction: column; overflow: hidden">
+        <DefaultDialogHeader
+          :title="classData ? 'Editar Aula' : 'Nova Aula'"
+          @close="onDialogCancel"
+        />
+
+        <q-form ref="formRef" @submit="onSubmit">
+          <q-card-section class="q-pt-sm">
+            <div class="row q-col-gutter-sm">
+              <!-- Nome da Atividade -->
+              <DefaultInput
+                v-model="form.title"
+                label="Nome da Atividade"
+                class="col-12"
+                :rules="[(v) => !!v || 'Campo obrigatório']"
+              />
+
+              <!-- Pacote vinculado -->
+              <DefaultSelect
+                v-model="form.class_package_unit_id"
+                label="Pacote relacionado"
+                :options="packageOptions"
+                option-value="id"
+                option-label="name"
+                emit-value
+                map-options
+                :loading="loadingPackages"
+                class="col-12"
+                :rules="[(v) => !!v || 'Campo obrigatório']"
+              />
+
+              <!-- Horários -->
+              <DefaultInput
+                v-model="form.start_time"
+                label="Hora de Início"
+                mask="##:##"
+                placeholder="08:40"
+                class="col-6"
+                :rules="[(v) => !!v || 'Campo obrigatório']"
+              >
+                <template #append>
+                  <q-icon name="mdi-clock-outline" class="cursor-pointer" color="secondary">
+                    <q-popup-proxy transition-show="scale" transition-hide="scale">
+                      <q-time v-model="form.start_time" format24h mask="HH:mm">
+                        <div class="row items-center justify-end">
+                          <q-btn v-close-popup label="OK" color="primary" flat />
+                        </div>
+                      </q-time>
+                    </q-popup-proxy>
+                  </q-icon>
+                </template>
+              </DefaultInput>
+
+              <DefaultInput
+                v-model="form.end_time"
+                label="Hora de Fim"
+                mask="##:##"
+                placeholder="09:40"
+                class="col-6"
+                :rules="[
+                  (v) => !!v || 'Campo obrigatório',
+                  (v) => !form.start_time || v > form.start_time || 'Fim deve ser após o início',
+                ]"
+              >
+                <template #append>
+                  <q-icon name="mdi-clock-outline" class="cursor-pointer" color="secondary">
+                    <q-popup-proxy transition-show="scale" transition-hide="scale">
+                      <q-time v-model="form.end_time" format24h mask="HH:mm">
+                        <div class="row items-center justify-end">
+                          <q-btn v-close-popup label="OK" color="primary" flat />
+                        </div>
+                      </q-time>
+                    </q-popup-proxy>
+                  </q-icon>
+                </template>
+              </DefaultInput>
+
+              <!-- Instrutor -->
+              <DefaultInput
+                v-model="form.instructor"
+                label="Nome do Instrutor"
+                class="col-6"
+              />
+
+              <!-- Sala -->
+              <DefaultInput
+                v-model="form.room"
+                label="Número da Sala"
+                class="col-6"
+              />
+
+              <!-- Data de início da aula -->
+              <DefaultInputDatePicker
+                v-model="form.date_display"
+                v-model:untreated-date="form.date"
+                label="Data de Início"
+                class="col-12"
+                :rules="[(v) => !!v || 'Campo obrigatório']"
+              />
+            </div>
+          </q-card-section>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md">
+            <q-btn outline color="primary" label="Cancelar" no-caps @click="onDialogCancel" />
+            <q-btn
+              type="submit"
+              color="primary"
+              label="Salvar"
+              no-caps
+              :loading="saving"
+            />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from "vue";
+import { useDialogPluginComponent, useQuasar } 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 { createClass, updateClass } from "src/api/class";
+import { getUnitPackages } from "src/api/package";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  classData: { type: Object, default: null },
+  // Data pré-selecionada ao adicionar a partir de um dia do calendário (YYYY-MM-DD)
+  initialDate: { type: String, default: null },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+
+const formRef = ref(null);
+const saving = ref(false);
+const loadingPackages = ref(false);
+const packageOptions = ref([]);
+
+const toDisplay = (isoDate) =>
+  isoDate ? isoDate.split("-").reverse().join("/") : null;
+
+const form = reactive({
+  title: props.classData?.title ?? "",
+  class_package_unit_id: props.classData?.class_package_unit_id ?? null,
+  instructor: props.classData?.instructor ?? "",
+  room: props.classData?.room ?? "",
+  start_time: props.classData?.date_time_start?.slice(11, 16) ?? "",
+  end_time: props.classData?.date_time_end?.slice(11, 16) ?? "",
+  date: props.classData?.date_time_start?.slice(0, 10) ?? props.initialDate ?? null,
+  date_display: toDisplay(
+    props.classData?.date_time_start?.slice(0, 10) ?? props.initialDate,
+  ),
+});
+
+const loadPackages = async () => {
+  loadingPackages.value = true;
+  try {
+    packageOptions.value = await getUnitPackages();
+  } finally {
+    loadingPackages.value = false;
+  }
+};
+
+const onSubmit = async () => {
+  const valid = await formRef.value.validate();
+  if (!valid) return;
+
+  const payload = {
+    title: form.title,
+    class_package_unit_id: form.class_package_unit_id,
+    instructor: form.instructor || null,
+    room: form.room || null,
+    date_time_start: `${form.date}T${form.start_time}:00`,
+    date_time_end: `${form.date}T${form.end_time}:00`,
+  };
+
+  saving.value = true;
+  try {
+    if (props.classData?.id) {
+      await updateClass(payload, props.classData.id);
+    } else {
+      await createClass(payload);
+    }
+    $q.notify({ type: "positive", message: "Aula salva com sucesso." });
+    onDialogOK(true);
+  } catch {
+    // O interceptor do axios já notifica os erros de validação/servidor
+  } finally {
+    saving.value = false;
+  }
+};
+
+onMounted(loadPackages);
+</script>

+ 279 - 0
src/pages/classes/components/ClassAttendanceDialog.vue

@@ -0,0 +1,279 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 560px">
+      <q-card style="display: flex; flex-direction: column; max-height: 88vh">
+        <DefaultDialogHeader
+          :title="`Registrar Presença - ${classData.title}`"
+          @close="onDialogCancel"
+        />
+
+        <q-card-section class="q-pt-none" style="flex: 1; overflow-y: auto">
+          <!-- Cabeçalho da aula -->
+          <div class="attendance-head">
+            <div class="attendance-head__badge">{{ startTime }}</div>
+            <div class="column">
+              <span class="text-subtitle1 text-primary text-weight-medium">
+                {{ classData.title }}
+              </span>
+              <div class="row items-center q-gutter-x-md text-caption text-foreground">
+                <span class="row items-center" style="gap: 4px">
+                  <q-icon name="mdi-map-marker-outline" color="secondary" size="15px" />
+                  {{ classData.room || "—" }}
+                </span>
+                <span class="row items-center" style="gap: 4px">
+                  <q-icon name="mdi-account-outline" color="secondary" size="15px" />
+                  {{ classData.instructor || "—" }}
+                </span>
+                <span class="row items-center" style="gap: 4px">
+                  <q-icon name="mdi-clock-outline" color="secondary" size="15px" />
+                  {{ timeRange }}
+                </span>
+              </div>
+            </div>
+          </div>
+
+          <!-- Lista de alunos -->
+          <div class="q-mt-md row items-center" style="gap: 6px">
+            <q-icon name="mdi-account-group-outline" color="primary" size="18px" />
+            <span class="text-weight-medium text-primary">Alunos ({{ students.length }})</span>
+          </div>
+
+          <q-separator class="q-mt-sm" />
+
+          <div v-if="loading" class="flex flex-center q-pa-lg">
+            <q-spinner color="primary" size="32px" />
+          </div>
+
+          <div v-else-if="!students.length" class="text-center text-grey-6 q-pa-lg">
+            Nenhum aluno com contrato ativo neste pacote.
+          </div>
+
+          <q-markup-table v-else flat dense class="attendance-table">
+            <thead>
+              <tr>
+                <th class="text-left">Aluno</th>
+                <th class="text-center">Aulas</th>
+                <th class="text-center">Presença</th>
+                <th class="text-center">Justificar</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="row in students" :key="row.student_id">
+                <td class="text-left">{{ row.name }}</td>
+                <td class="text-center">{{ row.classes_count }}</td>
+
+                <!-- Presença: verde = presente, vermelho = falta -->
+                <td class="text-center">
+                  <div class="row no-wrap items-center justify-center" style="gap: 6px">
+                    <q-btn
+                      round
+                      unelevated
+                      size="sm"
+                      icon="mdi-check"
+                      :color="row.in_class ? 'positive' : 'grey-4'"
+                      :text-color="row.in_class ? 'white' : 'grey-7'"
+                      @click="markPresent(row)"
+                    >
+                      <q-tooltip>Presente</q-tooltip>
+                    </q-btn>
+                    <q-btn
+                      round
+                      unelevated
+                      size="sm"
+                      icon="mdi-close"
+                      :color="!row.in_class ? 'negative' : 'grey-4'"
+                      :text-color="!row.in_class ? 'white' : 'grey-7'"
+                      @click="markAbsent(row)"
+                    >
+                      <q-tooltip>Falta</q-tooltip>
+                    </q-btn>
+                  </div>
+                </td>
+
+                <!-- Justificar: só habilita para faltas -->
+                <td class="text-center">
+                  <q-btn
+                    no-caps
+                    unelevated
+                    size="sm"
+                    :color="row.justified ? 'primary' : 'secondary'"
+                    text-color="white"
+                    :label="row.justified ? 'Justificada' : 'Justificar'"
+                    :disable="row.in_class"
+                    @click="openJustify(row)"
+                  >
+                    <q-tooltip v-if="row.in_class">
+                      Só é possível justificar faltas
+                    </q-tooltip>
+                  </q-btn>
+                </td>
+              </tr>
+            </tbody>
+          </q-markup-table>
+
+          <!-- Resumo -->
+          <div v-if="students.length" class="attendance-summary q-mt-md">
+            <div class="text-weight-medium text-primary q-mb-xs">Resumo</div>
+            <div class="row justify-between attendance-summary__line">
+              <span>Total de Alunos</span><span>{{ students.length }}</span>
+            </div>
+            <div class="row justify-between attendance-summary__line">
+              <span>Presente</span><span>{{ presentCount }}</span>
+            </div>
+          </div>
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-btn outline color="primary" label="Cancelar" no-caps @click="onDialogCancel" />
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            :loading="saving"
+            :disable="!students.length"
+            @click="onSave"
+          />
+        </q-card-actions>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { format, parseISO } from "date-fns";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import JustifyAttendanceDialog from "./JustifyAttendanceDialog.vue";
+import { getClassStudents, saveClassAttendance } from "src/api/class";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  classData: { type: Object, required: true },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+
+const students = ref([]);
+const loading = ref(false);
+const saving = ref(false);
+
+const startTime = computed(() =>
+  props.classData.date_time_start
+    ? format(parseISO(props.classData.date_time_start), "HH:mm")
+    : "--:--",
+);
+
+const timeRange = computed(() => {
+  const { date_time_start, date_time_end } = props.classData;
+  if (!date_time_start || !date_time_end) return "";
+  return `${format(parseISO(date_time_start), "HH:mm")} - ${format(parseISO(date_time_end), "HH:mm")}`;
+});
+
+const presentCount = computed(
+  () => students.value.filter((s) => s.in_class).length,
+);
+
+const loadStudents = async () => {
+  loading.value = true;
+  try {
+    students.value = await getClassStudents(props.classData.id);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// Clicar no verde marca presente automaticamente (e remove qualquer justificativa)
+const markPresent = (row) => {
+  row.in_class = true;
+  row.justified = false;
+  row.notes = null;
+};
+
+const markAbsent = (row) => {
+  row.in_class = false;
+};
+
+const openJustify = (row) => {
+  // Só faz sentido justificar faltas
+  if (row.in_class) return;
+
+  $q.dialog({
+    component: JustifyAttendanceDialog,
+    componentProps: { notes: row.notes ?? "" },
+  }).onOk((notes) => {
+    row.in_class = false;
+    row.justified = true;
+    row.notes = notes;
+  });
+};
+
+const onSave = async () => {
+  saving.value = true;
+  try {
+    const attendances = students.value.map((s) => ({
+      student_id: s.student_id,
+      in_class: s.in_class,
+      justified: s.justified,
+      notes: s.notes ?? null,
+    }));
+    await saveClassAttendance(props.classData.id, attendances);
+    $q.notify({ type: "positive", message: "Presença registrada." });
+    onDialogOK(true);
+  } catch {
+    // erros já notificados pelo interceptor do axios
+  } finally {
+    saving.value = false;
+  }
+};
+
+onMounted(loadStudents);
+</script>
+
+<style scoped>
+.attendance-head {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  background: #eaf8f8;
+  border-radius: 10px;
+  padding: 12px;
+  margin-top: 8px;
+}
+
+.attendance-head__badge {
+  background: #ace4e4;
+  color: #003e29;
+  font-weight: 600;
+  border-radius: 8px;
+  padding: 10px 12px;
+  font-size: 14px;
+}
+
+.attendance-table :deep(th) {
+  font-size: 12px;
+  color: #909090;
+  font-weight: 500;
+}
+
+.attendance-table :deep(td) {
+  font-size: 13px;
+}
+
+.attendance-summary {
+  background: #eaf8f8;
+  border-radius: 8px;
+  padding: 10px 12px;
+  font-size: 13px;
+  color: #003e29;
+}
+
+.attendance-summary__line {
+  padding: 2px 0;
+}
+</style>

+ 66 - 0
src/pages/classes/components/JustifyAttendanceDialog.vue

@@ -0,0 +1,66 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 520px">
+      <q-card>
+        <DefaultDialogHeader title="Justifica Registrar Presença" @close="onDialogCancel" />
+
+        <q-card-section class="q-pt-sm">
+          <q-input
+            v-model="text"
+            type="textarea"
+            outlined
+            autogrow
+            autofocus
+            bg-color="white"
+            :placeholder="placeholder"
+            input-style="min-height: 110px"
+          >
+            <template #append>
+              <q-icon name="mdi-pencil-outline" color="secondary" />
+            </template>
+          </q-input>
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-btn outline color="primary" label="Cancelar" no-caps @click="onDialogCancel" />
+          <q-btn
+            color="primary"
+            label="Justificar"
+            no-caps
+            :disable="!text.trim()"
+            @click="onConfirm"
+          />
+        </q-card-actions>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  // Justificativa já existente (ao reabrir)
+  notes: { type: String, default: "" },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const placeholder =
+  "Ex: O(A) discente [Nome do Aluno] apresentou atestado médico (CID: [Opcional]) " +
+  "justificando sua ausência na aula do dia [Data]. A falta foi abonada/justificada " +
+  "conforme as normas da instituição, garantindo ao aluno o direito de [realizar " +
+  "prova substitutiva / entrega de trabalho em nova data].";
+
+const text = ref(props.notes ?? "");
+
+const onConfirm = () => {
+  onDialogOK(text.value.trim());
+};
+</script>