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",
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue