ClassAttendanceDialog.vue 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. <template>
  2. <q-dialog ref="dialogRef" @hide="onDialogHide">
  3. <div style="width: 100%; max-width: 560px">
  4. <q-card style="display: flex; flex-direction: column; max-height: 88vh">
  5. <DefaultDialogHeader
  6. :title="`Registrar Presença - ${classData.title}`"
  7. @close="onDialogCancel"
  8. />
  9. <q-card-section class="q-pt-none" style="flex: 1; overflow-y: auto">
  10. <!-- Cabeçalho da aula -->
  11. <div class="attendance-head">
  12. <div class="attendance-head__badge">{{ startTime }}</div>
  13. <div class="column">
  14. <span class="text-subtitle1 text-primary text-weight-medium">
  15. {{ classData.title }}
  16. </span>
  17. <div class="row items-center q-gutter-x-md text-caption text-foreground">
  18. <span class="row items-center" style="gap: 4px">
  19. <q-icon name="mdi-map-marker-outline" color="secondary" size="15px" />
  20. {{ classData.room || "—" }}
  21. </span>
  22. <span class="row items-center" style="gap: 4px">
  23. <q-icon name="mdi-account-outline" color="secondary" size="15px" />
  24. {{ classData.instructor || "—" }}
  25. </span>
  26. <span class="row items-center" style="gap: 4px">
  27. <q-icon name="mdi-clock-outline" color="secondary" size="15px" />
  28. {{ timeRange }}
  29. </span>
  30. </div>
  31. </div>
  32. </div>
  33. <!-- Lista de alunos -->
  34. <div class="q-mt-md row items-center" style="gap: 6px">
  35. <q-icon name="mdi-account-group-outline" color="primary" size="18px" />
  36. <span class="text-weight-medium text-primary">Alunos ({{ students.length }})</span>
  37. </div>
  38. <q-separator class="q-mt-sm" />
  39. <div v-if="loading" class="flex flex-center q-pa-lg">
  40. <q-spinner color="primary" size="32px" />
  41. </div>
  42. <div v-else-if="!students.length" class="text-center text-grey-6 q-pa-lg">
  43. Nenhum aluno com contrato ativo neste pacote.
  44. </div>
  45. <q-markup-table v-else flat dense class="attendance-table">
  46. <thead>
  47. <tr>
  48. <th class="text-left">Aluno</th>
  49. <th class="text-center">Aulas</th>
  50. <th class="text-center">Presença</th>
  51. <th class="text-center">Justificar</th>
  52. </tr>
  53. </thead>
  54. <tbody>
  55. <tr v-for="row in students" :key="row.student_id">
  56. <td class="text-left">{{ row.name }}</td>
  57. <td class="text-center">{{ row.classes_count }}</td>
  58. <!-- Presença: verde = presente, vermelho = falta -->
  59. <td class="text-center">
  60. <div class="row no-wrap items-center justify-center" style="gap: 6px">
  61. <q-btn
  62. :disable="!canEdit"
  63. round
  64. unelevated
  65. size="sm"
  66. icon="mdi-check"
  67. :color="row.in_class ? 'positive' : 'grey-4'"
  68. :text-color="row.in_class ? 'white' : 'grey-7'"
  69. @click="markPresent(row)"
  70. >
  71. <q-tooltip>Presente</q-tooltip>
  72. </q-btn>
  73. <q-btn
  74. :disable="!canEdit"
  75. round
  76. unelevated
  77. size="sm"
  78. icon="mdi-close"
  79. :color="!row.in_class ? 'negative' : 'grey-4'"
  80. :text-color="!row.in_class ? 'white' : 'grey-7'"
  81. @click="markAbsent(row)"
  82. >
  83. <q-tooltip>Falta</q-tooltip>
  84. </q-btn>
  85. </div>
  86. </td>
  87. <!-- Justificar: só habilita para faltas -->
  88. <td class="text-center">
  89. <q-btn
  90. :disable="!canEdit || row.in_class"
  91. no-caps
  92. unelevated
  93. size="sm"
  94. :color="row.justified ? 'primary' : 'secondary'"
  95. text-color="white"
  96. :label="row.justified ? 'Justificada' : 'Justificar'"
  97. @click="openJustify(row)"
  98. >
  99. <q-tooltip v-if="row.in_class">
  100. Só é possível justificar faltas
  101. </q-tooltip>
  102. </q-btn>
  103. </td>
  104. </tr>
  105. </tbody>
  106. </q-markup-table>
  107. <!-- Resumo -->
  108. <div v-if="students.length" class="attendance-summary q-mt-md">
  109. <div class="text-weight-medium text-primary q-mb-xs">Resumo</div>
  110. <div class="row justify-between attendance-summary__line">
  111. <span>Total de Alunos</span><span>{{ students.length }}</span>
  112. </div>
  113. <div class="row justify-between attendance-summary__line">
  114. <span>Presente</span><span>{{ presentCount }}</span>
  115. </div>
  116. </div>
  117. </q-card-section>
  118. <q-card-actions align="right" class="q-px-md q-pb-md">
  119. <q-btn outline color="primary" label="Cancelar" no-caps @click="onDialogCancel" />
  120. <q-btn
  121. v-if="canEdit"
  122. color="primary"
  123. label="Salvar"
  124. no-caps
  125. :loading="saving"
  126. :disable="!students.length"
  127. @click="onSave"
  128. />
  129. </q-card-actions>
  130. </q-card>
  131. </div>
  132. </q-dialog>
  133. </template>
  134. <script setup>
  135. import { ref, computed, onMounted } from "vue";
  136. import { useDialogPluginComponent, useQuasar } from "quasar";
  137. import { format, parseISO } from "date-fns";
  138. import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
  139. import JustifyAttendanceDialog from "./JustifyAttendanceDialog.vue";
  140. import { getClassStudents, saveClassAttendance } from "src/api/class";
  141. import { permissionStore } from "src/stores/permission";
  142. defineEmits([...useDialogPluginComponent.emits]);
  143. const props = defineProps({
  144. classData: { type: Object, required: true },
  145. });
  146. const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
  147. useDialogPluginComponent();
  148. const $q = useQuasar();
  149. const permissions = permissionStore();
  150. const canEdit = computed(() =>
  151. permissions.getAccess("franchisee_classes", "edit"),
  152. );
  153. const students = ref([]);
  154. const loading = ref(false);
  155. const saving = ref(false);
  156. const startTime = computed(() =>
  157. props.classData.date_time_start
  158. ? format(parseISO(props.classData.date_time_start), "HH:mm")
  159. : "--:--",
  160. );
  161. const timeRange = computed(() => {
  162. const { date_time_start, date_time_end } = props.classData;
  163. if (!date_time_start || !date_time_end) return "";
  164. return `${format(parseISO(date_time_start), "HH:mm")} - ${format(parseISO(date_time_end), "HH:mm")}`;
  165. });
  166. const presentCount = computed(
  167. () => students.value.filter((s) => s.in_class).length,
  168. );
  169. const loadStudents = async () => {
  170. loading.value = true;
  171. try {
  172. students.value = await getClassStudents(props.classData.id);
  173. } finally {
  174. loading.value = false;
  175. }
  176. };
  177. // Clicar no verde marca presente automaticamente (e remove qualquer justificativa)
  178. const markPresent = (row) => {
  179. row.in_class = true;
  180. row.justified = false;
  181. row.notes = null;
  182. };
  183. const markAbsent = (row) => {
  184. row.in_class = false;
  185. };
  186. const openJustify = (row) => {
  187. // Só faz sentido justificar faltas
  188. if (row.in_class) return;
  189. $q.dialog({
  190. component: JustifyAttendanceDialog,
  191. componentProps: { notes: row.notes ?? "" },
  192. }).onOk((notes) => {
  193. row.in_class = false;
  194. row.justified = true;
  195. row.notes = notes;
  196. });
  197. };
  198. const onSave = async () => {
  199. saving.value = true;
  200. try {
  201. const attendances = students.value.map((s) => ({
  202. student_id: s.student_id,
  203. in_class: s.in_class,
  204. justified: s.justified,
  205. notes: s.notes ?? null,
  206. }));
  207. await saveClassAttendance(props.classData.id, attendances);
  208. $q.notify({ type: "positive", message: "Presença registrada." });
  209. onDialogOK(true);
  210. } catch {
  211. // erros já notificados pelo interceptor do axios
  212. } finally {
  213. saving.value = false;
  214. }
  215. };
  216. onMounted(loadStudents);
  217. </script>
  218. <style scoped>
  219. .attendance-head {
  220. display: flex;
  221. align-items: center;
  222. gap: 12px;
  223. background: #eaf8f8;
  224. border-radius: 10px;
  225. padding: 12px;
  226. margin-top: 8px;
  227. }
  228. .attendance-head__badge {
  229. background: #ace4e4;
  230. color: #003e29;
  231. font-weight: 600;
  232. border-radius: 8px;
  233. padding: 10px 12px;
  234. font-size: 14px;
  235. }
  236. .attendance-table :deep(th) {
  237. font-size: 12px;
  238. color: #909090;
  239. font-weight: 500;
  240. }
  241. .attendance-table :deep(td) {
  242. font-size: 13px;
  243. }
  244. .attendance-summary {
  245. background: #eaf8f8;
  246. border-radius: 8px;
  247. padding: 10px 12px;
  248. font-size: 13px;
  249. color: #003e29;
  250. }
  251. .attendance-summary__line {
  252. padding: 2px 0;
  253. }
  254. </style>