|
|
@@ -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>
|