Attendance: Improve attendance tool with Vue & Symfony integration - refs #6048

pull/6053/head
Christian Beeznest 8 months ago
parent c3f6949dce
commit 05e9fab473
  1. 156
      assets/vue/components/attendance/AttendanceCalendarForm.vue
  2. 205
      assets/vue/components/attendance/AttendanceForm.vue
  3. 133
      assets/vue/components/attendance/AttendanceTable.vue
  4. 2
      assets/vue/components/basecomponents/BaseIcon.vue
  5. 11
      assets/vue/components/basecomponents/ChamiloIcons.js
  6. 44
      assets/vue/router/attendance.js
  7. 2
      assets/vue/router/index.js
  8. 253
      assets/vue/services/attendanceService.js
  9. 24
      assets/vue/services/gradebookService.js
  10. 32
      assets/vue/views/attendance/AttendanceCalendarAdd.vue
  11. 262
      assets/vue/views/attendance/AttendanceCalendarList.vue
  12. 32
      assets/vue/views/attendance/AttendanceCreate.vue
  13. 77
      assets/vue/views/attendance/AttendanceEdit.vue
  14. 11
      assets/vue/views/attendance/AttendanceExport.vue
  15. 124
      assets/vue/views/attendance/AttendanceList.vue
  16. 704
      assets/vue/views/attendance/AttendanceSheetList.vue
  17. 9
      config/services.yaml
  18. 241
      src/CoreBundle/Controller/AttendanceController.php
  19. 33
      src/CoreBundle/Controller/GradebookController.php
  20. 5
      src/CoreBundle/Entity/AbstractResource.php
  21. 30
      src/CoreBundle/Migrations/Schema/V200/Version20250126180000.php
  22. 65
      src/CoreBundle/Repository/GradeBookCategoryRepository.php
  23. 49
      src/CoreBundle/Repository/Node/UserRepository.php
  24. 133
      src/CoreBundle/State/CAttendanceStateProcessor.php
  25. 2
      src/CoreBundle/Tool/Attendance.php
  26. 105
      src/CourseBundle/Entity/CAttendance.php
  27. 21
      src/CourseBundle/Entity/CAttendanceCalendar.php
  28. 3
      src/CourseBundle/Entity/CAttendanceCalendarRelGroup.php
  29. 1
      src/CourseBundle/Entity/CAttendanceResult.php
  30. 29
      src/CourseBundle/Entity/CAttendanceSheet.php
  31. 40
      src/CourseBundle/Entity/CGroup.php
  32. 42
      src/CourseBundle/Repository/CAttendanceCalendarRelGroupRepository.php
  33. 184
      src/CourseBundle/Repository/CAttendanceCalendarRepository.php
  34. 38
      src/CourseBundle/Repository/CAttendanceSheetRepository.php

@ -0,0 +1,156 @@
<template>
<form class="flex flex-col gap-6 mt-6">
<!-- Start Date -->
<BaseCalendar
v-model="formData.startDate"
:label="t('Start Date')"
:show-time="true"
required
/>
<!-- Repeat Date -->
<BaseCheckbox
v-model="formData.repeatDate"
:label="t('Repeat Date')"
@change="toggleRepeatOptions"
/>
<div v-if="formData.repeatDate">
<!-- Repeat Type -->
<BaseSelect
v-model="formData.repeatType"
:label="t('Repeat Type')"
:options="repeatTypeOptions"
option-label="label"
option-value="value"
required
/>
<!-- Number of Days for Every X Days -->
<div v-if="formData.repeatType === 'every-x-days'">
<BaseInputNumber
v-model="formData.repeatDays"
:label="t('Number of days')"
type="number"
min="1"
required
/>
</div>
<!-- Repeat End Date -->
<BaseCalendar
v-model="formData.repeatEndDate"
:label="t('Repeat End Date')"
:show-time="true"
required
/>
</div>
<!-- Group -->
<BaseSelect
v-model="formData.group"
:label="t('Group')"
:options="groupOptions"
option-label="label"
option-value="value"
required
/>
<!-- Buttons -->
<LayoutFormButtons>
<BaseButton
:label="t('Back')"
icon="arrow-left"
type="black"
@click="$emit('back-pressed')"
/>
<BaseButton
:label="t('Save')"
icon="check"
type="success"
@click="submitForm"
/>
</LayoutFormButtons>
</form>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue"
import { useI18n } from "vue-i18n"
import attendanceService from "../../services/attendanceService"
import BaseCalendar from "../../components/basecomponents/BaseCalendar.vue"
import BaseCheckbox from "../../components/basecomponents/BaseCheckbox.vue"
import BaseSelect from "../../components/basecomponents/BaseSelect.vue"
import LayoutFormButtons from "../../components/layout/LayoutFormButtons.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import { useRoute } from "vue-router"
import BaseInputNumber from "../basecomponents/BaseInputNumber.vue"
const { t } = useI18n()
const emit = defineEmits(["back-pressed"])
const route = useRoute()
const parentResourceNodeId = ref(Number(route.params.node))
const formData = reactive({
startDate: "",
repeatDate: false,
repeatType: "",
repeatEndDate: "",
repeatDays: 0,
group: "",
})
const repeatTypeOptions = [
{ label: t("Daily"), value: "daily" },
{ label: t("Weekly"), value: "weekly" },
{ label: t("Bi-Weekly"), value: "bi-weekly" },
{ label: t("Every X Days"), value: "every-x-days" },
{ label: t("Monthly by Date"), value: "monthly-by-date" },
]
const groupOptions = ref([])
const toggleRepeatOptions = () => {
if (!formData.repeatDate) {
formData.repeatType = ""
formData.repeatEndDate = ""
formData.repeatDays = 0
}
}
const submitForm = async () => {
if (!formData.startDate) {
return
}
if (formData.repeatDate && (!formData.repeatType || !formData.repeatEndDate)) {
return
}
const payload = {
startDate: formData.startDate,
repeatDate: formData.repeatDate,
repeatType: formData.repeatType,
repeatEndDate: formData.repeatEndDate,
repeatDays: formData.repeatType === "every-x-days" ? formData.repeatDays : null,
group: formData.group ? parseInt(formData.group) : null,
}
try {
await attendanceService.addAttendanceCalendar(route.params.id, payload)
emit("back-pressed")
} catch (error) {
console.error("Error adding attendance calendar entry:", error)
}
}
const loadGroups = async () => {
try {
groupOptions.value = await attendanceService.fetchGroups(parentResourceNodeId.value)
} catch (error) {
console.error("Error loading groups:", error)
}
}
onMounted(loadGroups)
</script>

@ -0,0 +1,205 @@
<template>
<form
@submit.prevent="submitForm"
class="flex flex-col gap-6 mt-6"
>
<!-- Title -->
<BaseInputTextWithVuelidate
v-model="formData.title"
:label="t('Title')"
:vuelidate-property="v$.title"
required
/>
<!-- Description -->
<BaseTinyEditor
v-model="formData.description"
:title="t('Description')"
editor-id="attendance_description"
/>
<!-- Advanced Settings (only in creation mode) -->
<BaseAdvancedSettingsButton
v-if="!isEditMode"
v-model="showAdvancedSettings"
>
<div class="flex flex-row mb-4">
<label class="font-semibold w-28">{{ t("Gradebook Options") }}:</label>
<BaseCheckbox
id="attendance_qualify_gradebook"
v-model="formData.qualifyGradebook"
:label="t('Qualify Attendance Gradebook')"
name="attendance_qualify_gradebook"
@change="toggleGradebookOptions"
/>
</div>
<div
v-if="formData.qualifyGradebook"
class="ml-6"
>
<BaseSelect
v-model="formData.gradebookOption"
:label="t('Select Gradebook Option')"
:options="gradebookOptions"
option-label="label"
option-value="value"
/>
<BaseInputText
v-model="formData.gradebookTitle"
:label="t('Gradebook Column Title')"
/>
<BaseInputNumber
v-model="formData.gradeWeight"
:label="t('Grade Weight')"
:min="0"
:step="0.01"
/>
</div>
</BaseAdvancedSettingsButton>
<!-- Buttons -->
<LayoutFormButtons>
<BaseButton
:label="t('Back')"
icon="back"
type="black"
@click="emit('backPressed', route.query)"
/>
<BaseButton
:label="t('Save Attendance')"
icon="save"
type="success"
@click="submitForm"
/>
</LayoutFormButtons>
</form>
</template>
<script setup>
import { computed, onMounted, ref, reactive, watch } from "vue"
import { useI18n } from "vue-i18n"
import { required } from "@vuelidate/validators"
import useVuelidate from "@vuelidate/core"
import attendanceService from "../../services/attendanceService"
import BaseInputTextWithVuelidate from "../../components/basecomponents/BaseInputTextWithVuelidate.vue"
import BaseTinyEditor from "../../components/basecomponents/BaseTinyEditor.vue"
import BaseCheckbox from "../../components/basecomponents/BaseCheckbox.vue"
import BaseSelect from "../../components/basecomponents/BaseSelect.vue"
import BaseInputNumber from "../../components/basecomponents/BaseInputNumber.vue"
import LayoutFormButtons from "../../components/layout/LayoutFormButtons.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import BaseAdvancedSettingsButton from "../../components/basecomponents/BaseAdvancedSettingsButton.vue"
import BaseInputText from "../basecomponents/BaseInputText.vue"
import { useRoute } from "vue-router"
import { RESOURCE_LINK_PUBLISHED } from "../../constants/entity/resourcelink"
import { useCidReq } from "../../composables/cidReq"
import gradebookService from "../../services/gradebookService"
const { t } = useI18n()
const route = useRoute()
const { sid, cid } = useCidReq()
const emit = defineEmits(["backPressed"])
const props = defineProps({
initialData: {
type: Object,
default: () => ({}),
},
})
const parentResourceNodeId = ref(Number(route.params.node))
const resourceLinkList = ref([
{
sid,
cid,
visibility: RESOURCE_LINK_PUBLISHED,
},
])
const formData = reactive({
id: null,
title: "",
description: "",
qualifyGradebook: false,
gradebookOption: null,
gradebookTitle: "",
gradeWeight: 0.0,
})
const gradebookOptions = ref([])
const rules = {
title: { required },
description: {},
qualifyGradebook: {},
gradebookOption: {},
gradebookTitle: {},
gradeWeight: {},
}
const v$ = useVuelidate(rules, formData)
const showAdvancedSettings = ref(false)
const isEditMode = computed(() => !!props.initialData?.id)
onMounted(async () => {
if (!isEditMode.value) {
try {
const categories = await gradebookService.getCategories(cid, sid)
gradebookOptions.value = categories.map((cat) => ({
label: cat.title,
value: cat.id,
}))
} catch (error) {
console.error("Error loading gradebook categories:", error)
}
}
})
const toggleGradebookOptions = () => {
if (!formData.qualifyGradebook) {
formData.gradebookOption = null
formData.gradebookTitle = ""
formData.gradeWeight = 0.0
}
}
watch(
() => props.initialData,
(newData) => {
Object.assign(formData, newData || {})
},
{ immediate: true },
)
const submitForm = async () => {
v$.value.$touch()
if (v$.value.$invalid) {
return
}
const postData = {
title: formData.title,
description: formData.description,
parentResourceNodeId: parentResourceNodeId.value,
resourceLinkList: resourceLinkList.value,
sid: route.query.sid || null,
cid: route.query.cid || null,
attendanceQualifyTitle: formData.gradebookTitle,
attendanceWeight: formData.gradeWeight,
}
try {
if (props.initialData?.id) {
await attendanceService.updateAttendance(props.initialData.id, postData)
} else {
await attendanceService.createAttendance(postData)
}
emit("backPressed", route.query)
} catch (error) {
console.error("Error submitting attendance:", error)
}
}
</script>

