Merge 05e9fab473
into d1f1a407e6
commit
f83a707a25
@ -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> |
@ -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"), |
||||
}, |
||||
], |
||||
} |
@ -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> |
@ -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); |
||||
} |
||||
} |
@ -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'); |
||||
} |
||||
} |
@ -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.'), |
||||
}; |
||||
} |
||||
} |
@ -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…
Reference in new issue