Merge remote-tracking branch 'origin/feat/232-assignments-pagina-ui-ux' into feat/232-assignments-pagina-ui-ux
# Conflicts: # frontend/src/views/assignments/TeacherAssignment.vue
This commit is contained in:
		
						commit
						8f57da4bc1
					
				
					 11 changed files with 615 additions and 600 deletions
				
			
		|  | @ -39,9 +39,9 @@ export async function getAllAssignmentsHandler(req: Request, res: Response): Pro | |||
| 
 | ||||
| export async function createAssignmentHandler(req: Request, res: Response): Promise<void> { | ||||
|     const classid = req.params.classid; | ||||
|     const description = req.body.description || ""; | ||||
|     const description = req.body.description || ''; | ||||
|     const language = req.body.language || FALLBACK_LANG; | ||||
|     const learningPath = req.body.learningPath || ""; | ||||
|     const learningPath = req.body.learningPath || ''; | ||||
|     const title = req.body.title; | ||||
| 
 | ||||
|     requireFields({ title }); | ||||
|  |  | |||
|  | @ -20,7 +20,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | |||
|                     }, | ||||
|                 }, | ||||
|             }, | ||||
|             populate: ['groups', 'groups.members'] | ||||
|             populate: ['groups', 'groups.members'], | ||||
|         }); | ||||
|     } | ||||
|     public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { | ||||
|  |  | |||
|  | @ -31,7 +31,6 @@ router.get('/:username/students', preventImpersonation, getTeacherStudentHandler | |||
| 
 | ||||
| router.get(`/:username/assignments`, getTeacherAssignmentsHandler); | ||||
| 
 | ||||
| 
 | ||||
| router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); | ||||
| 
 | ||||
| router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); | ||||
|  |  | |||
|  | @ -103,20 +103,22 @@ export async function putAssignment(classid: string, id: number, assignmentData: | |||
|     if (assignmentData.groups) { | ||||
|         const hasDuplicates = (arr: string[]) => new Set(arr).size !== arr.length; | ||||
|         if (hasDuplicates(assignmentData.groups.flat() as string[])) { | ||||
|             throw new BadRequestException("Student can only be in one group"); | ||||
|             throw new BadRequestException('Student can only be in one group'); | ||||
|         } | ||||
| 
 | ||||
|         const studentLists = await Promise.all((assignmentData.groups! as string[][]).map(async group => await fetchStudents(group))); | ||||
|         const studentLists = await Promise.all((assignmentData.groups as string[][]).map(async (group) => await fetchStudents(group))); | ||||
| 
 | ||||
|         const groupRepository = getGroupRepository(); | ||||
|         await groupRepository.deleteAllByAssignment(assignment); | ||||
|         await Promise.all(studentLists.map(async students => { | ||||
|             const newGroup = groupRepository.create({ | ||||
|                 assignment: assignment, | ||||
|                 members: students, | ||||
|             }); | ||||
|             await groupRepository.save(newGroup); | ||||
|         })); | ||||
|         await Promise.all( | ||||
|             studentLists.map(async (students) => { | ||||
|                 const newGroup = groupRepository.create({ | ||||
|                     assignment: assignment, | ||||
|                     members: students, | ||||
|                 }); | ||||
|                 await groupRepository.save(newGroup); | ||||
|             }) | ||||
|         ); | ||||
| 
 | ||||
|         delete assignmentData.groups; | ||||
|     } | ||||
|  |  | |||
|  | @ -1,9 +1,4 @@ | |||
| import { | ||||
|     getAssignmentRepository, | ||||
|     getClassJoinRequestRepository, | ||||
|     getClassRepository, | ||||
|     getTeacherRepository, | ||||
| } from '../data/repositories.js'; | ||||
| import { getAssignmentRepository, getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js'; | ||||
| import { mapToClassDTO } from '../interfaces/class.js'; | ||||
| import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; | ||||
| import { Teacher } from '../entities/users/teacher.entity.js'; | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useAssignmentSubmissionsQuery } from "@/queries/assignments.ts"; | ||||
|     import type { SubmissionsResponse } from "@/controllers/submissions.ts"; | ||||
|     import {watch} from "vue"; | ||||
|     import { watch } from "vue"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         group: object; | ||||
|  | @ -29,7 +29,7 @@ | |||
|                 emit("update:hasSubmission", data.submissions.length > 0); | ||||
|             } | ||||
|         }, | ||||
|         { immediate: true } | ||||
|         { immediate: true }, | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,8 +6,7 @@ | |||
| 
 | ||||
|     const datetime = ref(""); | ||||
| 
 | ||||
|     datetime.value = props.deadline ? new Date(props.deadline).toISOString().slice(0, 16) : "" | ||||
| 
 | ||||
|     datetime.value = props.deadline ? new Date(props.deadline).toISOString().slice(0, 16) : ""; | ||||
| 
 | ||||
|     // Watch the datetime value and emit the update | ||||
|     watch(datetime, (val) => { | ||||
|  | @ -21,7 +20,6 @@ | |||
| 
 | ||||
|     const deadlineRules = [ | ||||
|         (value: string): string | boolean => { | ||||
| 
 | ||||
|             const selectedDateTime = new Date(value); | ||||
|             const now = new Date(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,281 +1,135 @@ | |||
| <script setup lang="ts"> | ||||
| import {computed, ref, watch} from "vue"; | ||||
| import {useI18n} from "vue-i18n"; | ||||
| import {useClassStudentsQuery} from "@/queries/classes"; | ||||
|     import { computed, ref, watch } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useClassStudentsQuery } from "@/queries/classes"; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|     classId: string | undefined; | ||||
|     groups: object[]; | ||||
| }>(); | ||||
| const emit = defineEmits(["close", "groupsUpdated", "done"]); | ||||
| const {t} = useI18n(); | ||||
|     const props = defineProps<{ | ||||
|         classId: string | undefined; | ||||
|         groups: object[]; | ||||
|     }>(); | ||||
|     const emit = defineEmits(["close", "groupsUpdated", "done"]); | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
| interface StudentItem { | ||||
|     username: string; | ||||
|     fullName: string; | ||||
| } | ||||
| 
 | ||||
| const {data: studentsData} = useClassStudentsQuery(() => props.classId, true); | ||||
| 
 | ||||
| // Dialog states for group editing | ||||
| const activeDialog = ref<"random" | "dragdrop" | null>(null); | ||||
| 
 | ||||
| // Drag state for the drag and drop | ||||
| const draggedItem = ref<{ groupIndex: number, studentIndex: number } | null>(null); | ||||
| 
 | ||||
| const currentGroups = ref<StudentItem[][]>([]); | ||||
| const unassignedStudents = ref<StudentItem[]>([]); | ||||
| const allStudents = ref<StudentItem[]>([]); | ||||
| 
 | ||||
| // Random groups state | ||||
| const groupSize = ref(1); | ||||
| const randomGroupsPreview = ref<StudentItem[][]>([]); | ||||
| 
 | ||||
| // Initialize data | ||||
| watch( | ||||
|     () => [studentsData.value, props.groups], | ||||
|     ([studentsVal, existingGroups]) => { | ||||
|         if (!studentsVal) return; | ||||
| 
 | ||||
|         // Initialize all students | ||||
|         allStudents.value = studentsVal.students.map((s) => ({ | ||||
|             username: s.username, | ||||
|             fullName: `${s.firstName} ${s.lastName}`, | ||||
|         })); | ||||
| 
 | ||||
|         // Initialize groups if they exist | ||||
|         if (existingGroups && existingGroups.length > 0) { | ||||
|             currentGroups.value = existingGroups.map((group) => | ||||
|                 group.members.map(member => ({ | ||||
|                     username: member.username, | ||||
|                     fullName: `${member.firstName} ${member.lastName}` | ||||
|                 })) | ||||
|             ); | ||||
|             const assignedUsernames = new Set( | ||||
|                 existingGroups.flatMap((g) => g.members.map((m: StudentItem) => m.username)), | ||||
|             ); | ||||
|             unassignedStudents.value = allStudents.value.filter((s) => !assignedUsernames.has(s.username)); | ||||
|         } else { | ||||
|             currentGroups.value = []; | ||||
|             unassignedStudents.value = [...allStudents.value]; | ||||
|         } | ||||
| 
 | ||||
|         randomGroupsPreview.value = [...currentGroups.value]; | ||||
|     }, | ||||
|     {immediate: true}, | ||||
| ); | ||||
| 
 | ||||
| /** Random groups functions */ | ||||
| function generateRandomGroups(): void { | ||||
|     if (groupSize.value < 1) return; | ||||
| 
 | ||||
|     // Shuffle students | ||||
|     const shuffled = [...allStudents.value].sort(() => Math.random() - 0.5); | ||||
| 
 | ||||
|     // Create new groups | ||||
|     const newGroups: StudentItem[][] = []; | ||||
|     const groupCount = Math.ceil(shuffled.length / groupSize.value); | ||||
| 
 | ||||
|     for (let i = 0; i < groupCount; i++) { | ||||
|         newGroups.push([]); | ||||
|     interface StudentItem { | ||||
|         username: string; | ||||
|         fullName: string; | ||||
|     } | ||||
| 
 | ||||
|     // Distribute students | ||||
|     shuffled.forEach((student, index) => { | ||||
|         const groupIndex = index % groupCount; | ||||
|         newGroups[groupIndex].push(student); | ||||
|     }); | ||||
|     const { data: studentsData } = useClassStudentsQuery(() => props.classId, true); | ||||
| 
 | ||||
|     randomGroupsPreview.value = newGroups; | ||||
| } | ||||
|     // Dialog states for group editing | ||||
|     const activeDialog = ref<"random" | "dragdrop" | null>(null); | ||||
| 
 | ||||
| function saveRandomGroups(): void { | ||||
|     if (randomGroupsPreview.value.length === 0) { | ||||
|         alert(t("please-generate-groups-first")); | ||||
|         return; | ||||
|     } | ||||
|     // Drag state for the drag and drop | ||||
|     const draggedItem = ref<{ groupIndex: number; studentIndex: number } | null>(null); | ||||
| 
 | ||||
|     emit( | ||||
|         "groupsUpdated", | ||||
|         randomGroupsPreview.value.map((g) => g.map((s) => s.username)), | ||||
|     ); | ||||
|     activeDialog.value = null; | ||||
|     emit("done"); | ||||
|     emit("close"); | ||||
| } | ||||
|     const currentGroups = ref<StudentItem[][]>([]); | ||||
|     const unassignedStudents = ref<StudentItem[]>([]); | ||||
|     const allStudents = ref<StudentItem[]>([]); | ||||
| 
 | ||||
| function addNewGroup() { | ||||
|     currentGroups.value.push([]); | ||||
| } | ||||
|     // Random groups state | ||||
|     const groupSize = ref(1); | ||||
|     const randomGroupsPreview = ref<StudentItem[][]>([]); | ||||
| 
 | ||||
| function removeGroup(index: number) { | ||||
|     // Move students back to unassigned | ||||
|     unassignedStudents.value.push(...currentGroups.value[index]); | ||||
|     currentGroups.value.splice(index, 1); | ||||
| } | ||||
|     // Initialize data | ||||
|     watch( | ||||
|         () => [studentsData.value, props.groups], | ||||
|         ([studentsVal, existingGroups]) => { | ||||
|             if (!studentsVal) return; | ||||
| 
 | ||||
| /** Drag and drop functions */ | ||||
|             // Initialize all students | ||||
|             allStudents.value = studentsVal.students.map((s) => ({ | ||||
|                 username: s.username, | ||||
|                 fullName: `${s.firstName} ${s.lastName}`, | ||||
|             })); | ||||
| 
 | ||||
| // Touch state interface | ||||
| interface TouchState { | ||||
|     isDragging: boolean; | ||||
|     startX: number; | ||||
|     startY: number; | ||||
|     currentGroupIndex: number; | ||||
|     currentStudentIndex: number; | ||||
|     element: HTMLElement | null; | ||||
|     clone: HTMLElement | null; | ||||
|     originalRect: DOMRect | null; | ||||
|     hasMoved: boolean; | ||||
| } | ||||
| 
 | ||||
| const touchState = ref<TouchState>({ | ||||
|     isDragging: false, | ||||
|     startX: 0, | ||||
|     startY: 0, | ||||
|     currentGroupIndex: -1, | ||||
|     currentStudentIndex: -1, | ||||
|     element: null, | ||||
|     clone: null, | ||||
|     originalRect: null, | ||||
|     hasMoved: false | ||||
| }); | ||||
| 
 | ||||
| function handleTouchStart(event: TouchEvent, groupIndex: number, studentIndex: number): void { | ||||
|     if (event.touches.length > 1) return; | ||||
| 
 | ||||
|     const touch = event.touches[0]; | ||||
|     const target = event.target as HTMLElement; | ||||
|     // Target the chip directly instead of the draggable container | ||||
|     const chip = target.closest('.v-chip') as HTMLElement; | ||||
| 
 | ||||
|     if (!chip) return; | ||||
| 
 | ||||
|     // Get the chip's position relative to the viewport | ||||
|     const rect = chip.getBoundingClientRect(); | ||||
| 
 | ||||
|     touchState.value = { | ||||
|         isDragging: true, | ||||
|         startX: touch.clientX, | ||||
|         startY: touch.clientY, | ||||
|         currentGroupIndex: groupIndex, | ||||
|         currentStudentIndex: studentIndex, | ||||
|         element: chip, | ||||
|         clone: null, | ||||
|         originalRect: rect, | ||||
|         hasMoved: false | ||||
|     }; | ||||
| 
 | ||||
|     // Clone only the chip | ||||
|     const clone = chip.cloneNode(true) as HTMLElement; | ||||
|     clone.classList.add('drag-clone'); | ||||
|     clone.style.position = 'fixed'; | ||||
|     clone.style.zIndex = '10000'; | ||||
|     clone.style.opacity = '0.9'; | ||||
|     clone.style.pointerEvents = 'none'; | ||||
|     clone.style.width = `${rect.width}px`; | ||||
|     clone.style.height = `${rect.height}px`; | ||||
|     clone.style.left = `${rect.left}px`; | ||||
|     clone.style.top = `${rect.top}px`; | ||||
|     clone.style.transform = 'scale(1.05)'; | ||||
|     clone.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)'; | ||||
|     clone.style.transition = 'transform 0.1s'; | ||||
| 
 | ||||
|     // Ensure the clone has the same chip styling | ||||
|     clone.style.backgroundColor = getComputedStyle(chip).backgroundColor; | ||||
|     clone.style.color = getComputedStyle(chip).color; | ||||
|     clone.style.borderRadius = getComputedStyle(chip).borderRadius; | ||||
|     clone.style.padding = getComputedStyle(chip).padding; | ||||
|     clone.style.margin = '0'; // Remove any margin | ||||
| 
 | ||||
|     document.body.appendChild(clone); | ||||
|     touchState.value.clone = clone; | ||||
|     chip.style.visibility = 'hidden'; | ||||
| 
 | ||||
|     event.preventDefault(); | ||||
|     event.stopPropagation(); | ||||
| } | ||||
| 
 | ||||
| function handleTouchMove(event: TouchEvent): void { | ||||
|     if (!touchState.value.isDragging || !touchState.value.clone || event.touches.length > 1) return; | ||||
| 
 | ||||
|     const touch = event.touches[0]; | ||||
|     const clone = touchState.value.clone; | ||||
| 
 | ||||
|     const dx = Math.abs(touch.clientX - touchState.value.startX); | ||||
|     const dy = Math.abs(touch.clientY - touchState.value.startY); | ||||
| 
 | ||||
|     if (dx > 5 || dy > 5) { | ||||
|         touchState.value.hasMoved = true; | ||||
|     } | ||||
| 
 | ||||
|     clone.style.left = `${touch.clientX - clone.offsetWidth / 2}px`; | ||||
|     clone.style.top = `${touch.clientY - clone.offsetHeight / 2}px`; | ||||
| 
 | ||||
|     document.querySelectorAll('.group-box').forEach(el => { | ||||
|         el.classList.remove('highlight'); | ||||
|     }); | ||||
| 
 | ||||
|     const elements = document.elementsFromPoint(touch.clientX, touch.clientY); | ||||
|     const dropTarget = elements.find(el => el.classList.contains('group-box')); | ||||
| 
 | ||||
|     if (dropTarget) { | ||||
|         dropTarget.classList.add('highlight'); | ||||
|     } | ||||
| 
 | ||||
|     event.preventDefault(); | ||||
|     event.stopPropagation(); | ||||
| } | ||||
| 
 | ||||
| function handleTouchEnd(event: TouchEvent): void { | ||||
|     if (!touchState.value.isDragging) return; | ||||
| 
 | ||||
|     const { | ||||
|         currentGroupIndex, | ||||
|         currentStudentIndex, | ||||
|         clone, | ||||
|         element, | ||||
|         hasMoved | ||||
|     } = touchState.value; | ||||
| 
 | ||||
|     document.querySelectorAll('.group-box').forEach(el => { | ||||
|         el.classList.remove('highlight'); | ||||
|     }); | ||||
| 
 | ||||
|     if (clone?.parentNode) { | ||||
|         clone.parentNode.removeChild(clone); | ||||
|     } | ||||
| 
 | ||||
|     if (element) { | ||||
|         element.style.visibility = 'visible'; | ||||
|     } | ||||
| 
 | ||||
|     if (hasMoved && event.changedTouches.length > 0) { | ||||
|         const touch = event.changedTouches[0]; | ||||
|         const elements = document.elementsFromPoint(touch.clientX, touch.clientY); | ||||
|         const dropTarget = elements.find(el => el.classList.contains('group-box')); | ||||
| 
 | ||||
|         if (dropTarget) { | ||||
|             const groupBoxes = document.querySelectorAll('.group-box'); | ||||
|             const targetGroupIndex = Array.from(groupBoxes).indexOf(dropTarget); | ||||
| 
 | ||||
|             if (targetGroupIndex !== currentGroupIndex) { | ||||
|                 const sourceArray = currentGroupIndex === -1 | ||||
|                     ? unassignedStudents.value | ||||
|                     : currentGroups.value[currentGroupIndex]; | ||||
|                 const targetArray = targetGroupIndex === -1 | ||||
|                     ? unassignedStudents.value | ||||
|                     : currentGroups.value[targetGroupIndex]; | ||||
| 
 | ||||
|                 if (sourceArray && targetArray) { | ||||
|                     const [movedStudent] = sourceArray.splice(currentStudentIndex, 1); | ||||
|                     targetArray.push(movedStudent); | ||||
|                 } | ||||
|             // Initialize groups if they exist | ||||
|             if (existingGroups && existingGroups.length > 0) { | ||||
|                 currentGroups.value = existingGroups.map((group) => | ||||
|                     group.members.map((member) => ({ | ||||
|                         username: member.username, | ||||
|                         fullName: `${member.firstName} ${member.lastName}`, | ||||
|                     })), | ||||
|                 ); | ||||
|                 const assignedUsernames = new Set( | ||||
|                     existingGroups.flatMap((g) => g.members.map((m: StudentItem) => m.username)), | ||||
|                 ); | ||||
|                 unassignedStudents.value = allStudents.value.filter((s) => !assignedUsernames.has(s.username)); | ||||
|             } else { | ||||
|                 currentGroups.value = []; | ||||
|                 unassignedStudents.value = [...allStudents.value]; | ||||
|             } | ||||
| 
 | ||||
|             randomGroupsPreview.value = [...currentGroups.value]; | ||||
|         }, | ||||
|         { immediate: true }, | ||||
|     ); | ||||
| 
 | ||||
|     /** Random groups functions */ | ||||
|     function generateRandomGroups(): void { | ||||
|         if (groupSize.value < 1) return; | ||||
| 
 | ||||
|         // Shuffle students | ||||
|         const shuffled = [...allStudents.value].sort(() => Math.random() - 0.5); | ||||
| 
 | ||||
|         // Create new groups | ||||
|         const newGroups: StudentItem[][] = []; | ||||
|         const groupCount = Math.ceil(shuffled.length / groupSize.value); | ||||
| 
 | ||||
|         for (let i = 0; i < groupCount; i++) { | ||||
|             newGroups.push([]); | ||||
|         } | ||||
| 
 | ||||
|         // Distribute students | ||||
|         shuffled.forEach((student, index) => { | ||||
|             const groupIndex = index % groupCount; | ||||
|             newGroups[groupIndex].push(student); | ||||
|         }); | ||||
| 
 | ||||
|         randomGroupsPreview.value = newGroups; | ||||
|     } | ||||
| 
 | ||||
|     touchState.value = { | ||||
|     function saveRandomGroups(): void { | ||||
|         if (randomGroupsPreview.value.length === 0) { | ||||
|             alert(t("please-generate-groups-first")); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         emit( | ||||
|             "groupsUpdated", | ||||
|             randomGroupsPreview.value.map((g) => g.map((s) => s.username)), | ||||
|         ); | ||||
|         activeDialog.value = null; | ||||
|         emit("done"); | ||||
|         emit("close"); | ||||
|     } | ||||
| 
 | ||||
|     function addNewGroup() { | ||||
|         currentGroups.value.push([]); | ||||
|     } | ||||
| 
 | ||||
|     function removeGroup(index: number) { | ||||
|         // Move students back to unassigned | ||||
|         unassignedStudents.value.push(...currentGroups.value[index]); | ||||
|         currentGroups.value.splice(index, 1); | ||||
|     } | ||||
| 
 | ||||
|     /** Drag and drop functions */ | ||||
| 
 | ||||
|     // Touch state interface | ||||
|     interface TouchState { | ||||
|         isDragging: boolean; | ||||
|         startX: number; | ||||
|         startY: number; | ||||
|         currentGroupIndex: number; | ||||
|         currentStudentIndex: number; | ||||
|         element: HTMLElement | null; | ||||
|         clone: HTMLElement | null; | ||||
|         originalRect: DOMRect | null; | ||||
|         hasMoved: boolean; | ||||
|     } | ||||
| 
 | ||||
|     const touchState = ref<TouchState>({ | ||||
|         isDragging: false, | ||||
|         startX: 0, | ||||
|         startY: 0, | ||||
|  | @ -284,80 +138,217 @@ function handleTouchEnd(event: TouchEvent): void { | |||
|         element: null, | ||||
|         clone: null, | ||||
|         originalRect: null, | ||||
|         hasMoved: false | ||||
|     }; | ||||
|         hasMoved: false, | ||||
|     }); | ||||
| 
 | ||||
|     event.preventDefault(); | ||||
|     event.stopPropagation(); | ||||
| } | ||||
|     function handleTouchStart(event: TouchEvent, groupIndex: number, studentIndex: number): void { | ||||
|         if (event.touches.length > 1) return; | ||||
| 
 | ||||
| function handleDragStart(event: DragEvent, groupIndex: number, studentIndex: number): void { | ||||
|     draggedItem.value = {groupIndex, studentIndex}; | ||||
|     if (event.dataTransfer) { | ||||
|         event.dataTransfer.effectAllowed = 'move'; | ||||
|         event.dataTransfer.setData('text/plain', ''); | ||||
|     } | ||||
| } | ||||
|         const touch = event.touches[0]; | ||||
|         const target = event.target as HTMLElement; | ||||
|         // Target the chip directly instead of the draggable container | ||||
|         const chip = target.closest(".v-chip") as HTMLElement; | ||||
| 
 | ||||
| function handleDragOver(e: DragEvent, _: number): void { | ||||
|     e.preventDefault(); | ||||
|     if (e.dataTransfer) { | ||||
|         e.dataTransfer.dropEffect = "move"; | ||||
|     } | ||||
| } | ||||
|         if (!chip) return; | ||||
| 
 | ||||
| function handleDrop(e: DragEvent, targetGroupIndex: number, targetStudentIndex?: number): void { | ||||
|     e.preventDefault(); | ||||
|     if (!draggedItem.value) return; | ||||
|         // Get the chip's position relative to the viewport | ||||
|         const rect = chip.getBoundingClientRect(); | ||||
| 
 | ||||
|     const {groupIndex: sourceGroupIndex, studentIndex: sourceStudentIndex} = draggedItem.value; | ||||
|     const sourceArray = sourceGroupIndex === -1 | ||||
|         ? unassignedStudents.value | ||||
|         : currentGroups.value[sourceGroupIndex]; | ||||
|     const targetArray = targetGroupIndex === -1 | ||||
|         ? unassignedStudents.value | ||||
|         : currentGroups.value[targetGroupIndex]; | ||||
|         touchState.value = { | ||||
|             isDragging: true, | ||||
|             startX: touch.clientX, | ||||
|             startY: touch.clientY, | ||||
|             currentGroupIndex: groupIndex, | ||||
|             currentStudentIndex: studentIndex, | ||||
|             element: chip, | ||||
|             clone: null, | ||||
|             originalRect: rect, | ||||
|             hasMoved: false, | ||||
|         }; | ||||
| 
 | ||||
|     const [movedStudent] = sourceArray.splice(sourceStudentIndex, 1); | ||||
|     if (targetStudentIndex !== undefined) { | ||||
|         targetArray.splice(targetStudentIndex, 0, movedStudent); | ||||
|     } else { | ||||
|         targetArray.push(movedStudent); | ||||
|         // Clone only the chip | ||||
|         const clone = chip.cloneNode(true) as HTMLElement; | ||||
|         clone.classList.add("drag-clone"); | ||||
|         clone.style.position = "fixed"; | ||||
|         clone.style.zIndex = "10000"; | ||||
|         clone.style.opacity = "0.9"; | ||||
|         clone.style.pointerEvents = "none"; | ||||
|         clone.style.width = `${rect.width}px`; | ||||
|         clone.style.height = `${rect.height}px`; | ||||
|         clone.style.left = `${rect.left}px`; | ||||
|         clone.style.top = `${rect.top}px`; | ||||
|         clone.style.transform = "scale(1.05)"; | ||||
|         clone.style.boxShadow = "0 4px 8px rgba(0,0,0,0.3)"; | ||||
|         clone.style.transition = "transform 0.1s"; | ||||
| 
 | ||||
|         // Ensure the clone has the same chip styling | ||||
|         clone.style.backgroundColor = getComputedStyle(chip).backgroundColor; | ||||
|         clone.style.color = getComputedStyle(chip).color; | ||||
|         clone.style.borderRadius = getComputedStyle(chip).borderRadius; | ||||
|         clone.style.padding = getComputedStyle(chip).padding; | ||||
|         clone.style.margin = "0"; // Remove any margin | ||||
| 
 | ||||
|         document.body.appendChild(clone); | ||||
|         touchState.value.clone = clone; | ||||
|         chip.style.visibility = "hidden"; | ||||
| 
 | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
|     } | ||||
| 
 | ||||
|     draggedItem.value = null; | ||||
| } | ||||
|     function handleTouchMove(event: TouchEvent): void { | ||||
|         if (!touchState.value.isDragging || !touchState.value.clone || event.touches.length > 1) return; | ||||
| 
 | ||||
| function saveDragDrop(): void { | ||||
|     if (unassignedStudents.value.length > 0) { | ||||
|         alert(t("please-assign-all-students")); | ||||
|         return; | ||||
|         const touch = event.touches[0]; | ||||
|         const clone = touchState.value.clone; | ||||
| 
 | ||||
|         const dx = Math.abs(touch.clientX - touchState.value.startX); | ||||
|         const dy = Math.abs(touch.clientY - touchState.value.startY); | ||||
| 
 | ||||
|         if (dx > 5 || dy > 5) { | ||||
|             touchState.value.hasMoved = true; | ||||
|         } | ||||
| 
 | ||||
|         clone.style.left = `${touch.clientX - clone.offsetWidth / 2}px`; | ||||
|         clone.style.top = `${touch.clientY - clone.offsetHeight / 2}px`; | ||||
| 
 | ||||
|         document.querySelectorAll(".group-box").forEach((el) => { | ||||
|             el.classList.remove("highlight"); | ||||
|         }); | ||||
| 
 | ||||
|         const elements = document.elementsFromPoint(touch.clientX, touch.clientY); | ||||
|         const dropTarget = elements.find((el) => el.classList.contains("group-box")); | ||||
| 
 | ||||
|         if (dropTarget) { | ||||
|             dropTarget.classList.add("highlight"); | ||||
|         } | ||||
| 
 | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
|     } | ||||
| 
 | ||||
|     emit( | ||||
|         "groupsUpdated", | ||||
|         currentGroups.value.map((g) => g.map((s) => s.username)), | ||||
|     ); | ||||
|     activeDialog.value = null; | ||||
|     emit("done"); | ||||
|     emit("close"); | ||||
| } | ||||
|     function handleTouchEnd(event: TouchEvent): void { | ||||
|         if (!touchState.value.isDragging) return; | ||||
| 
 | ||||
| const showGroupsPreview = computed(() => { | ||||
|     return currentGroups.value.length > 0 || unassignedStudents.value.length > 0; | ||||
| }); | ||||
|         const { currentGroupIndex, currentStudentIndex, clone, element, hasMoved } = touchState.value; | ||||
| 
 | ||||
| function removeStudent(groupIndex: number, student: StudentItem): void { | ||||
|     const group = currentGroups.value[groupIndex]; | ||||
|     currentGroups.value[groupIndex] = group.filter((s) => s.username !== student.username); | ||||
|     unassignedStudents.value.push(student); | ||||
| } | ||||
|         document.querySelectorAll(".group-box").forEach((el) => { | ||||
|             el.classList.remove("highlight"); | ||||
|         }); | ||||
| 
 | ||||
|         if (clone?.parentNode) { | ||||
|             clone.parentNode.removeChild(clone); | ||||
|         } | ||||
| 
 | ||||
|         if (element) { | ||||
|             element.style.visibility = "visible"; | ||||
|         } | ||||
| 
 | ||||
|         if (hasMoved && event.changedTouches.length > 0) { | ||||
|             const touch = event.changedTouches[0]; | ||||
|             const elements = document.elementsFromPoint(touch.clientX, touch.clientY); | ||||
|             const dropTarget = elements.find((el) => el.classList.contains("group-box")); | ||||
| 
 | ||||
|             if (dropTarget) { | ||||
|                 const groupBoxes = document.querySelectorAll(".group-box"); | ||||
|                 const targetGroupIndex = Array.from(groupBoxes).indexOf(dropTarget); | ||||
| 
 | ||||
|                 if (targetGroupIndex !== currentGroupIndex) { | ||||
|                     const sourceArray = | ||||
|                         currentGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[currentGroupIndex]; | ||||
|                     const targetArray = | ||||
|                         targetGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[targetGroupIndex]; | ||||
| 
 | ||||
|                     if (sourceArray && targetArray) { | ||||
|                         const [movedStudent] = sourceArray.splice(currentStudentIndex, 1); | ||||
|                         targetArray.push(movedStudent); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         touchState.value = { | ||||
|             isDragging: false, | ||||
|             startX: 0, | ||||
|             startY: 0, | ||||
|             currentGroupIndex: -1, | ||||
|             currentStudentIndex: -1, | ||||
|             element: null, | ||||
|             clone: null, | ||||
|             originalRect: null, | ||||
|             hasMoved: false, | ||||
|         }; | ||||
| 
 | ||||
|         event.preventDefault(); | ||||
|         event.stopPropagation(); | ||||
|     } | ||||
| 
 | ||||
|     function handleDragStart(event: DragEvent, groupIndex: number, studentIndex: number): void { | ||||
|         draggedItem.value = { groupIndex, studentIndex }; | ||||
|         if (event.dataTransfer) { | ||||
|             event.dataTransfer.effectAllowed = "move"; | ||||
|             event.dataTransfer.setData("text/plain", ""); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     function handleDragOver(e: DragEvent, _: number): void { | ||||
|         e.preventDefault(); | ||||
|         if (e.dataTransfer) { | ||||
|             e.dataTransfer.dropEffect = "move"; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     function handleDrop(e: DragEvent, targetGroupIndex: number, targetStudentIndex?: number): void { | ||||
|         e.preventDefault(); | ||||
|         if (!draggedItem.value) return; | ||||
| 
 | ||||
|         const { groupIndex: sourceGroupIndex, studentIndex: sourceStudentIndex } = draggedItem.value; | ||||
|         const sourceArray = sourceGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[sourceGroupIndex]; | ||||
|         const targetArray = targetGroupIndex === -1 ? unassignedStudents.value : currentGroups.value[targetGroupIndex]; | ||||
| 
 | ||||
|         const [movedStudent] = sourceArray.splice(sourceStudentIndex, 1); | ||||
|         if (targetStudentIndex !== undefined) { | ||||
|             targetArray.splice(targetStudentIndex, 0, movedStudent); | ||||
|         } else { | ||||
|             targetArray.push(movedStudent); | ||||
|         } | ||||
| 
 | ||||
|         draggedItem.value = null; | ||||
|     } | ||||
| 
 | ||||
|     function saveDragDrop(): void { | ||||
|         if (unassignedStudents.value.length > 0) { | ||||
|             alert(t("please-assign-all-students")); | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         emit( | ||||
|             "groupsUpdated", | ||||
|             currentGroups.value.map((g) => g.map((s) => s.username)), | ||||
|         ); | ||||
|         activeDialog.value = null; | ||||
|         emit("done"); | ||||
|         emit("close"); | ||||
|     } | ||||
| 
 | ||||
|     const showGroupsPreview = computed(() => { | ||||
|         return currentGroups.value.length > 0 || unassignedStudents.value.length > 0; | ||||
|     }); | ||||
| 
 | ||||
|     function removeStudent(groupIndex: number, student: StudentItem): void { | ||||
|         const group = currentGroups.value[groupIndex]; | ||||
|         currentGroups.value[groupIndex] = group.filter((s) => s.username !== student.username); | ||||
|         unassignedStudents.value.push(student); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-card class="pa-4 minimal-card"> | ||||
|         <!-- Current groups and unassigned students Preview --> | ||||
|         <div v-if="showGroupsPreview" class="mb-6"> | ||||
|         <div | ||||
|             v-if="showGroupsPreview" | ||||
|             class="mb-6" | ||||
|         > | ||||
|             <h3 class="mb-2">{{ t("current-groups") }}</h3> | ||||
|             <div> | ||||
|                 <div class="d-flex flex-wrap"> | ||||
|  | @ -366,11 +357,23 @@ function removeStudent(groupIndex: number, student: StudentItem): void { | |||
|             </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <v-row justify="center" class="mb-4"> | ||||
|             <v-btn color="primary" @click="activeDialog = 'random'" prepend-icon="mdi-shuffle"> | ||||
|         <v-row | ||||
|             justify="center" | ||||
|             class="mb-4" | ||||
|         > | ||||
|             <v-btn | ||||
|                 color="primary" | ||||
|                 @click="activeDialog = 'random'" | ||||
|                 prepend-icon="mdi-shuffle" | ||||
|             > | ||||
|                 {{ t("random-grouping") }} | ||||
|             </v-btn> | ||||
|             <v-btn color="secondary" class="ml-4" @click="activeDialog = 'dragdrop'" prepend-icon="mdi-drag"> | ||||
|             <v-btn | ||||
|                 color="secondary" | ||||
|                 class="ml-4" | ||||
|                 @click="activeDialog = 'dragdrop'" | ||||
|                 prepend-icon="mdi-drag" | ||||
|             > | ||||
|                 {{ t("drag-and-drop") }} | ||||
|             </v-btn> | ||||
|         </v-row> | ||||
|  | @ -436,8 +439,12 @@ function removeStudent(groupIndex: number, student: StudentItem): void { | |||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-actions class="dialog-actions"> | ||||
|                     <v-spacer/> | ||||
|                     <v-btn text @click="activeDialog = null">{{ t("cancel") }}</v-btn> | ||||
|                     <v-spacer /> | ||||
|                     <v-btn | ||||
|                         text | ||||
|                         @click="activeDialog = null" | ||||
|                         >{{ t("cancel") }}</v-btn | ||||
|                     > | ||||
|                     <v-btn | ||||
|                         color="success" | ||||
|                         @click="saveRandomGroups" | ||||
|  | @ -456,16 +463,27 @@ function removeStudent(groupIndex: number, student: StudentItem): void { | |||
|             max-width="900" | ||||
|         > | ||||
|             <v-card class="custom-dialog"> | ||||
|                 <v-card-title class=" dialog-title d-flex justify-space-between align-center"> | ||||
|                 <v-card-title class="dialog-title d-flex justify-space-between align-center"> | ||||
|                     <div>{{ t("drag-and-drop") }}</div> | ||||
|                     <v-btn color="primary" small @click="addNewGroup">+</v-btn> | ||||
|                     <v-btn | ||||
|                         color="primary" | ||||
|                         small | ||||
|                         @click="addNewGroup" | ||||
|                         >+</v-btn | ||||
|                     > | ||||
|                 </v-card-title> | ||||
| 
 | ||||
|                 <v-card-text> | ||||
|                     <v-row> | ||||
|                         <!-- Groups Column --> | ||||
|                         <v-col cols="12" md="8"> | ||||
|                             <div v-if="currentGroups.length === 0" class="text-center py-4"> | ||||
|                         <v-col | ||||
|                             cols="12" | ||||
|                             md="8" | ||||
|                         > | ||||
|                             <div | ||||
|                                 v-if="currentGroups.length === 0" | ||||
|                                 class="text-center py-4" | ||||
|                             > | ||||
|                                 <v-alert type="info">{{ t("no-groups-yet") }}</v-alert> | ||||
|                             </div> | ||||
| 
 | ||||
|  | @ -479,7 +497,12 @@ function removeStudent(groupIndex: number, student: StudentItem): void { | |||
|                                 > | ||||
|                                     <div class="d-flex justify-space-between align-center mb-2"> | ||||
|                                         <strong>{{ t("group") }} {{ groupIndex + 1 }}</strong> | ||||
|                                         <v-btn icon small color="error" @click="removeGroup(groupIndex)"> | ||||
|                                         <v-btn | ||||
|                                             icon | ||||
|                                             small | ||||
|                                             color="error" | ||||
|                                             @click="removeGroup(groupIndex)" | ||||
|                                         > | ||||
|                                             <v-icon>mdi-delete</v-icon> | ||||
|                                         </v-btn> | ||||
|                                     </div> | ||||
|  | @ -497,7 +520,10 @@ function removeStudent(groupIndex: number, student: StudentItem): void { | |||
|                                             @dragover.prevent="handleDragOver($event, groupIndex)" | ||||
|                                             @drop="handleDrop($event, groupIndex, studentIndex)" | ||||
|                                         > | ||||
|                                             <v-chip close @click:close="removeStudent(groupIndex, student)"> | ||||
|                                             <v-chip | ||||
|                                                 close | ||||
|                                                 @click:close="removeStudent(groupIndex, student)" | ||||
|                                             > | ||||
|                                                 {{ student.fullName }} | ||||
|                                             </v-chip> | ||||
|                                         </div> | ||||
|  | @ -539,8 +565,12 @@ function removeStudent(groupIndex: number, student: StudentItem): void { | |||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-actions> | ||||
|                     <v-spacer/> | ||||
|                     <v-btn text @click="activeDialog = null">{{ t("cancel") }}</v-btn> | ||||
|                     <v-spacer /> | ||||
|                     <v-btn | ||||
|                         text | ||||
|                         @click="activeDialog = null" | ||||
|                         >{{ t("cancel") }}</v-btn | ||||
|                     > | ||||
|                     <v-btn | ||||
|                         color="primary" | ||||
|                         @click="saveDragDrop" | ||||
|  | @ -555,101 +585,101 @@ function removeStudent(groupIndex: number, student: StudentItem): void { | |||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| .group-box { | ||||
|     min-height: 100px; | ||||
|     max-height: 200px; | ||||
|     overflow-y: auto; | ||||
|     background-color: #fafafa; | ||||
|     border-radius: 4px; | ||||
|     transition: all 0.2s; | ||||
| } | ||||
|     .group-box { | ||||
|         min-height: 100px; | ||||
|         max-height: 200px; | ||||
|         overflow-y: auto; | ||||
|         background-color: #fafafa; | ||||
|         border-radius: 4px; | ||||
|         transition: all 0.2s; | ||||
|     } | ||||
| 
 | ||||
| .group-box.highlight { | ||||
|     background-color: #e3f2fd; | ||||
|     border: 2px dashed #2196f3; | ||||
| } | ||||
|     .group-box.highlight { | ||||
|         background-color: #e3f2fd; | ||||
|         border: 2px dashed #2196f3; | ||||
|     } | ||||
| 
 | ||||
| .v-expansion-panel-text { | ||||
|     max-height: 200px; | ||||
|     overflow-y: auto; | ||||
| } | ||||
|     .v-expansion-panel-text { | ||||
|         max-height: 200px; | ||||
|         overflow-y: auto; | ||||
|     } | ||||
| 
 | ||||
| .drag-clone { | ||||
|     z-index: 10000; | ||||
|     transform: scale(1.05); | ||||
|     box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); | ||||
|     transition: transform 0.1s; | ||||
|     will-change: transform; | ||||
|     pointer-events: none; | ||||
|     display: inline-flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     border-radius: 16px; | ||||
|     background-color: inherit; | ||||
| } | ||||
|     .drag-clone { | ||||
|         z-index: 10000; | ||||
|         transform: scale(1.05); | ||||
|         box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); | ||||
|         transition: transform 0.1s; | ||||
|         will-change: transform; | ||||
|         pointer-events: none; | ||||
|         display: inline-flex; | ||||
|         align-items: center; | ||||
|         justify-content: center; | ||||
|         border-radius: 16px; | ||||
|         background-color: inherit; | ||||
|     } | ||||
| 
 | ||||
| .draggable-item { | ||||
|     display: inline-block; | ||||
| } | ||||
|     .draggable-item { | ||||
|         display: inline-block; | ||||
|     } | ||||
| 
 | ||||
| .draggable-item .v-chip[style*="hidden"] { | ||||
|     visibility: hidden; | ||||
|     display: inline-block; | ||||
| } | ||||
|     .draggable-item .v-chip[style*="hidden"] { | ||||
|         visibility: hidden; | ||||
|         display: inline-block; | ||||
|     } | ||||
| 
 | ||||
| .custom-dialog { | ||||
|     border-radius: 16px; | ||||
|     padding: 24px; | ||||
|     box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); | ||||
| } | ||||
|     .custom-dialog { | ||||
|         border-radius: 16px; | ||||
|         padding: 24px; | ||||
|         box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); | ||||
|     } | ||||
| 
 | ||||
| .dialog-title { | ||||
|     color: #00796b; /* teal-like green */ | ||||
|     font-weight: bold; | ||||
|     font-size: 1.25rem; | ||||
|     margin-bottom: 16px; | ||||
| } | ||||
|     .dialog-title { | ||||
|         color: #00796b; /* teal-like green */ | ||||
|         font-weight: bold; | ||||
|         font-size: 1.25rem; | ||||
|         margin-bottom: 16px; | ||||
|     } | ||||
| 
 | ||||
| .dialog-actions { | ||||
|     display: flex; | ||||
|     justify-content: flex-end; | ||||
|     gap: 12px; | ||||
|     margin-top: 24px; | ||||
| } | ||||
|     .dialog-actions { | ||||
|         display: flex; | ||||
|         justify-content: flex-end; | ||||
|         gap: 12px; | ||||
|         margin-top: 24px; | ||||
|     } | ||||
| 
 | ||||
| .v-btn.custom-green { | ||||
|     background-color: #43a047; | ||||
|     color: white; | ||||
| } | ||||
|     .v-btn.custom-green { | ||||
|         background-color: #43a047; | ||||
|         color: white; | ||||
|     } | ||||
| 
 | ||||
| .v-btn.custom-green:hover { | ||||
|     background-color: #388e3c | ||||
| } | ||||
|     .v-btn.custom-green:hover { | ||||
|         background-color: #388e3c; | ||||
|     } | ||||
| 
 | ||||
| .v-btn.custom-blue { | ||||
|     background-color: #1e88e5; | ||||
|     color: white; | ||||
| } | ||||
|     .v-btn.custom-blue { | ||||
|         background-color: #1e88e5; | ||||
|         color: white; | ||||
|     } | ||||
| 
 | ||||
| .v-btn.custom-blue:hover { | ||||
|     background-color: #1565c0 ; | ||||
| } | ||||
|     .v-btn.custom-blue:hover { | ||||
|         background-color: #1565c0; | ||||
|     } | ||||
| 
 | ||||
| .v-btn.cancel-button { | ||||
|     background-color: #e0f2f1; | ||||
|     color: #00695c; | ||||
| } | ||||
|     .v-btn.cancel-button { | ||||
|         background-color: #e0f2f1; | ||||
|         color: #00695c; | ||||
|     } | ||||
| 
 | ||||
| .minimal-card { | ||||
|     box-shadow: none;           /* remove card shadow */ | ||||
|     border: none;               /* remove border */ | ||||
|     background-color: transparent;  /* make background transparent */ | ||||
|     padding: 0;                 /* reduce padding */ | ||||
|     margin-bottom: 1rem;        /* keep some spacing below */ | ||||
| } | ||||
|     .minimal-card { | ||||
|         box-shadow: none; /* remove card shadow */ | ||||
|         border: none; /* remove border */ | ||||
|         background-color: transparent; /* make background transparent */ | ||||
|         padding: 0; /* reduce padding */ | ||||
|         margin-bottom: 1rem; /* keep some spacing below */ | ||||
|     } | ||||
| 
 | ||||
| /* Optionally, keep some padding only around buttons */ | ||||
| .minimal-card > .v-row { | ||||
|     padding: 1rem 0;            /* give vertical padding around buttons */ | ||||
| } | ||||
|     /* Optionally, keep some padding only around buttons */ | ||||
|     .minimal-card > .v-row { | ||||
|         padding: 1rem 0; /* give vertical padding around buttons */ | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,11 +1,9 @@ | |||
| 
 | ||||
| /** | ||||
|  * Validation rule for the assignment title. | ||||
|  * | ||||
|  * Ensures that the title is not empty. | ||||
|  */ | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  * Validation rule for the classes selection. | ||||
|  * | ||||
|  | @ -17,4 +15,3 @@ | |||
|  * | ||||
|  * Ensures that a valid deadline is selected and is in the future. | ||||
|  */ | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,119 +1,114 @@ | |||
| <script setup lang="ts"> | ||||
| import { useI18n } from "vue-i18n"; | ||||
| import { computed, onMounted, ref, watch } from "vue"; | ||||
| import auth from "@/services/auth/auth-service.ts"; | ||||
| import { useTeacherClassesQuery } from "@/queries/teachers.ts"; | ||||
| import { useRouter, useRoute } from "vue-router"; | ||||
| import { useGetAllLearningPaths } from "@/queries/learning-paths.ts"; | ||||
| import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
| import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||
| import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
| import { useCreateAssignmentMutation } from "@/queries/assignments.ts"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { computed, onMounted, ref, watch } from "vue"; | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
|     import { useTeacherClassesQuery } from "@/queries/teachers.ts"; | ||||
|     import { useRouter, useRoute } from "vue-router"; | ||||
|     import { useGetAllLearningPaths } from "@/queries/learning-paths.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||
|     import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||
|     import { useCreateAssignmentMutation } from "@/queries/assignments.ts"; | ||||
| 
 | ||||
| const route = useRoute(); | ||||
| const router = useRouter(); | ||||
| const { t, locale } = useI18n(); | ||||
| const role = ref(auth.authState.activeRole); | ||||
| const username = ref<string>(""); | ||||
|     const route = useRoute(); | ||||
|     const router = useRouter(); | ||||
|     const { t, locale } = useI18n(); | ||||
|     const role = ref(auth.authState.activeRole); | ||||
|     const username = ref<string>(""); | ||||
| 
 | ||||
| onMounted(async () => { | ||||
|     if (role.value === "student") { | ||||
|         await router.push("/user"); | ||||
|     } | ||||
|     const user = await auth.loadUser(); | ||||
|     username.value = user?.profile?.preferred_username ?? ""; | ||||
| }); | ||||
| 
 | ||||
| const language = computed(() => locale.value); | ||||
| const form = ref(); | ||||
| 
 | ||||
| const learningPathsQueryResults = useGetAllLearningPaths(language); | ||||
| const classesQueryResults = useTeacherClassesQuery(username, true); | ||||
| 
 | ||||
| const selectedClass = ref(undefined); | ||||
| const assignmentTitle = ref(""); | ||||
| 
 | ||||
| const selectedLearningPath = ref<LearningPath | undefined>(undefined); | ||||
| const lpIsSelected = ref(false); | ||||
| 
 | ||||
| watch(learningPathsQueryResults.data, (data) => { | ||||
|     const hruidFromRoute = route.query.hruid?.toString(); | ||||
|     if (!hruidFromRoute || !data) return; | ||||
| 
 | ||||
|     // Verify if the hruid given in the url is valid before accepting it | ||||
|     const matchedLP = data.find(lp => lp.hruid === hruidFromRoute); | ||||
|     if (matchedLP) { | ||||
|         selectedLearningPath.value = matchedLP; | ||||
|         lpIsSelected.value = true; | ||||
|     } | ||||
| }); | ||||
| 
 | ||||
| const { mutate, data, isSuccess } = useCreateAssignmentMutation(); | ||||
| 
 | ||||
| watch([isSuccess, data], async ([success, newData]) => { | ||||
|     if (success && newData?.assignment) { | ||||
|         await router.push(`/assignment/${newData.assignment.within}/${newData.assignment.id}`); | ||||
|     } | ||||
| }); | ||||
| 
 | ||||
| async function submitFormHandler(): Promise<void> { | ||||
|     const { valid } = await form.value.validate(); | ||||
|     if (!valid) return; | ||||
| 
 | ||||
|     const lp = lpIsSelected.value | ||||
|         ? route.query.hruid?.toString() | ||||
|         : selectedLearningPath.value?.hruid; | ||||
|     if (!lp) { | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     const assignmentDTO: AssignmentDTO = { | ||||
|         id: 0, | ||||
|         within: selectedClass.value?.id || "", | ||||
|         title: assignmentTitle.value, | ||||
|         description: "", | ||||
|         learningPath: lp, | ||||
|         language: language.value, | ||||
|         deadline: null, | ||||
|         groups: [], | ||||
|     }; | ||||
| 
 | ||||
|     mutate({ cid: assignmentDTO.within, data: assignmentDTO }); | ||||
| } | ||||
| 
 | ||||
| const learningPathRules = [ | ||||
|     (value: LearningPath): string | boolean => { | ||||
| 
 | ||||
|         if(lpIsSelected.value) return true; | ||||
| 
 | ||||
|         if (!value) return t("lp-required"); | ||||
| 
 | ||||
|         const allLPs = learningPathsQueryResults.data.value ?? []; | ||||
|         const valid = allLPs.some(lp => lp.hruid === value?.hruid); | ||||
|         return valid || t("lp-invalid"); | ||||
|     } | ||||
| ]; | ||||
| 
 | ||||
| const assignmentTitleRules = [ | ||||
|     (value: string): string | boolean => { | ||||
|         if (value?.length >= 1) { | ||||
|             return true; | ||||
|         } // Title must not be empty | ||||
|         return t("title-required"); | ||||
|     }, | ||||
| ]; | ||||
| 
 | ||||
| const classRules = [ | ||||
|     (value: string): string | boolean => { | ||||
|         if (value) { | ||||
|             return true; | ||||
|     onMounted(async () => { | ||||
|         if (role.value === "student") { | ||||
|             await router.push("/user"); | ||||
|         } | ||||
|         return t("class-required"); | ||||
|     }, | ||||
| ]; | ||||
|         const user = await auth.loadUser(); | ||||
|         username.value = user?.profile?.preferred_username ?? ""; | ||||
|     }); | ||||
| 
 | ||||
|     const language = computed(() => locale.value); | ||||
|     const form = ref(); | ||||
| 
 | ||||
|     const learningPathsQueryResults = useGetAllLearningPaths(language); | ||||
|     const classesQueryResults = useTeacherClassesQuery(username, true); | ||||
| 
 | ||||
|     const selectedClass = ref(undefined); | ||||
|     const assignmentTitle = ref(""); | ||||
| 
 | ||||
|     const selectedLearningPath = ref<LearningPath | undefined>(undefined); | ||||
|     const lpIsSelected = ref(false); | ||||
| 
 | ||||
|     watch(learningPathsQueryResults.data, (data) => { | ||||
|         const hruidFromRoute = route.query.hruid?.toString(); | ||||
|         if (!hruidFromRoute || !data) return; | ||||
| 
 | ||||
|         // Verify if the hruid given in the url is valid before accepting it | ||||
|         const matchedLP = data.find((lp) => lp.hruid === hruidFromRoute); | ||||
|         if (matchedLP) { | ||||
|             selectedLearningPath.value = matchedLP; | ||||
|             lpIsSelected.value = true; | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     const { mutate, data, isSuccess } = useCreateAssignmentMutation(); | ||||
| 
 | ||||
|     watch([isSuccess, data], async ([success, newData]) => { | ||||
|         if (success && newData?.assignment) { | ||||
|             await router.push(`/assignment/${newData.assignment.within}/${newData.assignment.id}`); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     async function submitFormHandler(): Promise<void> { | ||||
|         const { valid } = await form.value.validate(); | ||||
|         if (!valid) return; | ||||
| 
 | ||||
|         const lp = lpIsSelected.value ? route.query.hruid?.toString() : selectedLearningPath.value?.hruid; | ||||
|         if (!lp) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const assignmentDTO: AssignmentDTO = { | ||||
|             id: 0, | ||||
|             within: selectedClass.value?.id || "", | ||||
|             title: assignmentTitle.value, | ||||
|             description: "", | ||||
|             learningPath: lp, | ||||
|             language: language.value, | ||||
|             deadline: null, | ||||
|             groups: [], | ||||
|         }; | ||||
| 
 | ||||
|         mutate({ cid: assignmentDTO.within, data: assignmentDTO }); | ||||
|     } | ||||
| 
 | ||||
|     const learningPathRules = [ | ||||
|         (value: LearningPath): string | boolean => { | ||||
|             if (lpIsSelected.value) return true; | ||||
| 
 | ||||
|             if (!value) return t("lp-required"); | ||||
| 
 | ||||
|             const allLPs = learningPathsQueryResults.data.value ?? []; | ||||
|             const valid = allLPs.some((lp) => lp.hruid === value?.hruid); | ||||
|             return valid || t("lp-invalid"); | ||||
|         }, | ||||
|     ]; | ||||
| 
 | ||||
|     const assignmentTitleRules = [ | ||||
|         (value: string): string | boolean => { | ||||
|             if (value?.length >= 1) { | ||||
|                 return true; | ||||
|             } // Title must not be empty | ||||
|             return t("title-required"); | ||||
|         }, | ||||
|     ]; | ||||
| 
 | ||||
|     const classRules = [ | ||||
|         (value: string): string | boolean => { | ||||
|             if (value) { | ||||
|                 return true; | ||||
|             } | ||||
|             return t("class-required"); | ||||
|         }, | ||||
|     ]; | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -156,7 +151,6 @@ const classRules = [ | |||
|                             :disabled="lpIsSelected" | ||||
|                             return-object | ||||
|                         /> | ||||
| 
 | ||||
|                     </using-query-result> | ||||
| 
 | ||||
|                     <!-- Klas keuze --> | ||||
|  | @ -212,59 +206,59 @@ const classRules = [ | |||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| .main-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     justify-content: start; | ||||
|     padding-top: 32px; | ||||
|     text-align: center; | ||||
| } | ||||
|     .main-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: center; | ||||
|         justify-content: start; | ||||
|         padding-top: 32px; | ||||
|         text-align: center; | ||||
|     } | ||||
| 
 | ||||
| .form-card { | ||||
|     width: 100%; | ||||
|     max-width: 720px; | ||||
|     border-radius: 16px; | ||||
| } | ||||
| 
 | ||||
| .form-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 24px; | ||||
|     width: 100%; | ||||
| } | ||||
| 
 | ||||
| .step-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 24px; | ||||
| } | ||||
| 
 | ||||
| @media (max-width: 1000px) { | ||||
|     .form-card { | ||||
|         width: 85%; | ||||
|         padding: 1%; | ||||
|         width: 100%; | ||||
|         max-width: 720px; | ||||
|         border-radius: 16px; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @media (max-width: 600px) { | ||||
|     h1 { | ||||
|         font-size: 32px; | ||||
|         text-align: center; | ||||
|         margin-left: 0; | ||||
|     .form-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 24px; | ||||
|         width: 100%; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @media (max-width: 400px) { | ||||
|     h1 { | ||||
|         font-size: 24px; | ||||
|         text-align: center; | ||||
|         margin-left: 0; | ||||
|     .step-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 24px; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| .v-card { | ||||
|     border: 2px solid #0e6942; | ||||
|     border-radius: 12px; | ||||
| } | ||||
|     @media (max-width: 1000px) { | ||||
|         .form-card { | ||||
|             width: 85%; | ||||
|             padding: 1%; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: 600px) { | ||||
|         h1 { | ||||
|             font-size: 32px; | ||||
|             text-align: center; | ||||
|             margin-left: 0; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: 400px) { | ||||
|         h1 { | ||||
|             font-size: 24px; | ||||
|             text-align: center; | ||||
|             margin-left: 0; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     .v-card { | ||||
|         border: 2px solid #0e6942; | ||||
|         border-radius: 12px; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -6,11 +6,11 @@ | |||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import type { AssignmentResponse } from "@/controllers/assignments.ts"; | ||||
|     import { asyncComputed } from "@vueuse/core"; | ||||
|     import {useStudentGroupsQuery, useStudentsByUsernamesQuery} from "@/queries/students.ts"; | ||||
|     import { useStudentGroupsQuery, useStudentsByUsernamesQuery } from "@/queries/students.ts"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import type { Language } from "@/data-objects/language.ts"; | ||||
|     import { calculateProgress } from "@/utils/assignment-utils.ts"; | ||||
|     import type {LearningPath} from "@/data-objects/learning-paths/learning-path.ts"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||
| 
 | ||||
|     const props = defineProps<{ | ||||
|         classId: string; | ||||
|  |  | |||
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana