feat: drag en drop werkt op mobiel
This commit is contained in:
		
							parent
							
								
									45563b68ea
								
							
						
					
					
						commit
						862e72ef4a
					
				
					 4 changed files with 489 additions and 277 deletions
				
			
		|  | @ -21,6 +21,7 @@ | ||||||
|         "@tanstack/vue-query": "^5.69.0", |         "@tanstack/vue-query": "^5.69.0", | ||||||
|         "@vueuse/core": "^13.1.0", |         "@vueuse/core": "^13.1.0", | ||||||
|         "axios": "^1.8.2", |         "axios": "^1.8.2", | ||||||
|  |         "interactjs": "^1.10.27", | ||||||
|         "oidc-client-ts": "^3.1.0", |         "oidc-client-ts": "^3.1.0", | ||||||
|         "rollup": "^4.40.0", |         "rollup": "^4.40.0", | ||||||
|         "uuid": "^11.1.0", |         "uuid": "^11.1.0", | ||||||
|  |  | ||||||
|  | @ -1,243 +1,388 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import { computed, ref, watch } from "vue"; | import {computed, ref, watch} from "vue"; | ||||||
|     import { useI18n } from "vue-i18n"; | import {useI18n} from "vue-i18n"; | ||||||
|     import { useClassStudentsQuery } from "@/queries/classes"; | import {useClassStudentsQuery} from "@/queries/classes"; | ||||||
| 
 | 
 | ||||||
|     const props = defineProps<{ | const props = defineProps<{ | ||||||
|         classId: string | undefined; |     classId: string | undefined; | ||||||
|         groups: object[]; |     groups: object[]; | ||||||
|     }>(); | }>(); | ||||||
|     const emit = defineEmits(["close", "groupsUpdated", "done"]); | const emit = defineEmits(["close", "groupsUpdated", "done"]); | ||||||
|     const { t } = useI18n(); | const {t} = useI18n(); | ||||||
| 
 | 
 | ||||||
|     interface StudentItem { | interface StudentItem { | ||||||
|         username: string; |     username: string; | ||||||
|         fullName: string; |     fullName: string; | ||||||
|     } | } | ||||||
| 
 | 
 | ||||||
|     const { data: studentsData } = useClassStudentsQuery(() => props.classId, true); | const {data: studentsData} = useClassStudentsQuery(() => props.classId, true); | ||||||
| 
 | 
 | ||||||
|     // Dialog states | // Dialog states for group editing | ||||||
|     const activeDialog = ref<"random" | "dragdrop" | null>(null); | const activeDialog = ref<"random" | "dragdrop" | null>(null); | ||||||
| 
 | 
 | ||||||
|     // Drag state | // Drag state for the drag and drop | ||||||
|     const draggedItem = ref<{groupIndex: number, studentIndex: number} | null>(null); | const draggedItem = ref<{ groupIndex: number, studentIndex: number } | null>(null); | ||||||
| 
 | 
 | ||||||
|     const currentGroups = ref<StudentItem[][]>([]); | const currentGroups = ref<StudentItem[][]>([]); | ||||||
|     const unassignedStudents = ref<StudentItem[]>([]); | const unassignedStudents = ref<StudentItem[]>([]); | ||||||
|     const allStudents = ref<StudentItem[]>([]); | const allStudents = ref<StudentItem[]>([]); | ||||||
| 
 | 
 | ||||||
|     // Random groups state | // Random groups state | ||||||
|     const groupSize = ref(1); | const groupSize = ref(1); | ||||||
|     const randomGroupsPreview = ref<StudentItem[][]>([]); | const randomGroupsPreview = ref<StudentItem[][]>([]); | ||||||
| 
 | 
 | ||||||