@ -0,0 +1,133 @@
<template>
<DataTable
:value="attendances"
:paginator="true"
:rows="10"
:rows-per-page-options="[5, 10, 20, 50]"
:total-records="totalRecords"
class="p-datatable-sm"
:loading="loading"
data-key="id"
current-page-report-template="Showing {first} to {last} of {totalRecords}"
responsive-layout="scroll"
@page="onPageChange"
>
<!-- Column for Name -->
<Column
field="title"
header="Name"
sortable
>
<template #body="slotProps">
<RouterLink
:to="{
name: 'AttendanceSheetList',
params: {
node: route.params.node,
id: slotProps.data.id,
},
query: {
cid: route.query.cid,
sid: route.query.sid,
gid: route.query.gid,
},
}"
class="text-blue-500 underline"
>
{{ slotProps.data.title }}
</RouterLink>
</template>
</Column>
<!-- Column for Description -->
<Column
field="description"
header="Description"
sortable
>
<template #body="slotProps">
<div v-html="slotProps.data.description"></div>
</template>
</Column>
<!-- Column for # attended -->
<Column
field="results.length"
header="# attended"
sortable
>
<template #body="slotProps">
<center>{{ slotProps.data.results ? slotProps.data.results.length : 0 }}</center>
</template>
</Column>
<!-- Column for Detail -->
<Column header="Detail">
<template #body="slotProps">
<div class="flex gap-2 justify-center">
<Button
icon="pi pi-pencil"
class="p-button-rounded p-button-sm p-button-info"
@click="onEdit(slotProps.data)"
tooltip="Edit"
/>
<Button
:icon="getVisibilityIcon(slotProps.data)"
class="p-button-rounded p-button-sm"
:class="getVisibilityClass(slotProps.data)"
@click="onView(slotProps.data)"
:tooltip="getVisibilityTooltip(slotProps.data)"
/>
<Button
icon="pi pi-trash"
class="p-button-rounded p-button-sm p-button-danger"
@click="onDelete(slotProps.data)"
tooltip="Delete"
/>
</div>
</template>
</Column>
</DataTable>
</template>
<script setup>
import { useRoute } from "vue-router"
const route = useRoute()
const props = defineProps({
attendances: {
type: Array,
required: true,
},
loading: {
type: Boolean,
default: false,
},
totalRecords: {
type: Number,
default: 0,
},
})
const emit = defineEmits(["edit", "view", "delete", "pageChange"])
const onEdit = (attendance) => emit("edit", attendance)
const onView = (attendance) => emit("view", attendance)
const onDelete = (attendance) => emit("delete", attendance)
const onPageChange = (event) => emit("pageChange", event)
const getVisibilityIcon = (attendance) => {
const visibility = attendance.resourceLinkListFromEntity?.[0]?.visibility || 0
return visibility === 2 ? "pi pi-eye" : "pi pi-eye-slash"
}
const getVisibilityClass = (attendance) => {
const visibility = attendance.resourceLinkListFromEntity?.[0]?.visibility || 0
return visibility === 2 ? "p-button-success" : "p-button-warning"
}
const getVisibilityTooltip = (attendance) => {
const visibility = attendance.resourceLinkListFromEntity?.[0]?.visibility || 0
return visibility === 2 ? "Visible" : "Hidden"
}
</script>

@ -2,6 +2,8 @@
<i <i
:class="iconClass" :class="iconClass"
aria-hidden="true" aria-hidden="true"
@click="$emit('click', $event)"
class="cursor-pointer"
/> />
</template> </template>

@ -41,7 +41,8 @@ export const chamiloIconToClass = {
"dots-vertical": "mdi mdi-dots-vertical", "dots-vertical": "mdi mdi-dots-vertical",
"down": "mdi mdi-arrow-down-right", "down": "mdi mdi-arrow-down-right",
"download": "mdi mdi-download-box", "download": "mdi mdi-download-box",
"drawing": "mdi mdi-drawing", "comment": "mdi mdi-comment-text-outline",
"drawing": "mdi mdi-pencil-outline",
"edit": "mdi mdi-pencil", "edit": "mdi mdi-pencil",
"email-plus": "mdi mdi-email-plus-outline", "email-plus": "mdi mdi-email-plus-outline",
"email-unread": "mdi mdi-email-mark-as-unread", "email-unread": "mdi mdi-email-mark-as-unread",
@ -127,5 +128,11 @@ export const chamiloIconToClass = {
"next": "mdi mdi-arrow-right-bold-box", "next": "mdi mdi-arrow-right-bold-box",
"crosshairs": "mdi mdi-crosshairs", "crosshairs": "mdi mdi-crosshairs",
"square": "mdi mdi-square", "square": "mdi mdi-square",
"wheel": "mdi mdi-tire" "wheel": "mdi mdi-tire",
"view-table": "mdi mdi-table-eye",
"eye-lock": "mdi mdi-lock",
"unlock": "mdi mdi-lock-open",
"lock": "mdi mdi-lock",
"account-check": "mdi mdi-account-check",
"account-cancel": "mdi mdi-account-cancel",
} }

@ -0,0 +1,44 @@
export default {
path: "/resources/attendance/:node/",
meta: { requiresAuth: true, showBreadcrumb: true },
name: "attendance",
component: () => import("../components/layout/SimpleRouterViewLayout.vue"),
redirect: { name: "AttendanceList" },
children: [
{
name: "AttendanceList",
path: "",
component: () => import("../views/attendance/AttendanceList.vue"),
},
{
name: "CreateAttendance",
path: "create",
component: () => import("../views/attendance/AttendanceCreate.vue"),
},
{
name: "EditAttendance",
path: "edit/:id",
component: () => import("../views/attendance/AttendanceEdit.vue"),
},
{
name: "AttendanceSheetList",
path: ":id?/sheet-list",
component: () => import("../views/attendance/AttendanceSheetList.vue"),
},
{
name: "CalendarList",
path: ":id?/calendar",
component: () => import("../views/attendance/AttendanceCalendarList.vue"),
},
{
name: "AddCalendarEvent",
path: ":id?/calendar/create",
component: () => import("../views/attendance/AttendanceCalendarAdd.vue"),
},
{
name: "ExportToPdf",
path: ":id?/export/pdf",
component: () => import("../views/attendance/AttendanceExport.vue"),
},
],
}

@ -21,6 +21,7 @@ import documents from "./documents"
import assignments from "./assignments" import assignments from "./assignments"
import links from "./links" import links from "./links"
import glossary from "./glossary" import glossary from "./glossary"
import attendance from "./attendance"
import catalogue from "./catalogue" import catalogue from "./catalogue"
import { useSecurityStore } from "../store/securityStore" import { useSecurityStore } from "../store/securityStore"
import MyCourseList from "../views/user/courses/List.vue" import MyCourseList from "../views/user/courses/List.vue"
@ -234,6 +235,7 @@ const router = createRouter({
assignments, assignments,
links, links,
glossary, glossary,
attendance,
accountRoutes, accountRoutes,
personalFileRoutes, personalFileRoutes,
messageRoutes, messageRoutes,

@ -0,0 +1,253 @@
import { ENTRYPOINT } from "../config/entrypoint"
import axios from "axios"
export const ATTENDANCE_STATES = {
ABSENT: { id: 0, label: "Absent", score: 0 },
PRESENT: { id: 1, label: "Present", score: 1 },
LATE_15: { id: 2, label: "Late < 15 min", score: 1 },
LATE_15_PLUS: { id: 3, label: "Late > 15 min", score: 0.5 },
ABSENT_JUSTIFIED: { id: 4, label: "Absent, justified", score: 0.25 },
}
export default {
/**
* Fetches all attendance lists for a specific course.
* @param {Object} params - Filters for the attendance lists.
* @returns {Promise<Object>} - Data of the attendance lists.
*/
getAttendances: async (params) => {
const response = await axios.get(`${ENTRYPOINT}attendances/`, { params })
return response.data
},
/**
* Fetches a specific attendance list by its ID.
* @param {Number|String} attendanceId - ID of the attendance list.
* @returns {Promise<Object>} - Data of the specific attendance list.
*/
getAttendance: async (attendanceId) => {
const response = await axios.get(`${ENTRYPOINT}attendances/${attendanceId}/`)
return response.data
},
/**
* Fetches groups filtered by the parent node (course).
* @param {Number|String} parentNodeId - The ID of the parent resource node (course).
* @returns {Promise<Array>} - List of groups associated with the course.
*/
fetchGroups: async (parentNodeId) => {
try {
const response = await axios.get(`${ENTRYPOINT}groups`, {
params: { "resourceNode.parent": parentNodeId },
})
return response.data["hydra:member"].map((group) => ({
label: group.title,
value: group.iid,
}))
} catch (error) {
console.error("Error fetching groups:", error)
throw error
}
},
/**
* Creates a new attendance list.
* @param {Object} data - Data for the new attendance list.
* @returns {Promise<Object>} - Data of the created attendance list.
*/
createAttendance: async (data) => {
const response = await axios.post(`${ENTRYPOINT}attendances`, data)
return response.data
},
/**
* Updates an existing attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @param {Object} data - Updated data for the attendance list.
* @returns {Promise<Object>} - Data of the updated attendance list.
*/
updateAttendance: async (attendanceId, data) => {
const response = await axios.put(`${ENTRYPOINT}attendances/${attendanceId}`, data)
return response.data
},
/**
* Deletes an attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @returns {Promise<Object>} - Result of the deletion.
*/
deleteAttendance: async (attendanceId) => {
const response = await axios.delete(`${ENTRYPOINT}attendances/${attendanceId}`)
return response.data
},
/**
* Toggles the visibility of an attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @returns {Promise<void>} - Result of the toggle action.
*/
toggleVisibility: async (attendanceId) => {
const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/toggle_visibility`
await axios.put(endpoint, {}, { headers: { "Content-Type": "application/json" } })
},
/**
* Soft deletes an attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @returns {Promise<void>} - Result of the soft delete action.
*/
softDelete: async (attendanceId) => {
const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/soft_delete`
await axios.put(endpoint, {}, { headers: { "Content-Type": "application/json" } })
},
/**
* Adds a new calendar event to an attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @param {Object} data - Data for the new calendar event.
* @returns {Promise<Object>} - Data of the created calendar event.
*/
addCalendarEvent: async (attendanceId, data) => {
const response = await axios.post(`${ENTRYPOINT}attendances/${attendanceId}/calendars`, data)
return response.data
},
/**
* Updates an existing calendar event.
* @param {Number|String} calendarId - ID of the calendar event.
* @param {Object} data - Updated data for the calendar event.
* @returns {Promise<Object>} - Data of the updated calendar event.
*/
updateCalendarEvent: async (calendarId, data) => {
const response = await axios.put(`${ENTRYPOINT}c_attendance_calendars/${calendarId}`, data)
return response.data
},
/**
* Deletes a specific calendar event.
* @param {Number|String} calendarId - ID of the calendar event.
* @returns {Promise<Object>} - Result of the deletion.
*/
deleteCalendarEvent: async (calendarId) => {
const response = await axios.delete(`${ENTRYPOINT}c_attendance_calendars/${calendarId}`)
return response.data
},
/**
* Adds a new calendar entry directly to an attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @param {Object} data - Calendar data, including repetition and groups.
* @returns {Promise<Object>} - Data of the created calendar entry.
*/
addAttendanceCalendar: async (attendanceId, data) => {
const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/calendars`
const response = await axios.post(endpoint, data, {
headers: { "Content-Type": "application/json" },
})
return response.data
},
/**
* Fetches full attendance data (dates and attendance status).
* @param {Number|String} attendanceId - ID of the attendance list.
* @returns {Promise<Object>} - Full attendance data structured for Vue.
*/
getFullAttendanceData: async (attendanceId) => {
try {
const response = await axios.get(`/attendance/full-data`, {
params: { attendanceId },
})
return response.data
} catch (error) {
console.error("Error fetching full attendance data:", error)
throw error
}
},
/**
* Fetches users related to attendance based on course, session, or group.
* @param {Object} params - Object with courseId, sessionId, and/or groupId.
* @returns {Promise<Array>} - List of users.
*/
getAttendanceSheetUsers: async (params) => {
try {
const response = await axios.get(`/attendance/users/context`, { params })
return response.data
} catch (error) {
console.error("Error fetching attendance sheet users:", error)
throw error
}
},
/**
* Updates an existing calendar entry for an attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @param {Number|String} calendarId - ID of the calendar entry to update.
* @param {Object} data - Updated calendar data.
* @returns {Promise<Object>} - Data of the updated calendar entry.
*/
updateAttendanceCalendar: async (attendanceId, calendarId, data) => {
const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/calendars/${calendarId}`
const response = await axios.put(endpoint, data)
return response.data
},
/**
* Deletes a specific calendar entry for an attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @param {Number|String} calendarId - ID of the calendar entry to delete.
* @returns {Promise<Object>} - Result of the deletion.
*/
deleteAttendanceCalendar: async (attendanceId, calendarId) => {
const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/calendars/${calendarId}`
const response = await axios.delete(endpoint)
return response.data
},
/**
* Deletes all calendar entries for a specific attendance list.
* @param {Number|String} attendanceId - ID of the attendance list.
* @returns {Promise<Object>} - Result of the deletion.
*/
deleteAllAttendanceCalendars: async (attendanceId) => {
const endpoint = `${ENTRYPOINT}attendances/${attendanceId}/calendars`
const response = await axios.delete(endpoint)
return response.data
},
/**
* Exports an attendance list to PDF format.
* @param {Number|String} attendanceId - ID of the attendance list.
* @returns {Promise<Blob>} - PDF file of the attendance list.
*/
exportAttendanceToPdf: async (attendanceId) => {
const response = await axios.get(`${ENTRYPOINT}attendances/${attendanceId}/export/pdf`, {
responseType: "blob",
})
return response.data
},
/**
* Exports an attendance list to XLS format.
* @param {Number|String} attendanceId - ID of the attendance list.
* @returns {Promise<Blob>} - XLS file of the attendance list.
*/
exportAttendanceToXls: async (attendanceId) => {
const response = await axios.get(`${ENTRYPOINT}attendances/${attendanceId}/export/xls`, {
responseType: "blob",
})
return response.data
},
saveAttendanceSheet: async (data) => {
try {
const response = await axios.post(`/attendance/sheet/save`, data, {
headers: { "Content-Type": "application/json" },
})
return response.data
} catch (error) {
console.error("Error saving attendance sheet:", error)
throw error
}
},
}

@ -0,0 +1,24 @@
import axios from "axios"
const API_BASE = "/gradebook"
export default {
/**
* Fetches gradebook categories for a specific course and session.
* @param {number} courseId The course ID.
* @param {number|null} sessionId The session ID (optional).
* @returns {Promise<Array>} The list of gradebook categories.
*/
async getCategories(courseId, sessionId = null) {
const params = { courseId }
if (sessionId) params.sessionId = sessionId
try {
const response = await axios.get(`${API_BASE}/categories`, { params })
return response.data
} catch (error) {
console.error("Error fetching gradebook categories:", error)
throw error
}
},
}

@ -0,0 +1,32 @@
<template>
<LayoutFormGeneric>
<template #header>
<BaseIcon icon="calendar-plus" />
{{ t("Add Attendance Calendar") }}
</template>
<AttendanceCalendarForm @back-pressed="goBack" />
</LayoutFormGeneric>
</template>
<script setup>
import { useRouter, useRoute } from "vue-router"
import { useI18n } from "vue-i18n"
import LayoutFormGeneric from "../../components/layout/LayoutFormGeneric.vue"
import BaseIcon from "../../components/basecomponents/BaseIcon.vue"
import AttendanceCalendarForm from "../../components/attendance/AttendanceCalendarForm.vue"
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const goBack = () => {
router.push({
name: "CalendarList",
params: { node: route.params.node, id: route.params.id },
query: {
...route.query,
},
})
}
</script>

@ -0,0 +1,262 @@
<template>
<div class="p-4">
<!-- Toolbar -->
<BaseToolbar>
<BaseButton
:label="t('Back to Attendance Sheet')"
icon="back"
type="info"
@click="redirectToAttendanceSheet"
/>
<BaseButton
:label="t('Add Calendar Event')"
icon="plus"
type="success"
@click="redirectToAddCalendarEvent"
/>
<BaseButton
:label="t('Clear All')"
icon="delete"
type="error"
@click="clearAllEvents"
/>
</BaseToolbar>
<!-- Informative Message -->
<div class="p-4 mb-4 text-primary bg-gray-15 border border-gray-25 rounded">
<p>
{{
t(
"The attendance calendar allows you to register attendance lists (one per real session the students need to attend). Add new attendance lists here.",
)
}}
</p>
</div>
<!-- Calendar Events List -->
<div class="calendar-list flex flex-col gap-4">
<div
v-for="event in calendarEvents"
:key="event.id"
class="calendar-item flex justify-between items-center border border-gray-25 rounded bg-gray-10 p-4"
>
<div class="flex items-center gap-2">
<i class="pi pi-calendar text-primary text-lg"></i>
<span class="text-gray-90">{{ formatDateTime(event.dateTime) }}</span>
</div>
<div class="calendar-actions flex gap-2">
<BaseButton
icon="edit"
type="warning"
@click="openEditDialog(event)"
/>
<BaseButton
icon="delete"
type="danger"
@click="openDeleteDialog(event.id)"
/>
</div>
</div>
</div>
<!-- Edit Dialog -->
<Dialog
v-model:visible="editDialogVisible"
:header="t('Edit Calendar Event')"
:modal="true"
:closable="false"
>
<div class="p-fluid">
<BaseCalendar
v-model="selectedEventDate"
:label="t('Date')"
:show-time="true"
/>
</div>
<div class="dialog-actions">
<BaseButton
:label="t('Save')"
icon="check"
type="success"
@click="updateCalendarEvent"
/>
<BaseButton
:label="t('Cancel')"
icon="times"
type="danger"
@click="closeEditDialog"
/>
</div>
</Dialog>
<!-- Delete Dialog -->
<Dialog
v-model:visible="deleteDialogVisible"
:header="t('Delete Calendar Event')"
:modal="true"
:closable="false"
>
<p>{{ t("Are you sure you want to delete this event?") }}</p>
<div class="dialog-actions">
<BaseButton
:label="t('Yes')"
icon="check"
type="danger"
@click="deleteCalendarEventConfirmed"
/>
<BaseButton
:label="t('No')"
icon="times"
type="secondary"
@click="closeDeleteDialog"
/>
</div>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue"
import { useRouter, useRoute } from "vue-router"
import { useI18n } from "vue-i18n"
import attendanceService from "../../services/attendanceService"
import BaseToolbar from "../../components/basecomponents/BaseToolbar.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import Dialog from "primevue/dialog"
import BaseCalendar from "../../components/basecomponents/BaseCalendar.vue"
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const calendarEvents = ref([])
const isLoading = ref(false)
const editDialogVisible = ref(false)
const deleteDialogVisible = ref(false)
const selectedEvent = ref(null)
const selectedEventDate = ref("")
const eventIdToDelete = ref(null)
const formatDateTime = (dateTime) => new Date(dateTime).toLocaleString()
const fetchCalendarEvents = async () => {
try {
isLoading.value = true
const response = await attendanceService.getAttendance(route.params.id)
if (response.calendars && Array.isArray(response.calendars)) {
calendarEvents.value = response.calendars.map((calendar) => ({
id: calendar.iid || calendar["@id"].split("/").pop(),
title: calendar.title || "Unnamed Event",
dateTime: calendar.dateTime || new Date(),
}))
} else {
calendarEvents.value = []
}
} catch (error) {
console.error("Error fetching calendar events:", error)
} finally {
isLoading.value = false
}
}
const openEditDialog = (event) => {
selectedEvent.value = event
selectedEventDate.value = event.dateTime
editDialogVisible.value = true
}
const updateCalendarEvent = async () => {
try {
await attendanceService.updateCalendarEvent(selectedEvent.value.id, { dateTime: selectedEventDate.value })
closeEditDialog()
await fetchCalendarEvents()
} catch (error) {
console.error("Error updating calendar event:", error)
}
}
const closeEditDialog = () => {
editDialogVisible.value = false
selectedEvent.value = null
selectedEventDate.value = ""
}
const openDeleteDialog = (id) => {
eventIdToDelete.value = id
deleteDialogVisible.value = true
}
// Delete Calendar Event
const deleteCalendarEventConfirmed = async () => {
try {
await attendanceService.deleteCalendarEvent(eventIdToDelete.value)
closeDeleteDialog()
await fetchCalendarEvents()
} catch (error) {
console.error("Error deleting calendar event:", error)
}
}
const clearAllEvents = async () => {
try {
if (confirm(t("Are you sure you want to delete all calendar events?"))) {
for (const event of calendarEvents.value) {
await attendanceService.deleteCalendarEvent(event.id)
}
calendarEvents.value = []
}
} catch (error) {
console.error("Error clearing all events:", error)
}
}
const closeDeleteDialog = () => {
deleteDialogVisible.value = false
eventIdToDelete.value = null
}
const redirectToAttendanceSheet = () => {
router.push({
name: "AttendanceSheetList",
params: { id: route.params.id },
query: { cid: route.query.cid, sid: route.query.sid, gid: route.query.gid },
})
}
const redirectToAddCalendarEvent = () => {
router.push({
name: "AddCalendarEvent",
params: { id: route.params.id },
query: { cid: route.query.cid, sid: route.query.sid, gid: route.query.gid },
})
}
onMounted(fetchCalendarEvents)
</script>
<style scoped>
.calendar-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.calendar-item {
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid #ddd;
padding: 0.75rem;
border-radius: 8px;
background-color: #f9f9f9;
}
.calendar-actions {
display: flex;
gap: 0.5rem;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
</style>

@ -0,0 +1,32 @@
<template>
<LayoutFormGeneric>
<template #header>
<BaseIcon icon="plus" />
{{ t("Add Attendance") }}
</template>
<AttendanceForm @back-pressed="goBack" />
</LayoutFormGeneric>
</template>
<script setup>
import AttendanceForm from "../../components/attendance/AttendanceForm.vue"
import { useRoute, useRouter } from "vue-router"
import { useI18n } from "vue-i18n"
import LayoutFormGeneric from "../../components/layout/LayoutFormGeneric.vue"
import BaseIcon from "../../components/basecomponents/BaseIcon.vue"
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const goBack = (query = {}) => {
router.push({
name: "AttendanceList",
query: {
...route.query,
...query,
},
})
}
</script>

@ -0,0 +1,77 @@
<template>
<LayoutFormGeneric>
<template #header>
<BaseIcon icon="edit" />
{{ t("Edit Attendance") }}
</template>
<div v-if="loading"></div>
<div v-else-if="attendanceData">
<AttendanceForm
:initial-data="{ ...attendanceData }"
@back-pressed="goBack"
/>
</div>
<div v-else>
<p>{{ t("No data available.") }}</p>
</div>
</LayoutFormGeneric>
</template>
<script setup>
import { ref, onMounted } from "vue"
import AttendanceForm from "../../components/attendance/AttendanceForm.vue"
import attendanceService from "../../services/attendanceService"
import { useRouter, useRoute } from "vue-router"
import { useI18n } from "vue-i18n"
import LayoutFormGeneric from "../../components/layout/LayoutFormGeneric.vue"
import BaseIcon from "../../components/basecomponents/BaseIcon.vue"
import DOMPurify from "dompurify"
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const attendanceData = ref(null)
const loading = ref(true)
const goBack = (query = {}) => {
router.push({
name: "AttendanceList",
query: {
...route.query,
...query,
},
})
}
const fetchAttendance = async () => {
const attendanceId = route.params.id
if (!attendanceId) {
goBack()
return
}
try {
loading.value = true
const fetchedData = await attendanceService.getAttendance(attendanceId)
attendanceData.value = {
id: fetchedData.iid,
title: fetchedData.title,
description: DOMPurify.sanitize(fetchedData.description),
qualifyGradebook: !!fetchedData.attendanceQualifyTitle,
gradebookOption: fetchedData.gradebookOption || null,
gradebookTitle: fetchedData.attendanceQualifyTitle || "",
gradeWeight: fetchedData.attendanceWeight || 0.0,
}
} catch (error) {
console.error("Error fetching attendance:", error)
attendanceData.value = null
} finally {
loading.value = false
}
}
onMounted(fetchAttendance)
</script>

@ -0,0 +1,11 @@
<script setup lang="ts">
</script>
<template>
</template>
<style scoped>
</style>

@ -0,0 +1,124 @@
<template>
<div>
<BaseToolbar>
<BaseButton
:label="t('Add Attendance')"
icon="plus"
type="black"
@click="redirectToCreateAttendance"
/>
</BaseToolbar>
<AttendanceTable
:attendances="attendances"
:loading="isLoading"
:total-records="totalAttendances"
@edit="redirectToEditAttendance"
@view="toggleResourceLinkVisibility"
@delete="confirmDeleteAttendance"
@pageChange="fetchAttendances"
/>
<BaseDialogDelete
v-model:is-visible="isDeleteDialogVisible"
:item-to-delete="attendanceToDelete ? attendanceToDelete.title : ''"
@confirm-clicked="deleteAttendance"
@cancel-clicked="isDeleteDialogVisible = false"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue"
import { useRoute, useRouter } from "vue-router"
import attendanceService from "../../services/attendanceService"
import AttendanceTable from "../../components/attendance/AttendanceTable.vue"
import BaseToolbar from "../../components/basecomponents/BaseToolbar.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import BaseDialogDelete from "../../components/basecomponents/BaseDialogDelete.vue"
import { useI18n } from "vue-i18n"
import { useCidReq } from "../../composables/cidReq"
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const attendances = ref([])
const isDeleteDialogVisible = ref(false)
const attendanceToDelete = ref({ id: null, title: "" })
const totalAttendances = ref(0)
const isLoading = ref(false)
const { sid, cid, gid } = useCidReq()
const parentResourceNodeId = ref(Number(route.params.node))
const redirectToCreateAttendance = () => {
router.push({
name: "CreateAttendance",
query: { cid, sid, gid },
})
}
const redirectToEditAttendance = (attendance) => {
router.push({
name: "EditAttendance",
params: { id: attendance.id },
query: { cid, sid, gid },
})
}
const confirmDeleteAttendance = (attendance) => {
attendanceToDelete.value = { id: attendance.id, title: attendance.title }
isDeleteDialogVisible.value = true
}
const deleteAttendance = async () => {
try {
await attendanceService.softDelete(attendanceToDelete.value.id)
await fetchAttendances()
} catch (error) {
console.error("Error deleting attendance:", error)
} finally {
isDeleteDialogVisible.value = false
}
}
const toggleResourceLinkVisibility = async (attendance) => {
try {
await attendanceService.toggleVisibility(attendance.id)
await fetchAttendances()
} catch (error) {
console.error("Error toggling visibility:", error)
}
}
const fetchAttendances = async ({ page = 1, rows = 10 } = {}) => {
try {
isLoading.value = true
const params = {
"resourceNode.parent": parentResourceNodeId.value,
active: 1,
cid,
sid,
gid,
page,
rows,
}
const data = await attendanceService.getAttendances(params)
attendances.value = data["hydra:member"].map((item) => ({
id: item.iid,
title: item.title,
description: item.description,
resourceLinkListFromEntity: item.resourceLinkListFromEntity,
attendanceQualifyTitle: item.attendanceQualifyTitle,
attendanceWeight: item.attendanceWeight,
}))
totalAttendances.value = data.total || 0
} catch (error) {
console.error("Error fetching attendances:", error)
} finally {
isLoading.value = false
}
}
onMounted(fetchAttendances)
</script>

@ -0,0 +1,704 @@
<template>
<div class="p-4">
<!-- Toolbar -->
<BaseToolbar class="flex justify-between items-center mb-4">
<BaseButton
v-if="canEdit"
:label="t('Go to Calendar')"
icon="calendar"
type="info"
@click="redirectToCalendarList"
/>
<div class="flex items-center gap-2 ml-auto">
<select
v-if="!isStudent"
v-model="selectedFilter"
@change="filterAttendanceSheets"
class="p-2 border border-gray-300 rounded focus:ring-primary focus:border-primary w-64"
>
<option value="all">{{ t("All") }}</option>
<option value="today">{{ t("Today") }}</option>
<option value="done">{{ t("All done") }}</option>
<option value="not_done">{{ t("All not done") }}</option>
<option
v-for="date in attendanceDates"
:key="date.id"
:value="date.id"
>
{{ date.label }}
</option>
</select>
</div>
</BaseToolbar>
<!-- Loading Spinner -->
<div
v-if="isLoading"
class="flex justify-center items-center h-64"
>
<div class="loader"></div>
<span class="ml-4 text-lg text-primary">{{ t("Loading attendance data...") }}</span>
</div>
<!-- Attendance Table -->
<div v-else>
<!-- Alert if no class today -->
<div
v-if="!isTodayScheduled"
class="p-4 mb-4 text-warning bg-yellow-50 border border-yellow-300 rounded"
>
{{
t(
"There is no class scheduled today, try picking another day or add your attendance entry yourself using the action icons.",
)
}}
</div>
<!-- Informative Message -->
<div class="p-4 mb-4 text-primary bg-gray-15 border border-gray-25 rounded">
<p>
{{
t(
"The attendance calendar allows you to register attendance lists (one per real session the students need to attend).",
)
}}
</p>
</div>
<!-- Attendance Sheet Table -->
<div class="relative flex">
<!-- Fixed User Information -->
<div
class="overflow-hidden flex-shrink-0"
style="width: 520px"
>
<table class="w-full border-collapse">
<thead>
<tr class="bg-gray-15 h-28">
<th class="p-3 border border-gray-25 text-left">#</th>
<th class="p-3 border border-gray-25 text-left">{{ t("Photo") }}</th>
<th class="p-3 border border-gray-25 text-left">{{ t("Last Name") }}</th>
<th class="p-3 border border-gray-25 text-left w-32">{{ t("First Name") }}</th>
<th class="p-3 border border-gray-25 text-left">{{ t("Not Attended") }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(user, index) in filteredAttendanceSheets"
:key="user.id"
class="hover:bg-gray-10 h-28"
>
<td class="p-3 border border-gray-25">{{ index + 1 }}</td>
<td class="p-3 border border-gray-25">
<img
:src="user.photo"
alt="User photo"
class="w-10 h-10 rounded-full"
/>
</td>
<td
class="p-3 border border-gray-25 truncate"
:title="user.lastName"
>
{{ user.lastName }}
</td>
<td
class="p-3 border border-gray-25 truncate"
:title="user.firstName"
>
{{ user.firstName }}
</td>
<td class="p-3 border border-gray-25 text-center">
{{ user.notAttended }}
</td>
</tr>
</tbody>
</table>
</div>
<!-- Scrollable Dates -->
<div class="overflow-x-auto flex-1">
<table class="w-full border-collapse">
<thead>
<tr class="bg-gray-15 h-28">
<th
v-for="date in filteredDates"
:key="date.id"
class="p-3 border border-gray-25 text-center align-middle"
:class="{ 'bg-gray-200 cursor-not-allowed': isColumnLocked(date.id) }"
>
<div class="flex flex-col items-center">
<span class="font-bold">{{ date.label }}</span>
<div
class="flex gap-2 mt-1"
v-if="!isStudent"
>
<BaseIcon
icon="view-table"
size="normal"
@click="viewForTablet(date.id)"
class="cursor-pointer text-primary"
title="View for tablet"
/>
<BaseIcon
v-if="isAdmin"
:icon="isColumnLocked(date.id) ? 'lock' : 'unlock'"
size="normal"
@click="toggleLock(date.id)"
:class="isColumnLocked(date.id) ? 'text-gray-500' : 'text-warning'"
:title="isColumnLocked(date.id) ? 'Unlock column' : 'Lock column'"
/>
<BaseIcon
icon="account-check"
@click="setAllAttendance(date.id, 1)"
class="text-success"
title="Set all Present"
/>
<BaseIcon
icon="account-cancel"
@click="setAllAttendance(date.id, 0)"
class="text-danger"
title="Set all Absent"
/>
</div>
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in filteredAttendanceSheets"
:key="user.id"
class="hover:bg-gray-10 h-28"
>
<td
v-for="date in filteredDates"
:key="date.id"
class="p-3 border border-gray-25 text-center relative"
:class="{ 'bg-gray-200': isColumnLocked(date.id) || !canEdit }"
>
<div
v-if="isColumnLocked(date.id) || !canEdit"
class="cursor-not-allowed opacity-50"
title="Column is locked or read-only"
>
<div
:class="getStateIconClass(attendanceData[`${user.id}-${date.id}`])"
class="w-10 h-10 rounded-full mx-auto"
></div>
</div>
<div
v-else
:class="getStateIconClass(attendanceData[`${user.id}-${date.id}`])"
@click="openMenu(user.id, date.id)"
class="w-10 h-10 rounded-full cursor-pointer mx-auto"
:title="getStateLabel(attendanceData[`${user.id}-${date.id}`])"
></div>
<div
v-if="contextMenu.show && contextMenu.userId === user.id && contextMenu.dateId === date.id"
class="absolute bg-white border border-gray-300 rounded shadow-lg z-10 p-2"
style="top: 40px; left: 50%; transform: translateX(-50%)"
>
<div
v-for="(state, key) in ATTENDANCE_STATES"
:key="key"
class="flex items-center gap-2 p-2 cursor-pointer hover:bg-gray-100 rounded"
@click="selectState(user.id, date.id, state.id)"
>
<div
:class="getStateIconClass(state.id)"
class="w-5 h-5 rounded-full"
></div>
<span>{{ state.label }}</span>
</div>
</div>
<div
v-if="canEdit"
class="absolute top-2 right-2 flex gap-3"
>
<BaseIcon
icon="comment"
size="normal"
@click="openCommentDialog(user.id, date.id)"
class="cursor-pointer text-info"
/>
<BaseIcon
icon="drawing"
size="normal"
@click="openSignatureDialog(user.id, date.id)"
class="cursor-pointer text-success"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Save Button -->
<div class="mt-4 flex justify-end">
<BaseButton
v-if="canEdit"
:label="t('Save Attendance')"
icon="check"
type="success"
@click="saveAttendanceSheet"
/>
</div>
<!-- Comment Dialog -->
<BaseDialog
v-model:isVisible="showCommentDialog"
title="Add Comment"
>
<textarea
v-model="currentComment"
class="w-full h-32 border border-gray-300 rounded"
placeholder="Write your comment..."
></textarea>
<template #footer>
<BaseButton
:label="t('Save')"
icon="save"
type="success"
@click="saveComment"
/>
<BaseButton
:label="t('Close')"
icon="close"
type="danger"
@click="closeCommentDialog"
/>
</template>
</BaseDialog>
<!-- Signature Dialog -->
<BaseDialog
v-model:isVisible="showSignatureDialog"
title="Add Signature"
>
<div class="relative w-full h-48">
<canvas
ref="signaturePad"
class="border border-gray-300 rounded w-full h-full"
></canvas>
<button
@click="clearSignature"
class="mt-2 text-primary"
>
Clear
</button>
</div>
<template #footer>
<BaseButton
:label="t('Save')"
icon="save"
type="success"
@click="saveSignature"
/>
<BaseButton
:label="t('Close')"
icon="close"
type="danger"
@click="closeSignatureDialog"
/>
</template>
</BaseDialog>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, computed, onMounted, watch } from "vue"
import { useRouter, useRoute } from "vue-router"
import { useI18n } from "vue-i18n"
import SignaturePad from "signature_pad"
import BaseToolbar from "../../components/basecomponents/BaseToolbar.vue"
import BaseButton from "../../components/basecomponents/BaseButton.vue"
import BaseIcon from "../../components/basecomponents/BaseIcon.vue"
import BaseDialog from "../../components/basecomponents/BaseDialog.vue"
import attendanceService, { ATTENDANCE_STATES } from "../../services/attendanceService"
import { useCidReq } from "../../composables/cidReq"
import { useSecurityStore } from "../../store/securityStore"
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
const { sid, cid, gid } = useCidReq()
const isLoading = ref(true)
const securityStore = useSecurityStore()
const canEdit = computed(() => securityStore.isAdmin || securityStore.isTeacher || securityStore.isHRM)
const isStudent = computed(() => securityStore.isStudent)
const isAdmin = computed(() => securityStore.isAdmin)
const todayDate = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "2-digit",
})
const isTodayScheduled = computed(() => {
return attendanceDates.value.some((date) => date.label.includes(todayDate))
})
const attendanceDates = ref([])
const attendanceSheetUsers = ref([])
const selectedFilter = ref("all")
const comments = ref({})
const signatures = ref({})
const redirectToCalendarList = () => {
router.push({
name: "CalendarList",
params: { id: route.params.id },
query: { sid, cid, gid },
})
}
const filteredAttendanceSheets = computed(() => {
if (isStudent.value) {
const currentUser = securityStore.user
return attendanceSheetUsers.value.filter((user) => user.id === currentUser?.id)
}
return attendanceSheetUsers.value
})
const saveAttendanceSheet = async () => {
if (!canEdit.value) return
if (!attendanceData.value || Object.keys(attendanceData.value).length === 0) {
alert(t("No attendance data to save."))
return
}
const preparedData = []
filteredAttendanceSheets.value.forEach((user) => {
filteredDates.value.forEach((date) => {
const key = `${user.id}-${date.id}`
preparedData.push({
userId: user.id,
calendarId: date.id,
presence: attendanceData.value[key] ?? 0,
comment: comments.value[key] ?? null,
signature: signatures.value[key] ?? null,
})
})
})
try {
const response = await attendanceService.saveAttendanceSheet({
courseId: parseInt(cid),
sessionId: sid ? parseInt(sid) : null,
groupId: gid ? parseInt(gid) : null,
attendanceData: preparedData,
})
console.log("Attendance data saved:", response)
alert(t("Attendance saved successfully"))
} catch (error) {
console.error("Error saving attendance data:", error)
alert(t("Failed to save attendance. Please try again."))
}
}
const showCommentDialog = ref(false)
const showSignatureDialog = ref(false)
const currentComment = ref("")
const currentUserId = ref(null)
const currentDateId = ref(null)
const signaturePad = ref(null)
const attendanceData = ref({})
const fetchAttendanceSheetUsers = async () => {
isLoading.value = true
try {
const params = {
courseId: cid,
sessionId: sid || null,
groupId: gid || null,
}
const users = await attendanceService.getAttendanceSheetUsers(params)
attendanceSheetUsers.value = users.map((user) => ({
id: user.id,
photo: user.photo || "/img/default-avatar.png",
lastName: user.lastname,
firstName: user.firstname,
notAttended: user.notAttended,
}))
} catch (error) {
console.error("Failed to fetch attendance sheet users:", error)
} finally {
isLoading.value = false
}
}
const fetchFullAttendanceData = async (attendanceId) => {
isLoading.value = true
try {
const data = await attendanceService.getFullAttendanceData(attendanceId)
attendanceDates.value = data.attendanceDates
attendanceData.value = data.attendanceData
} catch (error) {
console.error("Failed to fetch attendance data:", error)
} finally {
isLoading.value = false
}
}
const filteredDates = ref([])
const filterAttendanceSheets = () => {
switch (selectedFilter.value) {
case "all":
filteredDates.value = attendanceDates.value
break
case "done":
filteredDates.value = attendanceDates.value.filter((date) =>
Object.keys(attendanceData.value).some(
(key) => key.endsWith(`-${date.id}`) && attendanceData.value[key] !== null,
),
)
break
case "not_done":
filteredDates.value = attendanceDates.value.filter(
(date) =>
!Object.keys(attendanceData.value).some(
(key) => key.endsWith(`-${date.id}`) && attendanceData.value[key] !== null,
),
)
break
default:
filteredDates.value = attendanceDates.value.filter((date) => date.id === parseInt(selectedFilter.value, 10))
break
}
}
watch([attendanceDates, attendanceData, selectedFilter], filterAttendanceSheets, {
immediate: true,
})
const contextMenu = ref({
show: false,
userId: null,
dateId: null,
})
const columnLocks = ref({})
const isColumnLocked = (dateId) => !!columnLocks.value[dateId]
const initializeColumnLocks = (dates) => {
columnLocks.value = {}
dates.forEach((date) => {
columnLocks.value[date.id] = false
})
}
const isToggling = ref(false)
const toggleLock = (dateId) => {
if (isToggling.value) return
isToggling.value = true
columnLocks.value = {
...columnLocks.value,
[dateId]: !columnLocks.value[dateId],
}
setTimeout(() => {
isToggling.value = false
}, 100)
}
onMounted(() => {
fetchFullAttendanceData(route.params.id)
fetchAttendanceSheetUsers()
initializeColumnLocks(attendanceDates.value)
})
initializeColumnLocks(attendanceDates.value)
const getStateLabel = (stateId) => Object.values(ATTENDANCE_STATES).find((state) => state.id === stateId)?.label
const getStateIconClass = (stateId) => {
const stateColors = {
0: "bg-red-500", // Absent
1: "bg-green-500", // Present
2: "bg-orange-300", // Late < 15 min
3: "bg-orange-500", // Late > 15 min
4: "bg-pink-400", // Absent, justified
}
return stateColors[stateId] || "bg-gray-200"
}
const setAllAttendance = (dateId, stateId) => {
filteredAttendanceSheets.value.forEach((user) => {
attendanceData.value[`${user.id}-${dateId}`] = stateId
})
}
const openMenu = (userId, dateId) => {
contextMenu.value = { show: true, userId, dateId }
}
const closeMenu = () => {
contextMenu.value = { show: false, userId: null, dateId: null }
}
const selectState = (userId, dateId, stateId) => {
attendanceData.value[`${userId}-${dateId}`] = stateId
closeMenu()
}
const openCommentDialog = (userId, dateId) => {
const key = `${userId}-${dateId}`
currentUserId.value = userId
currentDateId.value = dateId
currentComment.value = comments.value[key] || ""
showCommentDialog.value = true
}
const openSignatureDialog = (userId, dateId) => {
const key = `${userId}-${dateId}`
currentUserId.value = userId
currentDateId.value = dateId
showSignatureDialog.value = true
nextTick(() => {
const canvas = document.querySelector("canvas")
if (canvas) {
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
signaturePad.value = new SignaturePad(canvas)
if (signatures.value[key]) {
const img = new Image()
img.src = signatures.value[key]
img.onload = () => {
const ctx = canvas.getContext("2d")
ctx.drawImage(img, 0, 0)
}
}
}
})
}
const saveComment = () => {
const key = `${currentUserId.value}-${currentDateId.value}`
comments.value[key] = currentComment.value
console.log(`Saved comment for ${key}:`, currentComment.value)
closeCommentDialog()
}
const saveSignature = () => {
if (signaturePad.value) {
const key = `${currentUserId.value}-${currentDateId.value}`
signatures.value[key] = signaturePad.value.toDataURL()
console.log(`Saved signature for ${key}`)
}
closeSignatureDialog()
}
const clearSignature = () => {
if (signaturePad.value) {
signaturePad.value.clear()
}
}
const closeCommentDialog = () => {
showCommentDialog.value = false
}
const closeSignatureDialog = () => {
showSignatureDialog.value = false
}
const viewForTablet = (dateId) => {
console.log(`View for tablet clicked for date ID: ${dateId}`)
}
</script>
<style scoped>
canvas {
width: 100%;
height: 100%;
display: block;
}
tr {
height: 100px;
}
th,
td {
height: 100px;
vertical-align: middle;
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.align-middle {
vertical-align: middle;
}
.mt-1 {
margin-top: 4px;
}
.gap-2 {
gap: 8px;
}
.bg-red-500 {
background-color: #f87171;
}
.bg-green-500 {
background-color: #4ade80;
}
.bg-orange-300 {
background-color: #fdba74;
}
.bg-orange-500 {
background-color: #f97316;
}
.bg-pink-400 {
background-color: #f472b6;
}
.bg-gray-200 {
background-color: #e5e7eb;
}
.opacity-50 {
opacity: 0.5;
}
.cursor-not-allowed {
cursor: not-allowed;
}
.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #3498db;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

@ -89,6 +89,15 @@ services:
tags: tags:
- { name: 'api_platform.state_processor' } - { name: 'api_platform.state_processor' }
Chamilo\CoreBundle\State\CAttendanceStateProcessor:
arguments:
$persistProcessor: '@api_platform.doctrine.orm.state.persist_processor'
$entityManager: '@doctrine.orm.entity_manager'
$calendarRepo: '@Chamilo\CourseBundle\Repository\CAttendanceCalendarRepository'
$requestStack: '@request_stack'
tags:
- { name: 'api_platform.state_processor' }
Chamilo\CoreBundle\State\ColorThemeStateProcessor: Chamilo\CoreBundle\State\ColorThemeStateProcessor:
bind: bind:
$persistProcessor: '@api_platform.doctrine.orm.state.persist_processor' $persistProcessor: '@api_platform.doctrine.orm.state.persist_processor'

@ -0,0 +1,241 @@
<?php
declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Repository\Node\UserRepository;
use Chamilo\CourseBundle\Entity\CAttendance;
use Chamilo\CourseBundle\Entity\CAttendanceCalendar;
use Chamilo\CourseBundle\Entity\CAttendanceResult;
use Chamilo\CourseBundle\Entity\CAttendanceResultComment;
use Chamilo\CourseBundle\Entity\CAttendanceSheet;
use Chamilo\CourseBundle\Entity\CAttendanceSheetLog;
use Chamilo\CourseBundle\Repository\CAttendanceCalendarRepository;
use Chamilo\CourseBundle\Repository\CAttendanceSheetRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/attendance')]
class AttendanceController extends AbstractController
{
public function __construct(
private readonly CAttendanceCalendarRepository $attendanceCalendarRepository,
private readonly EntityManagerInterface $em
) {}
#[Route('/full-data', name: 'chamilo_core_attendance_get_full_data', methods: ['GET'])]
public function getFullAttendanceData(Request $request): JsonResponse
{
$attendanceId = (int) $request->query->get('attendanceId', 0);
if (!$attendanceId) {
return $this->json(['error' => 'Attendance ID is required'], 400);
}
$data = $this->attendanceCalendarRepository->findAttendanceWithData($attendanceId);
return $this->json($data, 200);
}
#[Route('/users/context', name: 'chamilo_core_get_users_with_faults', methods: ['GET'])]
public function getUsersWithFaults(
Request $request,
UserRepository $userRepository,
CAttendanceCalendarRepository $calendarRepository,
CAttendanceSheetRepository $sheetRepository
): JsonResponse {
$courseId = (int) $request->query->get('courseId', 0);
$sessionId = $request->query->get('sessionId') ? (int) $request->query->get('sessionId') : null;
$groupId = $request->query->get('groupId') ? (int) $request->query->get('groupId') : null;
if (!$courseId) {
return $this->json(['error' => 'Course ID is required'], 400);
}
try {
$users = $userRepository->findUsersByContext($courseId, $sessionId, $groupId);
$totalCalendars = $calendarRepository->countByAttendanceAndGroup($courseId, $groupId);
$formattedUsers = array_map(function ($user) use ($sheetRepository, $calendarRepository, $userRepository, $courseId, $groupId, $totalCalendars) {
$userScore = $sheetRepository->getUserScore($user->getId(), $courseId, $groupId);
$faults = max(0, $totalCalendars - $userScore);
$faultsPercent = $totalCalendars > 0 ? round(($faults * 100) / $totalCalendars, 0) : 0;
return [
'id' => $user->getId(),
'firstname' => $user->getFirstname(),
'lastname' => $user->getLastname(),
'email' => $user->getEmail(),
'username' => $user->getUsername(),
'photo' => $userRepository->getUserPicture($user->getId()),
'notAttended' => "$faults/$totalCalendars ({$faultsPercent}%)",
];
}, $users);
return $this->json($formattedUsers, 200);
} catch (\Exception $e) {
return $this->json(['error' => 'An error occurred: ' . $e->getMessage()], 500);
}
}
#[Route('/sheet/save', name: 'chamilo_core_attendance_sheet_save', methods: ['POST'])]
public function saveAttendanceSheet(
Request $request,
UserRepository $userRepository,
CAttendanceSheetRepository $sheetRepository
): JsonResponse {
$data = json_decode($request->getContent(), true);
if (empty($data['attendanceData']) || empty($data['courseId'])) {
return $this->json(['error' => 'Missing required parameters'], 400);
}
$attendanceData = $data['attendanceData'];
$courseId = (int) $data['courseId'];
$sessionId = isset($data['sessionId']) ? (int) $data['sessionId'] : null;
$groupId = isset($data['groupId']) ? (int) $data['groupId'] : null;
$usersInCourse = $userRepository->findUsersByContext($courseId, $sessionId, $groupId);
$userIdsInCourse = array_map(fn(User $user) => $user->getId(), $usersInCourse);
$affectedRows = 0;
try {
foreach ($attendanceData as $entry) {
$userId = (int) $entry['userId'];
$calendarId = (int) $entry['calendarId'];
$presence = isset($entry['presence']) ? (int)$entry['presence'] : null;
$signature = $entry['signature'] ?? null;
$comment = $entry['comment'] ?? null;
$calendar = $this->attendanceCalendarRepository->find($calendarId);
if (!$calendar) {
return $this->json(['error' => "Attendance calendar with ID $calendarId not found"], 404);
}
$user = $this->em->getRepository(User::class)->find($userId);
if (!$user) {
continue;
}
$sheet = $sheetRepository->findOneBy([
'user' => $user,
'attendanceCalendar' => $calendar,
]) ?? new CAttendanceSheet();
$sheet->setUser($user)
->setAttendanceCalendar($calendar)
->setPresence($presence)
->setSignature($signature);
$this->em->persist($sheet);
$this->em->flush();
if ($comment !== null) {
$existingComment = $this->em->getRepository(CAttendanceResultComment::class)->findOneBy([
'attendanceSheetId' => $sheet->getIid(),
'userId' => $user->getId(),
]);
if (!$existingComment) {
$existingComment = new CAttendanceResultComment();
$existingComment->setAttendanceSheetId($sheet->getIid());
$existingComment->setUserId($user->getId());
$existingComment->setAuthorUserId($this->getUser()->getId());
}
$existingComment->setComment($comment);
$existingComment->setUpdatedAt(new \DateTime());
$this->em->persist($existingComment);
}
}
$calendarIds = array_unique(array_column($attendanceData, 'calendarId'));
foreach ($calendarIds as $calendarId) {
$calendar = $this->attendanceCalendarRepository->find($calendarId);
if ($calendar && !$calendar->getDoneAttendance()) {
$calendar->setDoneAttendance(true);
$this->em->persist($calendar);
}
}
$calendars = $this->attendanceCalendarRepository->findBy(['iid' => $calendarIds]);
$attendance = $calendars[0]->getAttendance();
$this->updateAttendanceResults($attendance);
$lasteditType = $calendars[0]->getDoneAttendance()
? 'UPDATED_ATTENDANCE_LOG_TYPE'
: 'DONE_ATTENDANCE_LOG_TYPE';
foreach ($calendars as $calendar) {
$this->saveAttendanceLog($attendance, $lasteditType, $calendar);
}
$this->em->flush();
return $this->json([
'message' => 'Attendance data and comments saved successfully',
'affectedRows' => $affectedRows,
]);
} catch (\Exception $e) {
return $this->json(['error' => 'An error occurred: ' . $e->getMessage()], 500);
}
}
private function updateAttendanceResults(CAttendance $attendance): void
{
$sheets = $attendance->getCalendars()->map(fn ($calendar) => $calendar->getSheets())->toArray();
$results = [];
foreach ($sheets as $calendarSheets) {
foreach ($calendarSheets as $sheet) {
$userId = $sheet->getUser()->getId();
$results[$userId] = ($results[$userId] ?? 0) + $sheet->getPresence();
}
}
foreach ($results as $userId => $score) {
$user = $this->em->getRepository(User::class)->find($userId);
if (!$user) {
continue;
}
$result = $this->em->getRepository(CAttendanceResult::class)->findOneBy([
'user' => $user,
'attendance' => $attendance,
]);
if (!$result) {
$result = new CAttendanceResult();
$result->setUser($user);
$result->setAttendance($attendance);
}
$result->setScore((int) $score);
$this->em->persist($result);
}
}
private function saveAttendanceLog(CAttendance $attendance, string $lasteditType, CAttendanceCalendar $calendar): void
{
$log = new CAttendanceSheetLog();
$log->setAttendance($attendance)
->setLasteditDate(new \DateTime())
->setLasteditType($lasteditType)
->setCalendarDateValue($calendar->getDateTime())
->setUser($this->getUser());
$this->em->persist($log);
}
}

@ -5,16 +5,49 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Controller; namespace Chamilo\CoreBundle\Controller;
use Chamilo\CoreBundle\Entity\GradebookCategory; use Chamilo\CoreBundle\Entity\GradebookCategory;
use Chamilo\CoreBundle\Repository\GradeBookCategoryRepository;
use Chamilo\CourseBundle\Entity\CDocument; use Chamilo\CourseBundle\Entity\CDocument;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
#[Route('/gradebook')] #[Route('/gradebook')]
class GradebookController extends AbstractController class GradebookController extends AbstractController
{ {
public function __construct(
private readonly GradeBookCategoryRepository $gradeBookCategoryRepository,
) {}
#[Route('/categories', name: 'chamilo_core_gradebook_categories', methods: ['GET'])]
public function getCategories(Request $request): JsonResponse
{
// Extract parameters from the query string
$courseId = (int) $request->query->get('courseId');
$sessionId = $request->query->get('sessionId') ? (int) $request->query->get('sessionId') : null;
if (!$courseId) {
return new JsonResponse(['error' => 'courseId parameter is required'], Response::HTTP_BAD_REQUEST);
}
// Ensure the default category exists
$this->gradeBookCategoryRepository->createDefaultCategory($courseId, $sessionId);
// Fetch categories using the repository
$categories = $this->gradeBookCategoryRepository->getCategoriesForCourse($courseId, $sessionId);
// Format the response
$formatted = array_map(fn($category) => [
'id' => $category->getId(),
'title' => $category->getTitle(),
'parentId' => $category->getParent()?->getId(),
], $categories);
return new JsonResponse($formatted);
}
// Sets the default certificate for a gradebook category // Sets the default certificate for a gradebook category
#[Route('/set_default_certificate/{cid}/{certificateId}', name: 'chamilo_core_gradebook_set_default_certificate')] #[Route('/set_default_certificate/{cid}/{certificateId}', name: 'chamilo_core_gradebook_set_default_certificate')]
public function setDefaultCertificate(int $cid, int $certificateId, EntityManagerInterface $entityManager): Response public function setDefaultCertificate(int $cid, int $certificateId, EntityManagerInterface $entityManager): Response

@ -70,6 +70,7 @@ abstract class AbstractResource
'illustration:read', 'illustration:read',
'message:read', 'message:read',
'c_tool_intro:read', 'c_tool_intro:read',
'attendance:read',
])] ])]
#[ORM\OneToOne(targetEntity: ResourceNode::class, cascade: ['persist'])] #[ORM\OneToOne(targetEntity: ResourceNode::class, cascade: ['persist'])]
#[ORM\JoinColumn(name: 'resource_node_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'resource_node_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
@ -96,7 +97,7 @@ abstract class AbstractResource
*/ */
public $parentResource; public $parentResource;
#[Groups(['resource_node:read', 'document:read'])] #[Groups(['resource_node:read', 'document:read', 'attendance:read'])]
public ?array $resourceLinkListFromEntity = null; public ?array $resourceLinkListFromEntity = null;
/** /**
@ -105,7 +106,7 @@ abstract class AbstractResource
* *
* @var array<int, array<string, int>> * @var array<int, array<string, int>>
*/ */
#[Groups(['c_tool_intro:write', 'resource_node:write', 'c_student_publication:write', 'calendar_event:write'])] #[Groups(['c_tool_intro:write', 'resource_node:write', 'c_student_publication:write', 'calendar_event:write', 'attendance:write'])]
public array $resourceLinkList = []; public array $resourceLinkList = [];
/** /**

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CoreBundle\Migrations\Schema\V200;
use Chamilo\CoreBundle\Migrations\AbstractMigrationChamilo;
use Doctrine\DBAL\Schema\Schema;
final class Version20250126180000 extends AbstractMigrationChamilo
{
public function getDescription(): string
{
return 'Update the presence column in c_attendance_sheet from TINYINT(1) to INT to allow multiple attendance statuses.';
}
public function up(Schema $schema): void
{
// Alter the column type to integer
$this->addSql('ALTER TABLE c_attendance_sheet MODIFY presence INT NULL');
}
public function down(Schema $schema): void
{
// Revert the column type back to TINYINT(1)
$this->addSql('ALTER TABLE c_attendance_sheet MODIFY presence TINYINT(1) NOT NULL');
}
}

@ -6,14 +6,77 @@ declare(strict_types=1);
namespace Chamilo\CoreBundle\Repository; namespace Chamilo\CoreBundle\Repository;
use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\GradebookCategory; use Chamilo\CoreBundle\Entity\GradebookCategory;
use Chamilo\CoreBundle\Entity\Session;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
class GradeBookCategoryRepository extends ServiceEntityRepository class GradeBookCategoryRepository extends ServiceEntityRepository
{ {
public function __construct(ManagerRegistry $registry) private EntityManagerInterface $entityManager;
public function __construct(ManagerRegistry $registry, EntityManagerInterface $entityManager)
{ {
parent::__construct($registry, GradebookCategory::class); parent::__construct($registry, GradebookCategory::class);
$this->entityManager = $entityManager;
}
/**
* Retrieves gradebook categories for a specific course and optional session.
*
* @param int $courseId The ID of the course.
* @param int|null $sessionId The ID of the session (optional).
* @return GradebookCategory[] A list of gradebook categories.
*/
public function getCategoriesForCourse(int $courseId, ?int $sessionId = null): array
{
$qb = $this->createQueryBuilder('gc')
->where('gc.course = :courseId')
->setParameter('courseId', $courseId);
if ($sessionId !== null) {
$qb->andWhere('gc.session = :sessionId')
->setParameter('sessionId', $sessionId);
} else {
$qb->andWhere('gc.session IS NULL');
}
$qb->orderBy('gc.title', 'ASC');
return $qb->getQuery()->getResult();
}
/**
* Creates a default gradebook category for a course if it doesn't already exist.
*
* @param int $courseId The ID of the course.
* @param int|null $sessionId The ID of the session (optional).
* @return GradebookCategory The default category.
*/
public function createDefaultCategory(int $courseId, ?int $sessionId = null): GradebookCategory
{
$existingCategory = $this->findOneBy([
'course' => $courseId,
'session' => $sessionId,
'parent' => null, // Root category
]);
if ($existingCategory) {
return $existingCategory; // Return existing category
}
$defaultCategory = new GradebookCategory();
$defaultCategory->setTitle('Default');
$defaultCategory->setCourse($this->entityManager->getReference(Course::class, $courseId));
$defaultCategory->setSession($sessionId ? $this->entityManager->getReference(Session::class, $sessionId) : null);
$defaultCategory->setWeight(1.0);
$defaultCategory->setVisible(true);
$this->entityManager->persist($defaultCategory);
$this->entityManager->flush();
return $defaultCategory;
} }
} }

@ -8,11 +8,13 @@ namespace Chamilo\CoreBundle\Repository\Node;
use Chamilo\CoreBundle\Entity\AccessUrl; use Chamilo\CoreBundle\Entity\AccessUrl;
use Chamilo\CoreBundle\Entity\Course; use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\CourseRelUser;
use Chamilo\CoreBundle\Entity\ExtraField; use Chamilo\CoreBundle\Entity\ExtraField;
use Chamilo\CoreBundle\Entity\ExtraFieldValues; use Chamilo\CoreBundle\Entity\ExtraFieldValues;
use Chamilo\CoreBundle\Entity\Message; use Chamilo\CoreBundle\Entity\Message;
use Chamilo\CoreBundle\Entity\ResourceNode; use Chamilo\CoreBundle\Entity\ResourceNode;
use Chamilo\CoreBundle\Entity\Session; use Chamilo\CoreBundle\Entity\Session;
use Chamilo\CoreBundle\Entity\SessionRelCourseRelUser;
use Chamilo\CoreBundle\Entity\Tag; use Chamilo\CoreBundle\Entity\Tag;
use Chamilo\CoreBundle\Entity\TrackELogin; use Chamilo\CoreBundle\Entity\TrackELogin;
use Chamilo\CoreBundle\Entity\TrackEOnline; use Chamilo\CoreBundle\Entity\TrackEOnline;
@ -22,6 +24,7 @@ use Chamilo\CoreBundle\Entity\UsergroupRelUser;
use Chamilo\CoreBundle\Entity\UserRelTag; use Chamilo\CoreBundle\Entity\UserRelTag;
use Chamilo\CoreBundle\Entity\UserRelUser; use Chamilo\CoreBundle\Entity\UserRelUser;
use Chamilo\CoreBundle\Repository\ResourceRepository; use Chamilo\CoreBundle\Repository\ResourceRepository;
use Chamilo\CourseBundle\Entity\CGroupRelUser;
use Datetime; use Datetime;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria; use Doctrine\Common\Collections\Criteria;
@ -1123,4 +1126,50 @@ class UserRepository extends ResourceRepository implements PasswordUpgraderInter
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
public function findUsersByContext(int $courseId, ?int $sessionId = null, ?int $groupId = null): array
{
$course = $this->_em->getRepository(Course::class)->find($courseId);
if (!$course) {
throw new \InvalidArgumentException('Course not found.');
}
if ($sessionId !== null) {
$session = $this->_em->getRepository(Session::class)->find($sessionId);
if (!$session) {
throw new \InvalidArgumentException('Session not found.');
}
$list = $session->getSessionRelCourseRelUsersByStatus($course, Session::STUDENT);
$users = [];
if ($list) {
foreach ($list as $sessionCourseUser) {
$users[$sessionCourseUser->getUser()->getId()] = $sessionCourseUser->getUser();
}
}
return array_values($users);
}
if ($groupId !== null) {
$qb = $this->_em->createQueryBuilder();
$qb->select('u')
->from(CGroupRelUser::class, 'cgru')
->innerJoin('cgru.user', 'u')
->where('cgru.cId = :courseId')
->andWhere('cgru.group = :groupId')
->setParameters([
'courseId' => $courseId,
'groupId' => $groupId,
])
->orderBy('u.lastname', 'ASC')
->addOrderBy('u.firstname', 'ASC');
return $qb->getQuery()->getResult();
}
$queryBuilder = $this->_em->getRepository(Course::class)->getSubscribedStudents($course);
return $queryBuilder->getQuery()->getResult();
}
} }

@ -0,0 +1,133 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CoreBundle\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use Chamilo\CourseBundle\Entity\CAttendance;
use Chamilo\CourseBundle\Entity\CAttendanceCalendar;
use Chamilo\CourseBundle\Entity\CAttendanceCalendarRelGroup;
use Chamilo\CourseBundle\Repository\CAttendanceCalendarRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final class CAttendanceStateProcessor implements ProcessorInterface
{
public function __construct(
private readonly ProcessorInterface $persistProcessor,
private readonly EntityManagerInterface $entityManager,
private readonly CAttendanceCalendarRepository $calendarRepo,
private readonly RequestStack $requestStack
) {}
/**
* Main process function for handling attendance and calendar operations.
*/
public function process($data, Operation $operation, array $uriVariables = [], array $context = []): void
{
\assert($data instanceof CAttendance);
$operationName = $operation->getName();
error_log("Processing $operationName");
match ($operationName) {
'toggle_visibility' => $this->handleToggleVisibility($data),
'soft_delete' => $this->handleSoftDelete($data),
'calendar_add' => $this->handleAddCalendar($data),
default => throw new BadRequestHttpException('Operation not supported.'),
};
$this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
private function handleToggleVisibility(CAttendance $attendance): void
{
$attendance->setActive($attendance->getActive() === 1 ? 0 : 1);
$this->entityManager->persist($attendance);
}
private function handleSoftDelete(CAttendance $attendance): void
{
$attendance->setActive(2);
$this->entityManager->persist($attendance);
}
private function handleAddCalendar(CAttendance $attendance): void
{
$request = $this->requestStack->getCurrentRequest();
$data = json_decode($request->getContent(), true);
if (!$data) {
throw new BadRequestHttpException('Request data is required to create a calendar.');
}
$startDate = new \DateTime($data['startDate']);
$repeatDate = $data['repeatDate'] ?? false;
$repeatType = $data['repeatType'] ?? null;
$repeatDays = $data['repeatDays'] ?? null;
$endDate = $repeatDate ? new \DateTime($data['repeatEndDate']) : null;
$groupId = $data['group'] ?? 0;
$this->saveCalendar($attendance, $startDate, $groupId);
if ($repeatDate && $repeatType && $endDate) {
$interval = $this->getRepeatInterval($repeatType, $repeatDays);
$currentDate = clone $startDate;
while ($currentDate < $endDate) {
$currentDate->add($interval);
$this->saveCalendar($attendance, $currentDate, $groupId);
}
}
}
private function saveCalendar(CAttendance $attendance, \DateTime $date, ?int $groupId): void
{
$existingCalendar = $this->calendarRepo->findOneBy([
'attendance' => $attendance->getIid(),
'dateTime' => $date,
]);
if ($existingCalendar) {
return;
}
$calendar = new CAttendanceCalendar();
$calendar->setAttendance($attendance);
$calendar->setDateTime($date);
$calendar->setDoneAttendance(false);
$calendar->setBlocked(false);
$this->entityManager->persist($calendar);
$this->entityManager->flush();
if (!empty($groupId)) {
$this->addAttendanceCalendarToGroup($calendar, $groupId);
}
}
private function addAttendanceCalendarToGroup(CAttendanceCalendar $calendar, int $groupId): void
{
$repository = $this->entityManager->getRepository(CAttendanceCalendarRelGroup::class);
$repository->addGroupToCalendar($calendar->getIid(), $groupId);
}
private function getRepeatInterval(string $repeatType, ?int $repeatDays = null): \DateInterval
{
return match ($repeatType) {
'daily' => new \DateInterval('P1D'),
'weekly' => new \DateInterval('P7D'),
'bi-weekly' => new \DateInterval('P14D'),
'every-x-days' => new \DateInterval("P{$repeatDays}D"),
'monthly-by-date' => new \DateInterval('P1M'),
default => throw new BadRequestHttpException('Invalid repeat type.'),
};
}
}

@ -17,7 +17,7 @@ class Attendance extends AbstractTool implements ToolInterface
public function getLink(): string public function getLink(): string
{ {
return '/main/attendance/index.php'; return '/resources/attendance/:nodeId/';
} }
public function getIcon(): string public function getIcon(): string

@ -6,37 +6,110 @@ declare(strict_types=1);
namespace Chamilo\CourseBundle\Entity; namespace Chamilo\CourseBundle\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Chamilo\CoreBundle\Entity\AbstractResource; use Chamilo\CoreBundle\Entity\AbstractResource;
use Chamilo\CoreBundle\Entity\ResourceInterface; use Chamilo\CoreBundle\Entity\ResourceInterface;
use Chamilo\CoreBundle\State\CAttendanceStateProcessor;
use Chamilo\CourseBundle\Repository\CAttendanceRepository; use Chamilo\CourseBundle\Repository\CAttendanceRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Stringable; use Stringable;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
shortName: 'Attendances',
operations: [
new Put(
uriTemplate: '/attendances/{iid}/toggle_visibility',
openapiContext: [
'summary' => 'Toggle visibility of the attendance\'s associated ResourceLink',
],
security: "is_granted('EDIT', object.resourceNode)",
name: 'toggle_visibility',
processor: CAttendanceStateProcessor::class
),
new Put(
uriTemplate: '/attendances/{iid}/soft_delete',
openapiContext: [
'summary' => 'Soft delete the attendance',
],
security: "is_granted('EDIT', object.resourceNode)",
name: 'soft_delete',
processor: CAttendanceStateProcessor::class
),
new Delete(security: "is_granted('ROLE_TEACHER')"),
new Post(
uriTemplate: '/attendances/{iid}/calendars',
openapiContext: ['summary' => 'Add a calendar to an attendance.'],
denormalizationContext: ['groups' => ['attendance:write']],
name: 'calendar_add',
processor: CAttendanceStateProcessor::class
),
new GetCollection(
openapiContext: [
'parameters' => [
[
'name' => 'resourceNode.parent',
'in' => 'query',
'required' => true,
'description' => 'Resource node Parent',
'schema' => ['type' => 'integer'],
],
],
],
),
new Get(security: "is_granted('VIEW', object.resourceNode)"),
new Post(
denormalizationContext: ['groups' => ['attendance:write']],
security: "is_granted('ROLE_TEACHER')",
validationContext: ['groups' => ['Default']]
),
new Put(
denormalizationContext: ['groups' => ['attendance:write']],
security: "is_granted('ROLE_TEACHER')"
),
],
normalizationContext: ['groups' => ['attendance:read']],
denormalizationContext: ['groups' => ['attendance:write']],
paginationEnabled: true,
)]
#[ApiFilter(SearchFilter::class, properties: ['active' => 'exact', 'title' => 'partial', 'resourceNode.parent' => 'exact'])]
#[ORM\Table(name: 'c_attendance')] #[ORM\Table(name: 'c_attendance')]
#[ORM\Index(name: 'active', columns: ['active'])] #[ORM\Index(columns: ['active'], name: 'active')]
#[ORM\Entity(repositoryClass: CAttendanceRepository::class)] #[ORM\Entity(repositoryClass: CAttendanceRepository::class)]
class CAttendance extends AbstractResource implements ResourceInterface, Stringable class CAttendance extends AbstractResource implements ResourceInterface, Stringable
{ {
#[ORM\Column(name: 'iid', type: 'integer')] #[ORM\Column(name: 'iid', type: 'integer')]
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[Groups(['attendance:read'])]
protected ?int $iid = null; protected ?int $iid = null;
#[Assert\NotBlank] #[Assert\NotBlank]
#[ORM\Column(name: 'title', type: 'text', nullable: false)] #[ORM\Column(name: 'title', type: 'text', nullable: false)]
#[Groups(['attendance:read', 'attendance:write'])]
protected string $title; protected string $title;
#[ORM\Column(name: 'description', type: 'text', nullable: true)] #[ORM\Column(name: 'description', type: 'text', nullable: true)]
protected ?string $description; #[Groups(['attendance:read', 'attendance:write'])]
protected ?string $description = null;
#[Assert\NotBlank] #[Assert\NotBlank]
#[ORM\Column(name: 'active', type: 'integer', nullable: false)] #[ORM\Column(name: 'active', type: 'integer', nullable: false)]
protected int $active; #[Groups(['attendance:read', 'attendance:write'])]
protected int $active = 1;
#[ORM\Column(name: 'attendance_qualify_title', type: 'string', length: 255, nullable: true)] #[ORM\Column(name: 'attendance_qualify_title', type: 'string', length: 255, nullable: true)]
#[Groups(['attendance:read', 'attendance:write'])]
protected ?string $attendanceQualifyTitle = null; protected ?string $attendanceQualifyTitle = null;
#[Assert\NotNull] #[Assert\NotNull]
@ -45,7 +118,8 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa
#[Assert\NotNull] #[Assert\NotNull]
#[ORM\Column(name: 'attendance_weight', type: 'float', precision: 6, scale: 2, nullable: false)] #[ORM\Column(name: 'attendance_weight', type: 'float', precision: 6, scale: 2, nullable: false)]
protected float $attendanceWeight; #[Groups(['attendance:read', 'attendance:write'])]
protected float $attendanceWeight = 0.0;
#[Assert\NotNull] #[Assert\NotNull]
#[ORM\Column(name: 'locked', type: 'integer', nullable: false)] #[ORM\Column(name: 'locked', type: 'integer', nullable: false)]
@ -54,19 +128,20 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa
/** /**
* @var Collection|CAttendanceCalendar[] * @var Collection|CAttendanceCalendar[]
*/ */
#[ORM\OneToMany(targetEntity: CAttendanceCalendar::class, mappedBy: 'attendance', cascade: ['persist', 'remove'])] #[ORM\OneToMany(mappedBy: 'attendance', targetEntity: CAttendanceCalendar::class, cascade: ['persist', 'remove'])]
#[Groups(['attendance:read'])]
protected Collection $calendars; protected Collection $calendars;
/** /**
* @var Collection|CAttendanceResult[] * @var Collection|CAttendanceResult[]
*/ */
#[ORM\OneToMany(targetEntity: CAttendanceResult::class, mappedBy: 'attendance', cascade: ['persist', 'remove'])] #[ORM\OneToMany(mappedBy: 'attendance', targetEntity: CAttendanceResult::class, cascade: ['persist', 'remove'])]
protected Collection $results; protected Collection $results;
/** /**
* @var Collection|CAttendanceSheetLog[] * @var Collection|CAttendanceSheetLog[]
*/ */
#[ORM\OneToMany(targetEntity: CAttendanceSheetLog::class, mappedBy: 'attendance', cascade: ['persist', 'remove'])] #[ORM\OneToMany(mappedBy: 'attendance', targetEntity: CAttendanceSheetLog::class, cascade: ['persist', 'remove'])]
protected Collection $logs; protected Collection $logs;
public function __construct() public function __construct()
@ -145,7 +220,7 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa
* *
* @return int * @return int
*/ */
public function getAttendanceQualifyMax() public function getAttendanceQualifyMax(): int
{ {
return $this->attendanceQualifyMax; return $this->attendanceQualifyMax;
} }
@ -162,7 +237,7 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa
* *
* @return float * @return float
*/ */
public function getAttendanceWeight() public function getAttendanceWeight(): float
{ {
return $this->attendanceWeight; return $this->attendanceWeight;
} }
@ -179,7 +254,7 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa
* *
* @return int * @return int
*/ */
public function getLocked() public function getLocked(): int
{ {
return $this->locked; return $this->locked;
} }
@ -201,6 +276,16 @@ class CAttendance extends AbstractResource implements ResourceInterface, Stringa
return $this; return $this;
} }
public function addCalendar(CAttendanceCalendar $calendar): self
{
if (!$this->calendars->contains($calendar)) {
$this->calendars->add($calendar);
$calendar->setAttendance($this);
}
return $this;
}
/** /**
* @return CAttendanceSheetLog[]|Collection * @return CAttendanceSheetLog[]|Collection
*/ */

@ -6,10 +6,18 @@ declare(strict_types=1);
namespace Chamilo\CourseBundle\Entity; namespace Chamilo\CourseBundle\Entity;
use ApiPlatform\Metadata\ApiResource;
use DateTime; use DateTime;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ApiResource(
normalizationContext: ['groups' => ['attendance_calendar:read']],
denormalizationContext: ['groups' => ['attendance_calendar:write']],
paginationEnabled: false,
security: "is_granted('ROLE_TEACHER')"
)]
#[ORM\Table(name: 'c_attendance_calendar')] #[ORM\Table(name: 'c_attendance_calendar')]
#[ORM\Index(columns: ['done_attendance'], name: 'done_attendance')] #[ORM\Index(columns: ['done_attendance'], name: 'done_attendance')]
#[ORM\Entity] #[ORM\Entity]
@ -18,21 +26,29 @@ class CAttendanceCalendar
#[ORM\Column(name: 'iid', type: 'integer')] #[ORM\Column(name: 'iid', type: 'integer')]
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[Groups(['attendance:read', 'attendance_calendar:read'])]
protected ?int $iid = null; protected ?int $iid = null;
#[ORM\ManyToOne(targetEntity: CAttendance::class, cascade: ['remove'], inversedBy: 'calendars')] #[ORM\ManyToOne(targetEntity: CAttendance::class, inversedBy: 'calendars')]
#[ORM\JoinColumn(name: 'attendance_id', referencedColumnName: 'iid', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'attendance_id', referencedColumnName: 'iid', onDelete: 'CASCADE')]
#[Groups(['attendance:write', 'attendance:read', 'attendance_calendar:read'])]
protected CAttendance $attendance; protected CAttendance $attendance;
#[ORM\Column(name: 'date_time', type: 'datetime', nullable: false)] #[ORM\Column(name: 'date_time', type: 'datetime', nullable: false)]
#[Groups(['attendance:read', 'attendance:write', 'attendance_calendar:read', 'attendance_calendar:write'])]
protected DateTime $dateTime; protected DateTime $dateTime;
#[ORM\Column(name: 'done_attendance', type: 'boolean', nullable: false)] #[ORM\Column(name: 'done_attendance', type: 'boolean', nullable: false)]
#[Groups(['attendance:read', 'attendance:write'])]
protected bool $doneAttendance; protected bool $doneAttendance;
#[ORM\Column(name: 'blocked', type: 'boolean', nullable: false)] #[ORM\Column(name: 'blocked', type: 'boolean', nullable: false)]
#[Groups(['attendance:read', 'attendance:write'])]
protected bool $blocked; protected bool $blocked;
#[ORM\Column(name: 'duration', type: 'integer', nullable: true)]
protected ?int $duration = null;
/** /**
* @var Collection<int, CAttendanceSheet> * @var Collection<int, CAttendanceSheet>
*/ */
@ -43,9 +59,6 @@ class CAttendanceCalendar
)] )]
protected Collection $sheets; protected Collection $sheets;
#[ORM\Column(name: 'duration', type: 'integer', nullable: true)]
protected ?int $duration = null;
public function getIid(): ?int public function getIid(): ?int
{ {
return $this->iid; return $this->iid;

@ -6,13 +6,14 @@ declare(strict_types=1);
namespace Chamilo\CourseBundle\Entity; namespace Chamilo\CourseBundle\Entity;
use Chamilo\CourseBundle\Repository\CAttendanceCalendarRelGroupRepository;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
/** /**
* CAttendanceCalendarRelGroup. * CAttendanceCalendarRelGroup.
*/ */
#[ORM\Table(name: 'c_attendance_calendar_rel_group')] #[ORM\Table(name: 'c_attendance_calendar_rel_group')]
#[ORM\Entity] #[ORM\Entity(repositoryClass: CAttendanceCalendarRelGroupRepository::class)]
class CAttendanceCalendarRelGroup class CAttendanceCalendarRelGroup
{ {
#[ORM\Column(name: 'iid', type: 'integer')] #[ORM\Column(name: 'iid', type: 'integer')]

@ -21,6 +21,7 @@ class CAttendanceResult
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['attendance:read'])]
protected User $user; protected User $user;
#[ORM\ManyToOne(targetEntity: CAttendance::class, inversedBy: 'results')] #[ORM\ManyToOne(targetEntity: CAttendance::class, inversedBy: 'results')]

@ -20,9 +20,19 @@ class CAttendanceSheet
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
protected ?int $iid = null; protected ?int $iid = null;
/**
* Attendance status for each user on a given date:
* - 0: Absent (Score: 0)
* - 1: Present (Score: 1)
* - 2: Late less than 15 minutes (Score: 1)
* - 3: Late more than 15 minutes (Score: 0.5)
* - 4: Absent but justified (Score: 0.25)
*
* Scores are tentative and can be used for gradebook calculations.
*/
#[Assert\NotNull] #[Assert\NotNull]
#[ORM\Column(name: 'presence', type: 'boolean', nullable: false)] #[ORM\Column(name: 'presence', type: 'integer', nullable: true)]
protected bool $presence; protected ?int $presence = null;
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
@ -33,28 +43,33 @@ class CAttendanceSheet
protected CAttendanceCalendar $attendanceCalendar; protected CAttendanceCalendar $attendanceCalendar;
#[ORM\Column(name: 'signature', type: 'text', nullable: true)] #[ORM\Column(name: 'signature', type: 'text', nullable: true)]
protected string $signature; protected ?string $signature;
public function setPresence(bool $presence): self public function getIid(): ?int
{
return $this->iid;
}
public function setPresence(?int $presence): self
{ {
$this->presence = $presence; $this->presence = $presence;
return $this; return $this;
} }
public function getPresence(): bool public function getPresence(): ?int
{ {
return $this->presence; return $this->presence;
} }
public function setSignature(string $signature): static public function setSignature(?string $signature): static
{ {
$this->signature = $signature; $this->signature = $signature;
return $this; return $this;
} }
public function getSignature(): string public function getSignature(): ?string
{ {
return $this->signature; return $this->signature;
} }

@ -6,23 +6,47 @@ declare(strict_types=1);
namespace Chamilo\CourseBundle\Entity; namespace Chamilo\CourseBundle\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use Chamilo\CoreBundle\Entity\AbstractResource; use ApiPlatform\Metadata\GetCollection;
use Chamilo\CoreBundle\Entity\ResourceInterface; use ApiPlatform\Metadata\Get;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CourseBundle\Repository\CGroupRepository; use Chamilo\CourseBundle\Repository\CGroupRepository;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Chamilo\CoreBundle\Entity\AbstractResource;
use Chamilo\CoreBundle\Entity\ResourceInterface;
use Chamilo\CoreBundle\Entity\User;
use Stringable; use Stringable;
use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Uid\Uuid; use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
/** #[ApiResource(
* Course groups. shortName: 'Groups',
*/ operations: [
#[ApiResource(normalizationContext: ['groups' => ['group:read']], security: "is_granted('ROLE_ADMIN')")] new GetCollection(
uriTemplate: '/groups',
openapiContext: [
'parameters' => [
[
'name' => 'resourceNode.parent',
'in' => 'query',
'required' => true,
'description' => 'Filter groups by the parent resource node (course)',
'schema' => ['type' => 'integer'],
],
],
]
),
new Get(security: "is_granted('VIEW', object.resourceNode)")
],
normalizationContext: ['groups' => ['group:read']],
denormalizationContext: ['groups' => ['group:write']],
paginationEnabled: true
)]
#[ApiFilter(SearchFilter::class, properties: ['resourceNode.parent' => 'exact'])]
#[ORM\Table(name: 'c_group_info')] #[ORM\Table(name: 'c_group_info')]
#[ORM\Entity(repositoryClass: CGroupRepository::class)] #[ORM\Entity(repositoryClass: CGroupRepository::class)]
class CGroup extends AbstractResource implements ResourceInterface, Stringable class CGroup extends AbstractResource implements ResourceInterface, Stringable
@ -42,11 +66,13 @@ class CGroup extends AbstractResource implements ResourceInterface, Stringable
protected string $title; protected string $title;
#[Assert\NotNull] #[Assert\NotNull]
#[ORM\Column(name: 'status', type: 'boolean', nullable: false)] #[ORM\Column(name: 'status', type: 'boolean', nullable: false)]
#[Groups(['group:read', 'group:write'])]
protected bool $status; protected bool $status;
#[ORM\ManyToOne(targetEntity: CGroupCategory::class, cascade: ['persist'])] #[ORM\ManyToOne(targetEntity: CGroupCategory::class, cascade: ['persist'])]
#[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'iid', onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'iid', onDelete: 'CASCADE')]
protected ?CGroupCategory $category = null; protected ?CGroupCategory $category = null;
#[ORM\Column(name: 'description', type: 'text', nullable: true)] #[ORM\Column(name: 'description', type: 'text', nullable: true)]
#[Groups(['group:read', 'group:write'])]
protected ?string $description = null; protected ?string $description = null;
#[Assert\NotBlank] #[Assert\NotBlank]
#[ORM\Column(name: 'max_student', type: 'integer')] #[ORM\Column(name: 'max_student', type: 'integer')]

@ -0,0 +1,42 @@
<?php
/* For licensing terms, see /license.txt */
declare(strict_types=1);
namespace Chamilo\CourseBundle\Repository;
use Chamilo\CoreBundle\Repository\ResourceRepository;
use Chamilo\CourseBundle\Entity\CAttendanceCalendar;
use Chamilo\CourseBundle\Entity\CAttendanceCalendarRelGroup;
use Chamilo\CourseBundle\Entity\CGroup;
use Doctrine\Persistence\ManagerRegistry;
class CAttendanceCalendarRelGroupRepository extends ResourceRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CAttendanceCalendarRelGroup::class);
}
/**
* Add a group relation to a calendar entry.
*/
public function addGroupToCalendar(int $calendarId, int $groupId): void
{
$em = $this->getEntityManager();
$existingRelation = $this->findOneBy([
'attendanceCalendar' => $calendarId,
'group' => $groupId,
]);
if (!$existingRelation) {
$relation = new CAttendanceCalendarRelGroup();
$relation->setAttendanceCalendar($em->getReference(CAttendanceCalendar::class, $calendarId));
$relation->setGroup($em->getReference(CGroup::class, $groupId));
$em->persist($relation);
$em->flush();
}
}
}

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CourseBundle\Repository;
use Chamilo\CoreBundle\Repository\ResourceRepository;
use Chamilo\CourseBundle\Entity\CAttendanceCalendar;
use Chamilo\CourseBundle\Entity\CAttendanceSheet;
use Doctrine\Persistence\ManagerRegistry;
final class CAttendanceCalendarRepository extends ResourceRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CAttendanceCalendar::class);
}
/**
* Retrieves all calendar events for a specific attendance.
*
* @param int $attendanceId
* @return CAttendanceCalendar[]
*/
public function findByAttendanceId(int $attendanceId): array
{
return $this->createQueryBuilder('c')
->where('c.attendance = :attendanceId')
->setParameter('attendanceId', $attendanceId)
->orderBy('c.dateTime', 'ASC')
->getQuery()
->getResult();
}
/**
* Deletes all calendar events associated with a specific attendance.
*
* @param int $attendanceId
* @return int The number of deleted records
*/
public function deleteAllByAttendance(int $attendanceId): int
{
return $this->createQueryBuilder('c')
->delete()
->where('c.attendance = :attendanceId')
->setParameter('attendanceId', $attendanceId)
->getQuery()
->execute();
}
/**
* Finds a specific calendar event by its ID and the associated attendance ID.
*
* @param int $calendarId
* @param int $attendanceId
* @return CAttendanceCalendar|null
*/
public function findByIdAndAttendance(int $calendarId, int $attendanceId): ?CAttendanceCalendar
{
return $this->createQueryBuilder('c')
->where('c.id = :calendarId')
->andWhere('c.attendance = :attendanceId')
->setParameters([
'calendarId' => $calendarId,
'attendanceId' => $attendanceId,
])
->getQuery()
->getOneOrNullResult();
}
/**
* Retrieves calendar events filtered by a date range.
*
* @param int $attendanceId
* @param \DateTime|null $startDate
* @param \DateTime|null $endDate
* @return CAttendanceCalendar[]
*/
public function findByDateRange(
int $attendanceId,
?\DateTime $startDate,
?\DateTime $endDate
): array {
$qb = $this->createQueryBuilder('c')
->where('c.attendance = :attendanceId')
->setParameter('attendanceId', $attendanceId);
if ($startDate) {
$qb->andWhere('c.dateTime >= :startDate')
->setParameter('startDate', $startDate);
}
if ($endDate) {
$qb->andWhere('c.dateTime <= :endDate')
->setParameter('endDate', $endDate);
}
return $qb->orderBy('c.dateTime', 'ASC')
->getQuery()
->getResult();
}
/**
* Checks if a calendar event is blocked.
*
* @param int $calendarId
* @return bool
*/
public function isBlocked(int $calendarId): bool
{
return (bool) $this->createQueryBuilder('c')
->select('c.blocked')
->where('c.id = :calendarId')
->setParameter('calendarId', $calendarId)
->getQuery()
->getSingleScalarResult();
}
public function findAttendanceWithData(int $attendanceId): array
{
$calendars = $this->createQueryBuilder('calendar')
->andWhere('calendar.attendance = :attendanceId')
->setParameter('attendanceId', $attendanceId)
->orderBy('calendar.dateTime', 'ASC')
->getQuery()
->getResult();
$attendanceDates = array_map(function (CAttendanceCalendar $calendar) {
return [
'id' => $calendar->getIid(),
'label' => $calendar->getDateTime()->format('M d, Y - h:i A'),
];
}, $calendars);
$attendanceData = [];
foreach ($calendars as $calendar) {
/* @var CAttendanceSheet $sheet */
foreach ($calendar->getSheets() as $sheet) {
$key = $sheet->getUser()->getId() . '-' . $calendar->getIid();
$attendanceData[$key] = (int) $sheet->getPresence(); // Status: 1 (Present), 0 (Absent), null (No Status)
}
}
return [
'attendanceDates' => $attendanceDates,
'attendanceData' => $attendanceData,
];
}
public function countByAttendanceAndGroup(int $attendanceId, ?int $groupId = null): int
{
$qb = $this->createQueryBuilder('calendar')
->select('COUNT(calendar.iid)')
->where('calendar.attendance = :attendanceId')
->setParameter('attendanceId', $attendanceId);
if ($groupId) {
$qb->join('calendar.groups', 'groups')
->andWhere('groups.group = :groupId')
->setParameter('groupId', $groupId);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function countDoneAttendanceByAttendanceAndGroup(int $attendanceId, ?int $groupId = null): int
{
$qb = $this->createQueryBuilder('calendar')
->select('COUNT(calendar.iid)')
->where('calendar.attendance = :attendanceId')
->andWhere('calendar.doneAttendance = true')
->setParameter('attendanceId', $attendanceId);
if ($groupId) {
$qb->join('calendar.groups', 'groups')
->andWhere('groups.group = :groupId')
->setParameter('groupId', $groupId);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
}

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/* For licensing terms, see /license.txt */
namespace Chamilo\CourseBundle\Repository;
use Chamilo\CoreBundle\Repository\ResourceRepository;
use Chamilo\CourseBundle\Entity\CAttendanceSheet;
use Doctrine\Persistence\ManagerRegistry;
final class CAttendanceSheetRepository extends ResourceRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, CAttendanceSheet::class);
}
public function getUserScore(int $userId, int $attendanceId, ?int $groupId = null): int
{
$qb = $this->createQueryBuilder('sheet')
->select('SUM(sheet.presence) as score')
->join('sheet.attendanceCalendar', 'calendar')
->where('calendar.attendance = :attendanceId')
->andWhere('sheet.user = :userId')
->setParameter('attendanceId', $attendanceId)
->setParameter('userId', $userId);
if ($groupId) {
$qb->join('calendar.groups', 'groups')
->andWhere('groups.group = :groupId')
->setParameter('groupId', $groupId);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
}
Loading…
Cancel
Save