|     // Initialize data | // Initialize data | ||||||
|     watch( | watch( | ||||||
|         () => [studentsData.value, props.groups], |     () => [studentsData.value, props.groups], | ||||||
|         ([studentsVal, existingGroups]) => { |     ([studentsVal, existingGroups]) => { | ||||||
|             if (!studentsVal) return; |         if (!studentsVal) return; | ||||||
| 
 | 
 | ||||||
|             // Initialize all students |         // Initialize all students | ||||||
|             allStudents.value = studentsVal.students.map((s) => ({ |         allStudents.value = studentsVal.students.map((s) => ({ | ||||||
|                 username: s.username, |             username: s.username, | ||||||
|                 fullName: `${s.firstName} ${s.lastName}`, |             fullName: `${s.firstName} ${s.lastName}`, | ||||||
|             })); |         })); | ||||||
| 
 | 
 | ||||||
|             // Initialize groups if they exist |         // Initialize groups if they exist | ||||||
|             if (existingGroups && existingGroups.length > 0) { |         if (existingGroups && existingGroups.length > 0) { | ||||||
|                 currentGroups.value = existingGroups.map((group) => |             currentGroups.value = existingGroups.map((group) => | ||||||
|                     group.members.map(member => ({ |                 group.members.map(member => ({ | ||||||
|                         username: member.username, |                     username: member.username, | ||||||
|                         fullName: `${member.firstName} ${member.lastName}` |                     fullName: `${member.firstName} ${member.lastName}` | ||||||
|                     })) |                 })) | ||||||
|                 ); |             ); | ||||||
|                 const assignedUsernames = new Set( |             const assignedUsernames = new Set( | ||||||
|                     existingGroups.flatMap((g) => g.members.map((m: StudentItem) => m.username)), |                 existingGroups.flatMap((g) => g.members.map((m: StudentItem) => m.username)), | ||||||
|                 ); |             ); | ||||||
|                 unassignedStudents.value = allStudents.value.filter((s) => !assignedUsernames.has(s.username)); |             unassignedStudents.value = allStudents.value.filter((s) => !assignedUsernames.has(s.username)); | ||||||
|             } else { |  | ||||||
|                 // Default to all students unassigned |  | ||||||
|                 currentGroups.value = []; |  | ||||||
|                 unassignedStudents.value = [...allStudents.value]; |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             // Initialize random preview with current groups |  | ||||||
|             randomGroupsPreview.value = [...currentGroups.value]; |  | ||||||
|         }, |  | ||||||
|         { immediate: true }, |  | ||||||
|     ); |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|     // Random groups functions |  | ||||||
|     function generateRandomGroups() { |  | ||||||
|         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; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     function saveRandomGroups() { |  | ||||||
|         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"); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Drag and drop functions |  | ||||||
|     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); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Native Drag & Drop Handlers |  | ||||||
|     function handleDragStart(groupIndex: number, studentIndex: number) { |  | ||||||
|         draggedItem.value = { groupIndex, studentIndex }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     function handleDragOver(e: DragEvent, _: number) { |  | ||||||
|         e.preventDefault(); |  | ||||||
|         e.dataTransfer!.dropEffect = "move"; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     function handleDrop(e: DragEvent, targetGroupIndex: number, targetStudentIndex?: number) { |  | ||||||
|         e.preventDefault(); |  | ||||||
|         if (!draggedItem.value) return; |  | ||||||
| 
 |  | ||||||
|         const { groupIndex: sourceGroupIndex, studentIndex: sourceStudentIndex } = draggedItem.value; |  | ||||||
|         const isSameGroup = sourceGroupIndex === targetGroupIndex; |  | ||||||
| 
 |  | ||||||
|         let sourceArray, targetArray; |  | ||||||
| 
 |  | ||||||
|         // Determine source and target arrays |  | ||||||
|         if (sourceGroupIndex === -1) { |  | ||||||
|             sourceArray = unassignedStudents.value; |  | ||||||
|         } else { |         } else { | ||||||
|             sourceArray = currentGroups.value[sourceGroupIndex]; |             currentGroups.value = []; | ||||||
|  |             unassignedStudents.value = [...allStudents.value]; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         if (targetGroupIndex === -1) { |         randomGroupsPreview.value = [...currentGroups.value]; | ||||||
|             targetArray = unassignedStudents.value; |     }, | ||||||
|         } else { |     {immediate: true}, | ||||||
|             targetArray = currentGroups.value[targetGroupIndex]; | ); | ||||||
|         } |  | ||||||
| 
 | 
 | ||||||
|         // Remove from source | /** Random groups functions */ | ||||||
|         const [movedStudent] = sourceArray.splice(sourceStudentIndex, 1); | function generateRandomGroups(): void { | ||||||
|  |     if (groupSize.value < 1) return; | ||||||
| 
 | 
 | ||||||
|         // Add to target |     // Shuffle students | ||||||
|         if (targetStudentIndex !== undefined) { |     const shuffled = [...allStudents.value].sort(() => Math.random() - 0.5); | ||||||
|             targetArray.splice(targetStudentIndex, 0, movedStudent); |  | ||||||
|         } else { |  | ||||||
|             targetArray.push(movedStudent); |  | ||||||
|         } |  | ||||||
| 
 | 
 | ||||||
|         draggedItem.value = null; |     // 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 | ||||||
|     function saveDragDrop() { |     shuffled.forEach((student, index) => { | ||||||
|         if (unassignedStudents.value.length > 0) { |         const groupIndex = index % groupCount; | ||||||
|             alert(t("please-assign-all-students")); |         newGroups[groupIndex].push(student); | ||||||
|             return; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         emit( |  | ||||||
|             "groupsUpdated", |  | ||||||
|             currentGroups.value.map((g) => g.map((s) => s.username)), |  | ||||||
|         ); |  | ||||||
|         activeDialog.value = null; |  | ||||||
|         emit("done"); |  | ||||||
|         emit("close"); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // Preview current groups in the main view |  | ||||||
|     const showGroupsPreview = computed(() => { |  | ||||||
|         return currentGroups.value.length > 0 || unassignedStudents.value.length > 0; |  | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     function removeStudent(groupIndex, student) { |     randomGroupsPreview.value = newGroups; | ||||||
|         const group = currentGroups.value[groupIndex]; | } | ||||||
|         currentGroups.value[groupIndex] = group.filter((s) => s.username !== student.username); | 
 | ||||||
|         unassignedStudents.value.push(student); | 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, | ||||||
|  |     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); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     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> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|     <v-card class="pa-4"> |     <v-card class="pa-4"> | ||||||
|         <!-- Current Groups Preview --> |         <!-- Current groups and unassigned students Preview --> | ||||||
|         <div |         <div v-if="showGroupsPreview" class="mb-6"> | ||||||
|             v-if="showGroupsPreview" |  | ||||||
|             class="mb-6" |  | ||||||
|         > |  | ||||||
|             <h3 class="mb-2">{{ t("current-groups") }}</h3> |             <h3 class="mb-2">{{ t("current-groups") }}</h3> | ||||||
|             <div |             <div> | ||||||
|             > |  | ||||||
|                 <div class="d-flex flex-wrap"> |                 <div class="d-flex flex-wrap"> | ||||||
|                     <label>{{currentGroups.length}}</label> |                     <label>{{ currentGroups.length }}</label> | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|             <div |             <div v-if="unassignedStudents.length > 0" class="mt-3"> | ||||||
|                 v-if="unassignedStudents.length > 0" |  | ||||||
|                 class="mt-3" |  | ||||||
|             > |  | ||||||
|                 <strong>{{ t("unassigned-students") }}:</strong> |                 <strong>{{ t("unassigned-students") }}:</strong> | ||||||
|                 <div class="d-flex flex-wrap"> |                 <div class="d-flex flex-wrap"> | ||||||
|                     <label>{{unassignedStudents.length}}</label> |                     <label>{{ unassignedStudents.length }}</label> | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </div> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <!-- Action Buttons --> |         <v-row justify="center" class="mb-4"> | ||||||
|         <v-row |             <v-btn color="primary" @click="activeDialog = 'random'"> | ||||||
|             justify="center" |  | ||||||
|             class="mb-4" |  | ||||||
|         > |  | ||||||
|             <v-btn |  | ||||||
|                 color="primary" |  | ||||||
|                 @click="activeDialog = 'random'" |  | ||||||
|             > |  | ||||||
|                 {{ t("randomly-create-groups") }} |                 {{ t("randomly-create-groups") }} | ||||||
|             </v-btn> |             </v-btn> | ||||||
|             <v-btn |             <v-btn color="secondary" class="ml-4" @click="activeDialog = 'dragdrop'"> | ||||||
|                 color="secondary" |  | ||||||
|                 class="ml-4" |  | ||||||
|                 @click="activeDialog = 'dragdrop'" |  | ||||||
|             > |  | ||||||
|                 {{ t("drag-and-drop") }} |                 {{ t("drag-and-drop") }} | ||||||
|             </v-btn> |             </v-btn> | ||||||
|         </v-row> |         </v-row> | ||||||
| 
 | 
 | ||||||
|         <!-- Random Groups Dialog --> |         <!-- Random Groups selection Dialog --> | ||||||
|         <v-dialog |         <v-dialog | ||||||
|             :model-value="activeDialog === 'random'" |             :model-value="activeDialog === 'random'" | ||||||
|             @update:model-value="(val) => (val ? (activeDialog = 'random') : (activeDialog = null))" |             @update:model-value="(val) => (val ? (activeDialog = 'random') : (activeDialog = null))" | ||||||
|  | @ -298,12 +443,8 @@ | ||||||
|                 </v-card-text> |                 </v-card-text> | ||||||
| 
 | 
 | ||||||
|                 <v-card-actions> |                 <v-card-actions> | ||||||
|                     <v-spacer /> |                     <v-spacer/> | ||||||
|                     <v-btn |                     <v-btn text @click="activeDialog = null">{{ t("cancel") }}</v-btn> | ||||||
|                         text |  | ||||||
|                         @click="activeDialog = null" |  | ||||||
|                         >{{ t("cancel") }}</v-btn |  | ||||||
|                     > |  | ||||||
|                     <v-btn |                     <v-btn | ||||||
|                         color="success" |                         color="success" | ||||||
|                         @click="saveRandomGroups" |                         @click="saveRandomGroups" | ||||||
|  | @ -356,7 +497,10 @@ | ||||||
|                                             :key="student.username" |                                             :key="student.username" | ||||||
|                                             class="draggable-item ma-1" |                                             class="draggable-item ma-1" | ||||||
|                                             draggable="true" |                                             draggable="true" | ||||||
|                                             @dragstart="handleDragStart(groupIndex, studentIndex)" |                                             @touchstart="handleTouchStart($event, groupIndex, studentIndex)" | ||||||
|  |                                             @touchmove="handleTouchMove($event)" | ||||||
|  |                                             @touchend="handleTouchEnd($event)" | ||||||
|  |                                             @dragstart="handleDragStart($event, groupIndex, studentIndex)" | ||||||
|                                             @dragover.prevent="handleDragOver($event, groupIndex)" |                                             @dragover.prevent="handleDragOver($event, groupIndex)" | ||||||
|                                             @drop="handleDrop($event, groupIndex, studentIndex)" |                                             @drop="handleDrop($event, groupIndex, studentIndex)" | ||||||
|                                         > |                                         > | ||||||
|  | @ -387,7 +531,10 @@ | ||||||
|                                     :key="student.username" |                                     :key="student.username" | ||||||
|                                     class="draggable-item ma-1" |                                     class="draggable-item ma-1" | ||||||
|                                     draggable="true" |                                     draggable="true" | ||||||
|                                     @dragstart="handleDragStart(-1, studentIndex)" |                                     @touchstart="handleTouchStart($event, -1, studentIndex)" | ||||||
|  |                                     @touchmove="handleTouchMove($event)" | ||||||
|  |                                     @touchend="handleTouchEnd($event)" | ||||||
|  |                                     @dragstart="handleDragStart($event, -1, studentIndex)" | ||||||
|                                     @dragover.prevent="handleDragOver($event, -1)" |                                     @dragover.prevent="handleDragOver($event, -1)" | ||||||
|                                     @drop="handleDrop($event, -1, studentIndex)" |                                     @drop="handleDrop($event, -1, studentIndex)" | ||||||
|                                 > |                                 > | ||||||
|  | @ -399,7 +546,7 @@ | ||||||
|                 </v-card-text> |                 </v-card-text> | ||||||
| 
 | 
 | ||||||
|                 <v-card-actions> |                 <v-card-actions> | ||||||
|                     <v-spacer /> |                     <v-spacer/> | ||||||
|                     <v-btn text @click="activeDialog = null">{{ t("cancel") }}</v-btn> |                     <v-btn text @click="activeDialog = null">{{ t("cancel") }}</v-btn> | ||||||
|                     <v-btn |                     <v-btn | ||||||
|                         color="primary" |                         color="primary" | ||||||
|  | @ -415,16 +562,45 @@ | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped> | <style scoped> | ||||||
|     .group-box { | .group-box { | ||||||
|         min-height: 150px; |     min-height: 100px; | ||||||
|         max-height: 300px; |     max-height: 200px; | ||||||
|         overflow-y: auto; |     overflow-y: auto; | ||||||
|         background-color: #fafafa; |     background-color: #fafafa; | ||||||
|         border-radius: 4px; |     border-radius: 4px; | ||||||
|     } |     transition: all 0.2s; | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
|     .v-expansion-panel-text { | .group-box.highlight { | ||||||
|         max-height: 200px; |     background-color: #e3f2fd; | ||||||
|         overflow-y: auto; |     border: 2px dashed #2196f3; | ||||||
|     } | } | ||||||
|  | 
 | ||||||
|  | .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; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .draggable-item { | ||||||
|  |     display: inline-block; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .draggable-item .v-chip[style*="hidden"] { | ||||||
|  |     visibility: hidden; | ||||||
|  |     display: inline-block; | ||||||
|  | } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|  | @ -344,69 +344,71 @@ async function saveChanges(): Promise<void> { | ||||||
|                         md="6" |                         md="6" | ||||||
|                         class="responsive-col" |                         class="responsive-col" | ||||||
|                     > |                     > | ||||||
|                         <v-table class="table"> |                         <div class="table-container"> | ||||||
|                             <thead> |                             <v-table class="table"> | ||||||
|                             <tr> |                                 <thead> | ||||||
|                                 <th class="header">{{ t("group") }}</th> |                                 <tr> | ||||||
|                                 <th class="header">{{ t("progress") }}</th> |                                     <th class="header">{{ t("group") }}</th> | ||||||
|                                 <th class="header">{{ t("submission") }}</th> |                                     <th class="header">{{ t("progress") }}</th> | ||||||
|                                 <th class="header"> |                                     <th class="header">{{ t("submission") }}</th> | ||||||
|                                     <v-btn |                                     <th class="header"> | ||||||
|                                         @click="editGroups = true" |                                         <v-btn | ||||||
|                                         variant="text" |                                             @click="editGroups = true" | ||||||
|                                     > |                                             variant="text" | ||||||
|                                         <v-icon>mdi-pencil</v-icon> |                                         > | ||||||
|                                     </v-btn> |                                             <v-icon>mdi-pencil</v-icon> | ||||||
|                                 </th> |                                         </v-btn> | ||||||
|                             </tr> |                                     </th> | ||||||
|                             </thead> |                                 </tr> | ||||||
|                             <tbody> |                                 </thead> | ||||||
|                             <tr |                                 <tbody> | ||||||
|                                 v-for="g in allGroups" |                                 <tr | ||||||
|                                 :key="g.originalGroupNo" |                                     v-for="g in allGroups" | ||||||
|                             > |                                     :key="g.originalGroupNo" | ||||||
|                                 <td> |                                 > | ||||||
|                                     <v-btn |                                     <td> | ||||||
|                                         @click="openGroupDetails(g)" |                                         <v-btn | ||||||
|                                         variant="text" |                                             @click="openGroupDetails(g)" | ||||||
|                                     > |                                             variant="text" | ||||||
|                                         {{ g.name }} |                                         > | ||||||
|                                         <v-icon end>mdi-menu-right</v-icon> |                                             {{ g.name }} | ||||||
|                                     </v-btn> |                                             <v-icon end>mdi-menu-right</v-icon> | ||||||
|                                 </td> |                                         </v-btn> | ||||||
|  |                                     </td> | ||||||
| 
 | 
 | ||||||
|                                 <td> |                                     <td> | ||||||
|                                     <GroupProgressRow |                                         <GroupProgressRow | ||||||
|                                         :group-number="g.originalGroupNo" |                                             :group-number="g.originalGroupNo" | ||||||
|                                         :learning-path="learningPath" |                                             :learning-path="learningPath" | ||||||
|                                         :language="lang" |                                             :language="lang" | ||||||
|                                         :assignment-id="assignmentId" |                                             :assignment-id="assignmentId" | ||||||
|                                         :class-id="classId" |                                             :class-id="classId" | ||||||
|                                     /> |                                         /> | ||||||
|                                 </td> |                                     </td> | ||||||
| 
 | 
 | ||||||
|                                 <td> |                                     <td> | ||||||
|                                     <GroupSubmissionStatus |                                         <GroupSubmissionStatus | ||||||
|                                         :group="g" |                                             :group="g" | ||||||
|                                         :assignment-id="assignmentId" |                                             :assignment-id="assignmentId" | ||||||
|                                         :class-id="classId" |                                             :class-id="classId" | ||||||
|                                         :language="lang" |                                             :language="lang" | ||||||
|                                         :go-to-group-submission-link="goToGroupSubmissionLink" |                                             :go-to-group-submission-link="goToGroupSubmissionLink" | ||||||
|                                     /> |                                         /> | ||||||
|                                 </td> |                                     </td> | ||||||
| 
 | 
 | ||||||
|                                 <!-- Edit icon --> |                                     <!-- Edit icon --> | ||||||
|                                 <td> |                                     <td> | ||||||
|                                     <v-btn |                                         <v-btn | ||||||
|                                         @click="" |                                             @click="" | ||||||
|                                         variant="text" |                                             variant="text" | ||||||
|                                     > |                                         > | ||||||
|                                         <v-icon color="red">mdi-delete</v-icon> |                                             <v-icon color="red">mdi-delete</v-icon> | ||||||
|                                     </v-btn> |                                         </v-btn> | ||||||
|                                 </td> |                                     </td> | ||||||
|                             </tr> |                                 </tr> | ||||||
|                             </tbody> |                                 </tbody> | ||||||
|                         </v-table> |                             </v-table> | ||||||
|  |                         </div> | ||||||
|                     </v-col> |                     </v-col> | ||||||
|                 </v-row> |                 </v-row> | ||||||
|                 <v-dialog |                 <v-dialog | ||||||
|  | @ -510,6 +512,17 @@ main { | ||||||
|     margin-left: 30px; |     margin-left: 30px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .table-container { | ||||||
|  |     width: 100%; | ||||||
|  |     overflow-x: visible; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .table { | ||||||
|  |     width: 100%; | ||||||
|  |     min-width: auto; | ||||||
|  |     table-layout: auto; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| @media screen and (max-width: 850px) { | @media screen and (max-width: 850px) { | ||||||
|     h1 { |     h1 { | ||||||
|         text-align: center; |         text-align: center; | ||||||
|  | @ -540,6 +553,12 @@ main { | ||||||
| 
 | 
 | ||||||
|     .table { |     .table { | ||||||
|         width: 100%; |         width: 100%; | ||||||
|  |         display: block; | ||||||
|  |         overflow-x: auto; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .table-container { | ||||||
|  |         overflow-x: auto; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     .responsive-col { |     .responsive-col { | ||||||
|  |  | ||||||
							
								
								
									
										16
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										16
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							|  | @ -101,6 +101,7 @@ | ||||||
|                 "@tanstack/vue-query": "^5.69.0", |                 "@tanstack/vue-query": "^5.69.0", | ||||||
|                 "@vueuse/core": "^13.1.0", |                 "@vueuse/core": "^13.1.0", | ||||||
|                 "axios": "^1.8.2", |                 "axios": "^1.8.2", | ||||||
|  |                 "interactjs": "^1.10.27", | ||||||
|                 "oidc-client-ts": "^3.1.0", |                 "oidc-client-ts": "^3.1.0", | ||||||
|                 "rollup": "^4.40.0", |                 "rollup": "^4.40.0", | ||||||
|                 "uuid": "^11.1.0", |                 "uuid": "^11.1.0", | ||||||
|  | @ -1596,6 +1597,12 @@ | ||||||
|                 "url": "https://github.com/sponsors/nzakas" |                 "url": "https://github.com/sponsors/nzakas" | ||||||
|             } |             } | ||||||
|         }, |         }, | ||||||
|  |         "node_modules/@interactjs/types": { | ||||||
|  |             "version": "1.10.27", | ||||||
|  |             "resolved": "https://registry.npmjs.org/@interactjs/types/-/types-1.10.27.tgz", | ||||||
|  |             "integrity": "sha512-BUdv0cvs4H5ODuwft2Xp4eL8Vmi3LcihK42z0Ft/FbVJZoRioBsxH+LlsBdK4tAie7PqlKGy+1oyOncu1nQ6eA==", | ||||||
|  |             "license": "MIT" | ||||||
|  |         }, | ||||||
|         "node_modules/@intlify/core-base": { |         "node_modules/@intlify/core-base": { | ||||||
|             "version": "11.1.3", |             "version": "11.1.3", | ||||||
|             "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.3.tgz", |             "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.3.tgz", | ||||||
|  | @ -6830,6 +6837,15 @@ | ||||||
|             "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", |             "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", | ||||||
|             "license": "ISC" |             "license": "ISC" | ||||||
|         }, |         }, | ||||||
|  |         "node_modules/interactjs": { | ||||||
|  |             "version": "1.10.27", | ||||||
|  |             "resolved": "https://registry.npmjs.org/interactjs/-/interactjs-1.10.27.tgz", | ||||||
|  |             "integrity": "sha512-y/8RcCftGAF24gSp76X2JS3XpHiUvDQyhF8i7ujemBz77hwiHDuJzftHx7thY8cxGogwGiPJ+o97kWB6eAXnsA==", | ||||||
|  |             "license": "MIT", | ||||||
|  |             "dependencies": { | ||||||
|  |                 "@interactjs/types": "1.10.27" | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|         "node_modules/interpret": { |         "node_modules/interpret": { | ||||||
|             "version": "2.2.0", |             "version": "2.2.0", | ||||||
|             "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", |             "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", | ||||||
|  |  | ||||||
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana