feat: drag and drop en random selection voor groepen
This commit is contained in:
		
							parent
							
								
									936a34b709
								
							
						
					
					
						commit
						a3185ed1c1
					
				
					 10 changed files with 4347 additions and 1249 deletions
				
			
		|  | @ -30,10 +30,9 @@ | |||
|     display: flex; | ||||
|     gap: 0.5rem; | ||||
|     align-items: center; | ||||
|     color: #0e6942 | ||||
|     color: #0e6942; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| .group-section { | ||||
|     margin-top: 2rem; | ||||
| } | ||||
|  |  | |||
|  | @ -1,7 +1,10 @@ | |||
| <template> | ||||
|     <v-table class="table"> | ||||
|         <thead> | ||||
|             <tr v-for="name in columns" :key="column"> | ||||
|             <tr | ||||
|                 v-for="name in columns" | ||||
|                 :key="column" | ||||
|             > | ||||
|                 <th class="header">{{ name }}</th> | ||||
|             </tr> | ||||
|         </thead> | ||||
|  | @ -37,13 +40,13 @@ | |||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   name: 'columnList', | ||||
|     export default { | ||||
|         name: "columnList", | ||||
|         props: { | ||||
|             items: { | ||||
|                 type: Array, | ||||
|       required: true | ||||
|     } | ||||
|   } | ||||
| } | ||||
|                 required: true, | ||||
|             }, | ||||
|         }, | ||||
|     }; | ||||
| </script> | ||||
|  | @ -1,18 +1,18 @@ | |||
| <script setup lang="ts"> | ||||
| import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
| import { computed } from "vue"; | ||||
| import type { Language } from "@/data-objects/language.ts"; | ||||
| import { calculateProgress } from "@/utils/assignment-utils.ts"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import { computed } from "vue"; | ||||
|     import type { Language } from "@/data-objects/language.ts"; | ||||
|     import { calculateProgress } from "@/utils/assignment-utils.ts"; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|     const props = defineProps<{ | ||||
|         groupNumber: number; | ||||
|         learningPath: string; | ||||
|         language: Language; | ||||
|         assignmentId: number; | ||||
|         classId: string; | ||||
| }>(); | ||||
|     }>(); | ||||
| 
 | ||||
| const query = useGetLearningPathQuery( | ||||
|     const query = useGetLearningPathQuery( | ||||
|         () => props.learningPath, | ||||
|         () => props.language, | ||||
|         () => ({ | ||||
|  | @ -20,18 +20,18 @@ const query = useGetLearningPathQuery( | |||
|             assignmentNo: props.assignmentId, | ||||
|             classId: props.classId, | ||||
|         }), | ||||
| ); | ||||
|     ); | ||||
| 
 | ||||
| const progress = computed(() => { | ||||
|     const progress = computed(() => { | ||||
|         if (!query.data.value) return 0; | ||||
|         return calculateProgress(query.data.value); | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| const progressColor = computed(() => { | ||||
|     const progressColor = computed(() => { | ||||
|         if (progress.value < 50) return "error"; | ||||
|         if (progress.value < 80) return "warning"; | ||||
|         return "success"; | ||||
| }); | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -1,23 +1,23 @@ | |||
| <script setup lang="ts"> | ||||
| import { useI18n } from "vue-i18n"; | ||||
| import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
| import { useAssignmentSubmissionsQuery } from "@/queries/assignments.ts"; | ||||
| import type { SubmissionsResponse } from "@/controllers/submissions.ts"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useAssignmentSubmissionsQuery } from "@/queries/assignments.ts"; | ||||
|     import type { SubmissionsResponse } from "@/controllers/submissions.ts"; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|     const props = defineProps<{ | ||||
|         group: object; | ||||
|         assignmentId: number; | ||||
|         classId: string; | ||||
|         goToGroupSubmissionLink: (groupNo: number) => void; | ||||
| }>(); | ||||
|     }>(); | ||||
| 
 | ||||
| const { t } = useI18n(); | ||||
| const submissionsQuery = useAssignmentSubmissionsQuery( | ||||
|     const { t } = useI18n(); | ||||
|     const submissionsQuery = useAssignmentSubmissionsQuery( | ||||
|         () => props.classId, | ||||
|         () => props.assignmentId, | ||||
|         () => props.group.originalGroupNo, | ||||
|         () => true, | ||||
| ); | ||||
|     ); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  |  | |||
|  | @ -1,114 +1,438 @@ | |||
| <script setup lang="ts"> | ||||
| import { ref, } from "vue"; | ||||
| import draggable from "vuedraggable"; | ||||
| import { useI18n } from "vue-i18n"; | ||||
|     import { computed, ref, watch } from "vue"; | ||||
|     import draggable from "vuedraggable"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useClassStudentsQuery } from "@/queries/classes"; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|     const props = defineProps<{ | ||||
|         classId: string | undefined; | ||||
|     groups: string[][]; | ||||
| }>(); | ||||
| const emit = defineEmits(["done", "groupsUpdated"]); | ||||
| const { t } = useI18n(); | ||||
|         groups: object[]; | ||||
|     }>(); | ||||
|     const emit = defineEmits(["close", "groupsUpdated", "done"]); | ||||
|     const { t } = useI18n(); | ||||
| 
 | ||||
| const groupList = ref(props.groups.map(g => [...g])); // deep copy | ||||
| const unassigned = ref<string[]>([]); // voor vrije studenten | ||||
|     interface StudentItem { | ||||
|         username: string; | ||||
|         fullName: string; | ||||
|     } | ||||
| 
 | ||||
| function addNewGroup() { | ||||
|     groupList.value.push([]); | ||||
| } | ||||
|     const { data: studentsData } = useClassStudentsQuery(() => props.classId, true); | ||||
| 
 | ||||
| function removeGroup(index: number) { | ||||
|     unassigned.value.push(...groupList.value[index]); | ||||
|     groupList.value.splice(index, 1); | ||||
| } | ||||
|     // Dialog states | ||||
|     const activeDialog = ref<"random" | "dragdrop" | null>(null); | ||||
| 
 | ||||
| function saveChanges() { | ||||
|     emit("groupsUpdated", groupList.value); | ||||
|     // Drag state | ||||
|     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((g) => [...g.members]); | ||||
|                 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 { | ||||
|                 // 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 { | ||||
|             sourceArray = currentGroups.value[sourceGroupIndex]; | ||||
|         } | ||||
| 
 | ||||
|         if (targetGroupIndex === -1) { | ||||
|             targetArray = unassignedStudents.value; | ||||
|         } else { | ||||
|             targetArray = currentGroups.value[targetGroupIndex]; | ||||
|         } | ||||
| 
 | ||||
|         // Remove from source | ||||
|         const [movedStudent] = sourceArray.splice(sourceStudentIndex, 1); | ||||
| 
 | ||||
|         // Add to target | ||||
|         if (targetStudentIndex !== undefined) { | ||||
|             targetArray.splice(targetStudentIndex, 0, movedStudent); | ||||
|         } else { | ||||
|             targetArray.push(movedStudent); | ||||
|         } | ||||
| 
 | ||||
|         draggedItem.value = null; | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     function saveDragDrop() { | ||||
|         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"); | ||||
|     } | ||||
| 
 | ||||
|     // Preview current groups in the main view | ||||
|     const showGroupsPreview = computed(() => { | ||||
|         return currentGroups.value.length > 0 || unassignedStudents.value.length > 0; | ||||
|     }); | ||||
| 
 | ||||
|     function removeStudent(groupIndex, student) { | ||||
|         const group = currentGroups.value[groupIndex]; | ||||
|         currentGroups.value[groupIndex] = group.filter((s) => s.username !== student.username); | ||||
|         unassignedStudents.value.push(student); | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <v-card> | ||||
|         <v-card-title>{{ t("edit-groups") }}</v-card-title> | ||||
|         <v-card-text> | ||||
|             <v-row> | ||||
|                 <!-- Ongegroepeerde studenten --> | ||||
|                 <v-col cols="12" sm="4"> | ||||
|                     <h4>{{ t("unassigned") }}</h4> | ||||
|                     <draggable | ||||
|                         v-model="unassigned" | ||||
|                         group="students" | ||||
|                         item-key="username" | ||||
|                         class="group-box" | ||||
|     <v-card class="pa-4"> | ||||
|         <!-- Current Groups Preview --> | ||||
|         <div | ||||
|             v-if="showGroupsPreview" | ||||
|             class="mb-6" | ||||
|         > | ||||
|                         <template #item="{ element }"> | ||||
|                             <v-chip>{{ element }}</v-chip> | ||||
|                         </template> | ||||
|                     </draggable> | ||||
|                 </v-col> | ||||
|             <h3 class="mb-2">{{ t("current-groups") }}</h3> | ||||
|             <div | ||||
|                 v-for="(group, index) in currentGroups" | ||||
|                 :key="'preview-' + index" | ||||
|                 class="mb-3" | ||||
|             > | ||||
|                 <div class="d-flex align-center"> | ||||
|                     <strong class="mr-2">{{ t("group") }} {{ index + 1 }}:</strong> | ||||
|                     <span class="text-caption">({{ group.length }} {{ t("members") }})</span> | ||||
|                 </div> | ||||
|                 <div class="d-flex flex-wrap"> | ||||
|                     <v-chip | ||||
|                         v-for="student in group" | ||||
|                         :key="student.username" | ||||
|                         class="ma-1" | ||||
|                     > | ||||
|                         {{ student.fullName }} | ||||
|                     </v-chip> | ||||
|                 </div> | ||||
|             </div> | ||||
| 
 | ||||
|                 <!-- Bestaande groepen --> | ||||
|                 <v-col | ||||
|                     v-for="(group, i) in groupList" | ||||
|                     :key="i" | ||||
|                     cols="12" | ||||
|                     sm="4" | ||||
|             <div | ||||
|                 v-if="unassignedStudents.length > 0" | ||||
|                 class="mt-3" | ||||
|             > | ||||
|                     <h4>{{ t("group") }} {{ i + 1 }}</h4> | ||||
|                     <draggable | ||||
|                         v-model="groupList[i]" | ||||
|                         group="students" | ||||
|                         item-key="username" | ||||
|                         class="group-box" | ||||
|                     > | ||||
|                         <template #item="{ element }"> | ||||
|                             <v-chip>{{ element }}</v-chip> | ||||
|                         </template> | ||||
|                     </draggable> | ||||
|                 <strong>{{ t("unassigned") }}:</strong> | ||||
|                 <div class="d-flex flex-wrap"> | ||||
|                     <label>{{unassignedStudents.length}}</label> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <!-- Action Buttons --> | ||||
|         <v-row | ||||
|             justify="center" | ||||
|             class="mb-4" | ||||
|         > | ||||
|             <v-btn | ||||
|                         color="error" | ||||
|                         size="x-small" | ||||
|                         @click="removeGroup(i)" | ||||
|                         class="mt-2" | ||||
|                 color="primary" | ||||
|                 @click="activeDialog = 'random'" | ||||
|             > | ||||
|                         {{ t("remove-group") }} | ||||
|                 {{ t("randomly-create-groups") }} | ||||
|             </v-btn> | ||||
|             <v-btn | ||||
|                 color="secondary" | ||||
|                 class="ml-4" | ||||
|                 @click="activeDialog = 'dragdrop'" | ||||
|             > | ||||
|                 {{ t("drag-and-drop") }} | ||||
|             </v-btn> | ||||
|         </v-row> | ||||
| 
 | ||||
|         <!-- Random Groups Dialog --> | ||||
|         <v-dialog | ||||
|             :model-value="activeDialog === 'random'" | ||||
|             @update:model-value="(val) => (val ? (activeDialog = 'random') : (activeDialog = null))" | ||||
|             max-width="600" | ||||
|         > | ||||
|             <v-card> | ||||
|                 <v-card-title>{{ t("randomly-create-groups") }}</v-card-title> | ||||
|                 <v-card-text> | ||||
|                     <v-row align="center"> | ||||
|                         <v-col cols="6"> | ||||
|                             <v-text-field | ||||
|                                 v-model.number="groupSize" | ||||
|                                 type="number" | ||||
|                                 min="1" | ||||
|                                 :max="allStudents.length" | ||||
|                                 :label="t('group-size-label')" | ||||
|                                 dense | ||||
|                             /> | ||||
|                         </v-col> | ||||
|                         <v-col cols="6"> | ||||
|                             <v-btn | ||||
|                                 color="primary" | ||||
|                                 @click="generateRandomGroups" | ||||
|                                 :disabled="groupSize < 1" | ||||
|                                 block | ||||
|                             > | ||||
|                                 {{ t("generate-groups") }} | ||||
|                             </v-btn> | ||||
|                         </v-col> | ||||
|                     </v-row> | ||||
| 
 | ||||
|             <v-btn | ||||
|                 color="primary" | ||||
|                 class="mt-4" | ||||
|                 @click="addNewGroup" | ||||
|                     <div class="mt-4"> | ||||
|                         <div class="d-flex justify-space-between align-center mb-2"> | ||||
|                             <strong>{{ t("preview") }}</strong> | ||||
|                             <span class="text-caption"> {{ randomGroupsPreview.length }} {{ t("groups") }} </span> | ||||
|                         </div> | ||||
| 
 | ||||
|                         <v-expansion-panels> | ||||
|                             <v-expansion-panel | ||||
|                                 v-for="(group, index) in randomGroupsPreview" | ||||
|                                 :key="'random-preview-' + index" | ||||
|                             > | ||||
|                 {{ t("add-group") }} | ||||
|             </v-btn> | ||||
|                                 <v-expansion-panel-title> | ||||
|                                     {{ t("group") }} {{ index + 1 }} ({{ group.length }} {{ t("members") }}) | ||||
|                                 </v-expansion-panel-title> | ||||
|                                 <v-expansion-panel-text> | ||||
|                                     <v-chip | ||||
|                                         v-for="student in group" | ||||
|                                         :key="student.username" | ||||
|                                         class="ma-1" | ||||
|                                     > | ||||
|                                         {{ student.fullName }} | ||||
|                                     </v-chip> | ||||
|                                 </v-expansion-panel-text> | ||||
|                             </v-expansion-panel> | ||||
|                         </v-expansion-panels> | ||||
|                     </div> | ||||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-actions> | ||||
|                     <v-spacer /> | ||||
|                     <v-btn | ||||
|                         text | ||||
|                         @click="activeDialog = null" | ||||
|                         >{{ t("cancel") }}</v-btn | ||||
|                     > | ||||
|                     <v-btn | ||||
|                         color="success" | ||||
|                 @click="saveChanges" | ||||
|                         @click="saveRandomGroups" | ||||
|                         :disabled="randomGroupsPreview.length === 0" | ||||
|                     > | ||||
|                         {{ t("save") }} | ||||
|                     </v-btn> | ||||
|             <v-btn | ||||
|                 @click="$emit('done')" | ||||
|                 variant="text" | ||||
|                 </v-card-actions> | ||||
|             </v-card> | ||||
|         </v-dialog> | ||||
| 
 | ||||
|         <!-- Drag and Drop Dialog --> | ||||
|         <v-dialog | ||||
|             :model-value="activeDialog === 'dragdrop'" | ||||
|             @update:model-value="(val) => (val ? (activeDialog = 'dragdrop') : (activeDialog = null))" | ||||
|             max-width="900" | ||||
|         > | ||||
|                 {{ t("cancel") }} | ||||
|             <v-card> | ||||
|                 <v-card-title class="d-flex justify-space-between align-center"> | ||||
|                     <div>{{ t("drag-and-drop") }}</div> | ||||
|                     <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-alert type="info">{{ t("no-groups-yet") }}</v-alert> | ||||
|                             </div> | ||||
| 
 | ||||
|                             <template v-else> | ||||
|                                 <div | ||||
|                                     v-for="(group, groupIndex) in currentGroups" | ||||
|                                     :key="groupIndex" | ||||
|                                     class="mb-4" | ||||
|                                     @dragover.prevent="handleDragOver($event, groupIndex)" | ||||
|                                     @drop="handleDrop($event, groupIndex)" | ||||
|                                 > | ||||
|                                     <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-icon>mdi-delete</v-icon> | ||||
|                                         </v-btn> | ||||
|                                     </div> | ||||
| 
 | ||||
|                                     <div class="group-box pa-2"> | ||||
|                                         <div | ||||
|                                             v-for="(student, studentIndex) in group" | ||||
|                                             :key="student.username" | ||||
|                                             class="draggable-item ma-1" | ||||
|                                             draggable="true" | ||||
|                                             @dragstart="handleDragStart(groupIndex, studentIndex)" | ||||
|                                             @dragover.prevent="handleDragOver($event, groupIndex)" | ||||
|                                             @drop="handleDrop($event, groupIndex, studentIndex)" | ||||
|                                         > | ||||
|                                             <v-chip close @click:close="removeStudent(groupIndex, student)"> | ||||
|                                                 {{ student.fullName }} | ||||
|                                             </v-chip> | ||||
|                                         </div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                             </template> | ||||
|                         </v-col> | ||||
| 
 | ||||
|                         <!-- Unassigned Students Column --> | ||||
|                         <v-col | ||||
|                             cols="12" | ||||
|                             md="4" | ||||
|                             @dragover.prevent="handleDragOver($event, -1)" | ||||
|                             @drop="handleDrop($event, -1)" | ||||
|                         > | ||||
|                             <div class="mb-2"> | ||||
|                                 <strong>{{ t("unassigned") }}</strong> | ||||
|                                 <span class="text-caption ml-2">({{ unassignedStudents.length }})</span> | ||||
|                             </div> | ||||
| 
 | ||||
|                             <div class="group-box pa-2"> | ||||
|                                 <div | ||||
|                                     v-for="(student, studentIndex) in unassignedStudents" | ||||
|                                     :key="student.username" | ||||
|                                     class="draggable-item ma-1" | ||||
|                                     draggable="true" | ||||
|                                     @dragstart="handleDragStart(-1, studentIndex)" | ||||
|                                     @dragover.prevent="handleDragOver($event, -1)" | ||||
|                                     @drop="handleDrop($event, -1, studentIndex)" | ||||
|                                 > | ||||
|                                     <v-chip>{{ student.fullName }}</v-chip> | ||||
|                                 </div> | ||||
|                             </div> | ||||
|                         </v-col> | ||||
|                     </v-row> | ||||
|                 </v-card-text> | ||||
| 
 | ||||
|                 <v-card-actions> | ||||
|                     <v-spacer /> | ||||
|                     <v-btn text @click="activeDialog = null">{{ t("cancel") }}</v-btn> | ||||
|                     <v-btn | ||||
|                         color="primary" | ||||
|                         @click="saveDragDrop" | ||||
|                         :disabled="unassignedStudents.length > 0" | ||||
|                     > | ||||
|                         {{ t("save") }} | ||||
|                     </v-btn> | ||||
|                 </v-card-actions> | ||||
|             </v-card> | ||||
|         </v-dialog> | ||||
|     </v-card> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| .group-box { | ||||
|     min-height: 100px; | ||||
|     border: 1px dashed #ccc; | ||||
|     padding: 8px; | ||||
|     margin-bottom: 16px; | ||||
|     .group-box { | ||||
|         min-height: 150px; | ||||
|         max-height: 300px; | ||||
|         overflow-y: auto; | ||||
|         background-color: #fafafa; | ||||
| } | ||||
|         border-radius: 4px; | ||||
|     } | ||||
| 
 | ||||
|     .v-expansion-panel-text { | ||||
|         max-height: 200px; | ||||
|         overflow-y: auto; | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,51 +1,51 @@ | |||
| <script setup lang="ts"> | ||||
| import { useI18n } from "vue-i18n"; | ||||
| import { computed, onMounted, ref, watch } from "vue"; | ||||
| import { assignmentTitleRules, classRules, learningPathRules } from "@/utils/assignment-rules.ts"; | ||||
| 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 { assignmentTitleRules, classRules, learningPathRules } from "@/utils/assignment-rules.ts"; | ||||
|     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 () => { | ||||
|     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 language = computed(() => locale.value); | ||||
|     const form = ref(); | ||||
| 
 | ||||
| const learningPathsQueryResults = useGetAllLearningPaths(language); | ||||
| const classesQueryResults = useTeacherClassesQuery(username, true); | ||||
|     const learningPathsQueryResults = useGetAllLearningPaths(language); | ||||
|     const classesQueryResults = useTeacherClassesQuery(username, true); | ||||
| 
 | ||||
| const selectedClass = ref(undefined); | ||||
| const assignmentTitle = ref(""); | ||||
| const selectedLearningPath = ref(route.query.hruid || undefined); | ||||
| const lpIsSelected = route.query.hruid !== undefined; | ||||
|     const selectedClass = ref(undefined); | ||||
|     const assignmentTitle = ref(""); | ||||
|     const selectedLearningPath = ref(route.query.hruid || undefined); | ||||
|     const lpIsSelected = route.query.hruid !== undefined; | ||||
| 
 | ||||
| const { mutate, data, isSuccess } = useCreateAssignmentMutation(); | ||||
|     const { mutate, data, isSuccess } = useCreateAssignmentMutation(); | ||||
| 
 | ||||
| watch([isSuccess, data], async ([success, newData]) => { | ||||
|     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> { | ||||
|     async function submitFormHandler(): Promise<void> { | ||||
|         const { valid } = await form.value.validate(); | ||||
|         if (!valid) return; | ||||
| 
 | ||||
|  | @ -66,7 +66,7 @@ async function submitFormHandler(): Promise<void> { | |||
|         }; | ||||
| 
 | ||||
|         mutate({ cid: assignmentDTO.within, data: assignmentDTO }); | ||||
| } | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -74,9 +74,13 @@ async function submitFormHandler(): Promise<void> { | |||
|         <h1 class="h1">{{ t("new-assignment") }}</h1> | ||||
| 
 | ||||
|         <v-card class="form-card elevation-2 pa-6"> | ||||
|             <v-form ref="form" class="form-container" validate-on="submit lazy" @submit.prevent="submitFormHandler"> | ||||
|             <v-form | ||||
|                 ref="form" | ||||
|                 class="form-container" | ||||
|                 validate-on="submit lazy" | ||||
|                 @submit.prevent="submitFormHandler" | ||||
|             > | ||||
|                 <v-container class="step-container pa-0"> | ||||
| 
 | ||||
|                     <!-- Titel veld --> | ||||
|                     <v-text-field | ||||
|                         v-model="assignmentTitle" | ||||
|  | @ -90,7 +94,10 @@ async function submitFormHandler(): Promise<void> { | |||
|                     /> | ||||
| 
 | ||||
|                     <!-- Learning Path keuze --> | ||||
|                     <using-query-result :query-result="learningPathsQueryResults" v-slot="{ data }: { data: LearningPath[] }"> | ||||
|                     <using-query-result | ||||
|                         :query-result="learningPathsQueryResults" | ||||
|                         v-slot="{ data }: { data: LearningPath[] }" | ||||
|                     > | ||||
|                         <v-combobox | ||||
|                             v-model="selectedLearningPath" | ||||
|                             :items="data" | ||||
|  | @ -111,7 +118,10 @@ async function submitFormHandler(): Promise<void> { | |||
|                     </using-query-result> | ||||
| 
 | ||||
|                     <!-- Klas keuze --> | ||||
|                     <using-query-result :query-result="classesQueryResults" v-slot="{ data }: { data: ClassesResponse }"> | ||||
|                     <using-query-result | ||||
|                         :query-result="classesQueryResults" | ||||
|                         v-slot="{ data }: { data: ClassesResponse }" | ||||
|                     > | ||||
|                         <v-combobox | ||||
|                             v-model="selectedClass" | ||||
|                             :items="data?.classes ?? []" | ||||
|  | @ -153,7 +163,6 @@ async function submitFormHandler(): Promise<void> { | |||
|                             {{ t("cancel") }} | ||||
|                         </v-btn> | ||||
|                     </div> | ||||
| 
 | ||||
|                 </v-container> | ||||
|             </v-form> | ||||
|         </v-card> | ||||
|  | @ -161,65 +170,59 @@ async function submitFormHandler(): Promise<void> { | |||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| .main-container { | ||||
|     .main-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: center; | ||||
|         justify-content: start; | ||||
|         padding-top: 32px; | ||||
|         text-align: center; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .form-card { | ||||
|     .form-card { | ||||
|         width: 100%; | ||||
|         max-width: 720px; | ||||
|         border-radius: 16px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .form-container { | ||||
|     .form-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 24px; | ||||
|         width: 100%; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .step-container { | ||||
|     .step-container { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 24px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| @media (max-width: 1000px) { | ||||
|     @media (max-width: 1000px) { | ||||
|         .form-card { | ||||
|             width: 85%; | ||||
|             padding: 1%; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| @media (max-width: 600px) { | ||||
| 
 | ||||
|     @media (max-width: 600px) { | ||||
|         h1 { | ||||
|             font-size: 32px; | ||||
|             text-align: center; | ||||
|             margin-left: 0; | ||||
|         } | ||||
| } | ||||
| 
 | ||||
| @media (max-width: 400px) { | ||||
|     } | ||||
| 
 | ||||
|     @media (max-width: 400px) { | ||||
|         h1 { | ||||
|             font-size: 24px; | ||||
|             text-align: center; | ||||
|             margin-left: 0; | ||||
|         } | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .v-card { | ||||
|     .v-card { | ||||
|         border: 2px solid #0e6942; | ||||
|         border-radius: 12px; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| </style> | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,37 +1,36 @@ | |||
| <script setup lang="ts"> | ||||
| import {ref, computed, watchEffect} from "vue"; | ||||
| import auth from "@/services/auth/auth-service.ts"; | ||||
| import {useI18n} from "vue-i18n"; | ||||
| import {useAssignmentQuery} from "@/queries/assignments.ts"; | ||||
| import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
| import type {AssignmentResponse} from "@/controllers/assignments.ts"; | ||||
| import {asyncComputed} from "@vueuse/core"; | ||||
| import {useStudentsByUsernamesQuery} from "@/queries/students.ts"; | ||||
| import {useGroupsQuery} from "@/queries/groups.ts"; | ||||
| import {useGetLearningPathQuery} from "@/queries/learning-paths.ts"; | ||||
| import type {Language} from "@/data-objects/language.ts"; | ||||
| import {calculateProgress} from "@/utils/assignment-utils.ts"; | ||||
|     import { ref, computed, watchEffect } from "vue"; | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useAssignmentQuery } from "@/queries/assignments.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import type { AssignmentResponse } from "@/controllers/assignments.ts"; | ||||
|     import { asyncComputed } from "@vueuse/core"; | ||||
|     import { useStudentsByUsernamesQuery } from "@/queries/students.ts"; | ||||
|     import { useGroupsQuery } from "@/queries/groups.ts"; | ||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import type { Language } from "@/data-objects/language.ts"; | ||||
|     import { calculateProgress } from "@/utils/assignment-utils.ts"; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|     const props = defineProps<{ | ||||
|         classId: string; | ||||
|         assignmentId: number; | ||||
| }>(); | ||||
|     }>(); | ||||
| 
 | ||||
| const {t} = useI18n(); | ||||
| const lang = ref(); | ||||
| const learningPath = ref(); | ||||
| // Get the user's username/id | ||||
| const username = asyncComputed(async () => { | ||||
|     const { t } = useI18n(); | ||||
|     const lang = ref(); | ||||
|     const learningPath = ref(); | ||||
|     // Get the user's username/id | ||||
|     const username = asyncComputed(async () => { | ||||
|         const user = await auth.loadUser(); | ||||
|         return user?.profile?.preferred_username ?? undefined; | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); | ||||
| learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath; | ||||
|     const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); | ||||
|     learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath; | ||||
| 
 | ||||
| 
 | ||||
| const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); | ||||
| const group = computed(() => { | ||||
|     const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); | ||||
|     const group = computed(() => { | ||||
|         const groups = groupsQueryResult.data.value?.groups; | ||||
| 
 | ||||
|         if (!groups) return undefined; | ||||
|  | @ -45,15 +44,14 @@ const group = computed(() => { | |||
|                 groupNo: index + 1, // Renumbered index | ||||
|             })) | ||||
|             .find((group) => group.members?.some((m) => m.username === username.value)); | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
| watchEffect(() => { | ||||
|     watchEffect(() => { | ||||
|         learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath; | ||||
|         lang.value = assignmentQueryResult.data.value?.assignment?.language as Language; | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| const learningPathParams = computed(() => { | ||||
|     const learningPathParams = computed(() => { | ||||
|         if (!group.value || !learningPath.value || !lang.value) return undefined; | ||||
| 
 | ||||
|         return { | ||||
|  | @ -61,30 +59,29 @@ const learningPathParams = computed(() => { | |||
|             assignmentNo: props.assignmentId, | ||||
|             classId: props.classId, | ||||
|         }; | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| const lpQueryResult = useGetLearningPathQuery( | ||||
|     const lpQueryResult = useGetLearningPathQuery( | ||||
|         () => learningPath.value, | ||||
|         () => lang.value, | ||||
|     () => learningPathParams.value | ||||
| ); | ||||
|         () => learningPathParams.value, | ||||
|     ); | ||||
| 
 | ||||
| 
 | ||||
| const progressColor = computed(() => { | ||||
|     const progressColor = computed(() => { | ||||
|         const progress = calculateProgress(lpQueryResult.data.value); | ||||
|         if (progress >= 100) return "success"; | ||||
|         if (progress >= 50) return "warning"; | ||||
|         return "error"; | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| const studentQueries = useStudentsByUsernamesQuery(() => group.value?.members as string[] ?? undefined); | ||||
|     const studentQueries = useStudentsByUsernamesQuery(() => (group.value?.members as string[]) ?? undefined); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|     <div class="container"> | ||||
|         <using-query-result | ||||
|             :query-result="assignmentQueryResult" | ||||
|             v-slot="assignmentResponse : { data: AssignmentResponse }" | ||||
|             v-slot="assignmentResponse: { data: AssignmentResponse }" | ||||
|         > | ||||
|             <v-card | ||||
|                 v-if="assignmentResponse" | ||||
|  | @ -100,9 +97,8 @@ const studentQueries = useStudentsByUsernamesQuery(() => group.value?.members as | |||
|                         <v-icon>mdi-arrow-left</v-icon> | ||||
|                     </v-btn> | ||||
|                 </div> | ||||
|                 <v-card-title class="text-h4 assignmentTopTitle">{{ | ||||
|                         assignmentResponse.data.assignment.title | ||||
|                     }} | ||||
|                 <v-card-title class="text-h4 assignmentTopTitle" | ||||
|                     >{{ assignmentResponse.data.assignment.title }} | ||||
|                 </v-card-title> | ||||
| 
 | ||||
|                 <v-card-subtitle class="subtitle-section"> | ||||
|  | @ -112,14 +108,17 @@ const studentQueries = useStudentsByUsernamesQuery(() => group.value?.members as | |||
|                     > | ||||
|                         <v-btn | ||||
|                             v-if="lpData" | ||||
|                             :to="group ? `/learningPath/${lpData.hruid}/${assignmentResponse.data.assignment?.language}/${lpData.startNode.learningobjectHruid}?forGroup=${0}&assignmentNo=${assignmentId}&classId=${classId}` : undefined" | ||||
|                             :to=" | ||||
|                                 group | ||||
|                                     ? `/learningPath/${lpData.hruid}/${assignmentResponse.data.assignment?.language}/${lpData.startNode.learningobjectHruid}?forGroup=${0}&assignmentNo=${assignmentId}&classId=${classId}` | ||||
|                                     : undefined | ||||
|                             " | ||||
|                             :disabled="!group" | ||||
|                             variant="tonal" | ||||
|                             color="primary" | ||||
|                         > | ||||
|                             {{ t("learning-path") }} | ||||
|                         </v-btn> | ||||
| 
 | ||||
|                     </using-query-result> | ||||
|                 </v-card-subtitle> | ||||
| 
 | ||||
|  | @ -163,21 +162,23 @@ const studentQueries = useStudentsByUsernamesQuery(() => group.value?.members as | |||
|                     </div> | ||||
| 
 | ||||
|                     <div v-else> | ||||
|                         <v-alert type="info" variant="text"> | ||||
|                         <v-alert | ||||
|                             type="info" | ||||
|                             variant="text" | ||||
|                         > | ||||
|                             {{ t("not-in-group-message") }} | ||||
|                         </v-alert> | ||||
|                     </div> | ||||
|                 </v-card-text> | ||||
| 
 | ||||
|             </v-card> | ||||
|         </using-query-result> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| @import "@/assets/assignment.css"; | ||||
|     @import "@/assets/assignment.css"; | ||||
| 
 | ||||
| .progress-bar { | ||||
|     .progress-bar { | ||||
|         width: 40%; | ||||
| } | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,25 +1,25 @@ | |||
| <script setup lang="ts"> | ||||
| import {computed, type Ref, ref, watch, watchEffect} from "vue"; | ||||
| import {useI18n} from "vue-i18n"; | ||||
| import { | ||||
|     import { computed, type Ref, ref, watch, watchEffect } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { | ||||
|         useAssignmentQuery, | ||||
|         useDeleteAssignmentMutation, | ||||
|     useUpdateAssignmentMutation | ||||
| } from "@/queries/assignments.ts"; | ||||
| import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
| import {useGroupsQuery} from "@/queries/groups.ts"; | ||||
| import {useGetAllLearningPaths, useGetLearningPathQuery} from "@/queries/learning-paths.ts"; | ||||
| import type {Language} from "@/data-objects/language.ts"; | ||||
| import type {AssignmentResponse} from "@/controllers/assignments.ts"; | ||||
| import type {GroupDTO, GroupDTOId} from "@dwengo-1/common/interfaces/group"; | ||||
| import type {LearningPath} from "@/data-objects/learning-paths/learning-path"; | ||||
| import {descriptionRules, learningPathRules} from "@/utils/assignment-rules.ts"; | ||||
| import GroupSubmissionStatus from "@/components/GroupSubmissionStatus.vue" | ||||
| import GroupProgressRow from "@/components/GroupProgressRow.vue" | ||||
| import type {AssignmentDTO} from "@dwengo-1/common/dist/interfaces/assignment.ts"; | ||||
| import GroupSelector from "@/components/assignments/GroupSelector.vue"; | ||||
|         useUpdateAssignmentMutation, | ||||
|     } from "@/queries/assignments.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { useGroupsQuery } from "@/queries/groups.ts"; | ||||
|     import { useGetAllLearningPaths, useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||
|     import type { Language } from "@/data-objects/language.ts"; | ||||
|     import type { AssignmentResponse } from "@/controllers/assignments.ts"; | ||||
|     import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group"; | ||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path"; | ||||
|     import { descriptionRules, learningPathRules } from "@/utils/assignment-rules.ts"; | ||||
|     import GroupSubmissionStatus from "@/components/GroupSubmissionStatus.vue"; | ||||
|     import GroupProgressRow from "@/components/GroupProgressRow.vue"; | ||||
|     import type { AssignmentDTO } from "@dwengo-1/common/dist/interfaces/assignment.ts"; | ||||
|     import GroupSelector from "@/components/assignments/GroupSelector.vue"; | ||||
| 
 | ||||
| const props = defineProps<{ | ||||
|     const props = defineProps<{ | ||||
|         classId: string; | ||||
|         assignmentId: number; | ||||
|         useGroupsWithProgress: ( | ||||
|  | @ -27,39 +27,37 @@ const props = defineProps<{ | |||
|             hruid: Ref<string>, | ||||
|             language: Ref<Language>, | ||||
|         ) => { groupProgressMap: Map<number, number> }; | ||||
| }>(); | ||||
|     }>(); | ||||
| 
 | ||||
| const isEditing = ref(false); | ||||
|     const isEditing = ref(false); | ||||
| 
 | ||||
| const {t} = useI18n(); | ||||
| const lang = ref(); | ||||
| const groups = ref<GroupDTO[] | GroupDTOId[]>([]); | ||||
| const learningPath = ref(); | ||||
| const form = ref(); | ||||
|     const { t } = useI18n(); | ||||
|     const lang = ref(); | ||||
|     const groups = ref<GroupDTO[] | GroupDTOId[]>([]); | ||||
|     const learningPath = ref(); | ||||
|     const form = ref(); | ||||
| 
 | ||||
|     const editingLearningPath = ref(learningPath); | ||||
|     const description = ref(""); | ||||
|     const editGroups = ref(false); | ||||
| 
 | ||||
| const editingLearningPath = ref(learningPath); | ||||
| const description = ref(""); | ||||
| const editGroups = ref(false); | ||||
| 
 | ||||
| 
 | ||||
| const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); | ||||
| // Get learning path object | ||||
| const lpQueryResult = useGetLearningPathQuery( | ||||
|     const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); | ||||
|     // Get learning path object | ||||
|     const lpQueryResult = useGetLearningPathQuery( | ||||
|         computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), | ||||
|         computed(() => assignmentQueryResult.data.value?.assignment?.language as Language), | ||||
| ); | ||||
|     ); | ||||
| 
 | ||||
| // Get all the groups withing the assignment | ||||
| const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); | ||||
| groups.value = groupsQueryResult.data.value?.groups ?? []; | ||||
|     // Get all the groups withing the assignment | ||||
|     const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); | ||||
|     groups.value = groupsQueryResult.data.value?.groups ?? []; | ||||
| 
 | ||||
| watchEffect(() => { | ||||
|     watchEffect(() => { | ||||
|         learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath; | ||||
|         lang.value = assignmentQueryResult.data.value?.assignment?.language as Language; | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| const allGroups = computed(() => { | ||||
|     const allGroups = computed(() => { | ||||
|         const groups = groupsQueryResult.data.value?.groups; | ||||
| 
 | ||||
|         if (!groups) return []; | ||||
|  | @ -74,56 +72,56 @@ const allGroups = computed(() => { | |||
|             members: group.members, | ||||
|             originalGroupNo: group.groupNumber, // Keep original number if needed | ||||
|         })); | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| const dialog = ref(false); | ||||
| const selectedGroup = ref({}); | ||||
|     const dialog = ref(false); | ||||
|     const selectedGroup = ref({}); | ||||
| 
 | ||||
| function openGroupDetails(group): void { | ||||
|     function openGroupDetails(group): void { | ||||
|         selectedGroup.value = group; | ||||
|         dialog.value = true; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| async function deleteAssignment(num: number, clsId: string): Promise<void> { | ||||
|     const {mutate} = useDeleteAssignmentMutation(); | ||||
|     async function deleteAssignment(num: number, clsId: string): Promise<void> { | ||||
|         const { mutate } = useDeleteAssignmentMutation(); | ||||
|         mutate( | ||||
|         {cid: clsId, an: num}, | ||||
|             { cid: clsId, an: num }, | ||||
|             { | ||||
|                 onSuccess: () => { | ||||
|                     window.location.href = "/user/assignment"; | ||||
|                 }, | ||||
|             }, | ||||
|         ); | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| function goToLearningPathLink(): string | undefined { | ||||
|     function goToLearningPathLink(): string | undefined { | ||||
|         const assignment = assignmentQueryResult.data.value?.assignment; | ||||
|         const lp = lpQueryResult.data.value; | ||||
| 
 | ||||
|         if (!assignment || !lp) return undefined; | ||||
| 
 | ||||
|         return `/learningPath/${lp.hruid}/${assignment.language}/${lp.startNode.learningobjectHruid}?assignmentNo=${props.assignmentId}&classId=${props.classId}`; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| function goToGroupSubmissionLink(groupNo: number): string | undefined { | ||||
|     function goToGroupSubmissionLink(groupNo: number): string | undefined { | ||||
|         const lp = lpQueryResult.data.value; | ||||
|         if (!lp) return undefined; | ||||
| 
 | ||||
|         return `/learningPath/${lp.hruid}/${lp.language}/${lp.startNode.learningobjectHruid}?forGroup=${groupNo}&assignmentNo=${props.assignmentId}&classId=${props.classId}`; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| const learningPathsQueryResults = useGetAllLearningPaths(lang); | ||||
|     const learningPathsQueryResults = useGetAllLearningPaths(lang); | ||||
| 
 | ||||
| const {mutate, data, isSuccess} = useUpdateAssignmentMutation(); | ||||
|     const { mutate, data, isSuccess } = useUpdateAssignmentMutation(); | ||||
| 
 | ||||
| watch([isSuccess, data], ([success, newData]) => { | ||||
|     watch([isSuccess, data], ([success, newData]) => { | ||||
|         if (success && newData?.assignment) { | ||||
|             window.location.reload(); | ||||
|         } | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| async function saveChanges(): Promise<void> { | ||||
|     const {valid} = await form.value.validate(); | ||||
|     async function saveChanges(): Promise<void> { | ||||
|         const { valid } = await form.value.validate(); | ||||
|         if (!valid) return; | ||||
| 
 | ||||
|         isEditing.value = false; | ||||
|  | @ -140,11 +138,9 @@ async function saveChanges(): Promise<void> { | |||
|         mutate({ | ||||
|             cid: assignmentQueryResult.data.value?.assignment.within, | ||||
|             an: assignmentQueryResult.data.value?.assignment.id, | ||||
|         data: assignmentDTO | ||||
|             data: assignmentDTO, | ||||
|         }); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
|     } | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -167,7 +163,11 @@ async function saveChanges(): Promise<void> { | |||
|                         md="6" | ||||
|                         class="responsive-col" | ||||
|                     > | ||||
|                         <v-form ref="form" validate-on="submit lazy" @submit.prevent="saveChanges"> | ||||
|                         <v-form | ||||
|                             ref="form" | ||||
|                             validate-on="submit lazy" | ||||
|                             @submit.prevent="saveChanges" | ||||
|                         > | ||||
|                             <v-card | ||||
|                                 v-if="assignmentResponse" | ||||
|                                 class="assignment-card" | ||||
|  | @ -201,10 +201,14 @@ async function saveChanges(): Promise<void> { | |||
|                                                 v-else | ||||
|                                                 variant="text" | ||||
|                                                 class="top-right-btn" | ||||
|                                                 @click="() => {isEditing = false; editingLearningPath=learningPath}" | ||||
|                                                 @click=" | ||||
|                                                     () => { | ||||
|                                                         isEditing = false; | ||||
|                                                         editingLearningPath = learningPath; | ||||
|                                                     } | ||||
|                                                 " | ||||
|                                                 >{{ t("cancel") }} | ||||
|                                             </v-btn | ||||
|                                             > | ||||
|                                             </v-btn> | ||||
| 
 | ||||
|                                             <v-btn | ||||
|                                                 v-if="!isEditing" | ||||
|  | @ -233,9 +237,8 @@ async function saveChanges(): Promise<void> { | |||
|                                     </div> | ||||
|                                 </div> | ||||
| 
 | ||||
|                                 <v-card-title class="text-h4 assignmentTopTitle">{{ | ||||
|                                         assignmentResponse.data.assignment.title | ||||
|                                     }} | ||||
|                                 <v-card-title class="text-h4 assignmentTopTitle" | ||||
|                                     >{{ assignmentResponse.data.assignment.title }} | ||||
|                                 </v-card-title> | ||||
|                                 <v-card-subtitle | ||||
|                                     v-if="!isEditing" | ||||
|  | @ -328,15 +331,13 @@ async function saveChanges(): Promise<void> { | |||
|                                         color="primary" | ||||
|                                         @click="dialog = false" | ||||
|                                         >Close | ||||
|                                     </v-btn | ||||
|                                     > | ||||
|                                     </v-btn> | ||||
|                                 </v-card-actions> | ||||
|                             </v-card> | ||||
|                         </v-dialog> | ||||
|                     </v-col> | ||||
| 
 | ||||
|                     <!-- The second column of the screen --> | ||||
|                     <template v-if="!editGroups"> | ||||
|                     <v-col | ||||
|                         cols="12" | ||||
|                         sm="6" | ||||
|  | @ -356,7 +357,6 @@ async function saveChanges(): Promise<void> { | |||
|                                         > | ||||
|                                             <v-icon>mdi-pencil</v-icon> | ||||
|                                         </v-btn> | ||||
| 
 | ||||
|                                     </th> | ||||
|                                 </tr> | ||||
|                             </thead> | ||||
|  | @ -403,99 +403,104 @@ async function saveChanges(): Promise<void> { | |||
|                                         > | ||||
|                                             <v-icon color="red">mdi-delete</v-icon> | ||||
|                                         </v-btn> | ||||
| 
 | ||||
|                                     </td> | ||||
|                                 </tr> | ||||
|                             </tbody> | ||||
|                         </v-table> | ||||
|                     </v-col> | ||||
|                     </template> | ||||
|                     <template v-else> | ||||
|                 </v-row> | ||||
|                 <v-dialog | ||||
|                     v-model="editGroups" | ||||
|                     max-width="800" | ||||
|                     persistent | ||||
|                 > | ||||
|                     <v-card-text> | ||||
|                         <GroupSelector | ||||
|                             :groups="allGroups" | ||||
|                             :class-id="classId" | ||||
|                             @groupsUpdated="handleUpdatedGroups" | ||||
|                             :class-id="props.classId" | ||||
|                             :assignment-id="props.assignmentId" | ||||
|                             @close="editGroups = false" | ||||
|                         /> | ||||
|                     </template> | ||||
|                 </v-row> | ||||
|                     </v-card-text> | ||||
|                 </v-dialog> | ||||
|             </v-container> | ||||
|         </using-query-result> | ||||
|     </div> | ||||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| @import "@/assets/assignment.css"; | ||||
|     @import "@/assets/assignment.css"; | ||||
| 
 | ||||
| .table-scroll { | ||||
|     .table-scroll { | ||||
|         overflow-x: auto; | ||||
|         -webkit-overflow-scrolling: touch; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .header { | ||||
|     .header { | ||||
|         font-weight: bold !important; | ||||
|         background-color: #0e6942; | ||||
|         color: white; | ||||
|         padding: 10px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| table thead th:first-child { | ||||
|     table thead th:first-child { | ||||
|         border-top-left-radius: 10px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .table thead th:last-child { | ||||
|     .table thead th:last-child { | ||||
|         border-top-right-radius: 10px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .table tbody tr:nth-child(odd) { | ||||
|     .table tbody tr:nth-child(odd) { | ||||
|         background-color: white; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .table tbody tr:nth-child(even) { | ||||
|     .table tbody tr:nth-child(even) { | ||||
|         background-color: #f6faf2; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| td, | ||||
| th { | ||||
|     td, | ||||
|     th { | ||||
|         border-bottom: 1px solid #0e6942; | ||||
|         border-top: 1px solid #0e6942; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .table { | ||||
|     .table { | ||||
|         width: 90%; | ||||
|         padding-top: 10px; | ||||
|         border-collapse: collapse; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| h1 { | ||||
|     h1 { | ||||
|         color: #0e6942; | ||||
|         text-transform: uppercase; | ||||
|         font-weight: bolder; | ||||
|         padding-top: 2%; | ||||
|         font-size: 50px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| h2 { | ||||
|     h2 { | ||||
|         color: #0e6942; | ||||
|         font-size: 30px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .join { | ||||
|     .join { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         gap: 20px; | ||||
|         margin-top: 50px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .link { | ||||
|     .link { | ||||
|         color: #0b75bb; | ||||
|         text-decoration: underline; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| main { | ||||
|     main { | ||||
|         margin-left: 30px; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| @media screen and (max-width: 850px) { | ||||
|     @media screen and (max-width: 850px) { | ||||
|         h1 { | ||||
|             text-align: center; | ||||
|             padding-left: 0; | ||||
|  | @ -531,5 +536,5 @@ main { | |||
|             max-width: 100% !important; | ||||
|             flex-basis: 100% !important; | ||||
|         } | ||||
| } | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
|  | @ -1,26 +1,26 @@ | |||
| <script setup lang="ts"> | ||||
| import {ref, computed, onMounted, watch} from "vue"; | ||||
| import {useI18n} from "vue-i18n"; | ||||
| import {useRouter} from "vue-router"; | ||||
| import authState from "@/services/auth/auth-service.ts"; | ||||
| import auth from "@/services/auth/auth-service.ts"; | ||||
| import {useTeacherAssignmentsQuery, useTeacherClassesQuery} from "@/queries/teachers.ts"; | ||||
| import {useStudentAssignmentsQuery, useStudentClassesQuery} from "@/queries/students.ts"; | ||||
| import {useDeleteAssignmentMutation} from "@/queries/assignments.ts"; | ||||
| import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
| import {asyncComputed} from "@vueuse/core"; | ||||
|     import { ref, computed, onMounted, watch } from "vue"; | ||||
|     import { useI18n } from "vue-i18n"; | ||||
|     import { useRouter } from "vue-router"; | ||||
|     import authState from "@/services/auth/auth-service.ts"; | ||||
|     import auth from "@/services/auth/auth-service.ts"; | ||||
|     import { useTeacherAssignmentsQuery, useTeacherClassesQuery } from "@/queries/teachers.ts"; | ||||
|     import { useStudentAssignmentsQuery, useStudentClassesQuery } from "@/queries/students.ts"; | ||||
|     import { useDeleteAssignmentMutation } from "@/queries/assignments.ts"; | ||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||
|     import { asyncComputed } from "@vueuse/core"; | ||||
| 
 | ||||
| const {t, locale} = useI18n(); | ||||
| const router = useRouter(); | ||||
|     const { t, locale } = useI18n(); | ||||
|     const router = useRouter(); | ||||
| 
 | ||||
| const role = ref(auth.authState.activeRole); | ||||
| const username = ref<string | undefined>(undefined); | ||||
| const isLoading = ref(false); | ||||
| const isError = ref(false); | ||||
| const errorMessage = ref<string>(""); | ||||
|     const role = ref(auth.authState.activeRole); | ||||
|     const username = ref<string | undefined>(undefined); | ||||
|     const isLoading = ref(false); | ||||
|     const isError = ref(false); | ||||
|     const errorMessage = ref<string>(""); | ||||
| 
 | ||||
| // Load current user before rendering the page | ||||
| onMounted(async () => { | ||||
|     // Load current user before rendering the page | ||||
|     onMounted(async () => { | ||||
|         isLoading.value = true; | ||||
|         try { | ||||
|             const userObject = await authState.loadUser(); | ||||
|  | @ -31,14 +31,18 @@ onMounted(async () => { | |||
|         } finally { | ||||
|             isLoading.value = false; | ||||
|         } | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| const isTeacher = computed(() => role.value === "teacher"); | ||||
| const classesQueryResult = isTeacher.value ? useTeacherClassesQuery(username, true) : useStudentClassesQuery(username, true); | ||||
|     const isTeacher = computed(() => role.value === "teacher"); | ||||
|     const classesQueryResult = isTeacher.value | ||||
|         ? useTeacherClassesQuery(username, true) | ||||
|         : useStudentClassesQuery(username, true); | ||||
| 
 | ||||
| const assignmentsQueryResult = isTeacher.value ? useTeacherAssignmentsQuery(username, true) : useStudentAssignmentsQuery(username, true); | ||||
|     const assignmentsQueryResult = isTeacher.value | ||||
|         ? useTeacherAssignmentsQuery(username, true) | ||||
|         : useStudentAssignmentsQuery(username, true); | ||||
| 
 | ||||
| const allAssignments = asyncComputed( | ||||
|     const allAssignments = asyncComputed( | ||||
|         async () => { | ||||
|             const assignments = assignmentsQueryResult.data.value?.assignments; | ||||
|             if (!assignments) return []; | ||||
|  | @ -73,30 +77,30 @@ const allAssignments = asyncComputed( | |||
|             }); | ||||
|         }, | ||||
|         [], | ||||
|     {evaluating: true}, | ||||
| ); | ||||
|         { evaluating: true }, | ||||
|     ); | ||||
| 
 | ||||
| async function goToCreateAssignment(): Promise<void> { | ||||
|     async function goToCreateAssignment(): Promise<void> { | ||||
|         await router.push("/assignment/create"); | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| async function goToAssignmentDetails(id: number, clsId: string): Promise<void> { | ||||
|     async function goToAssignmentDetails(id: number, clsId: string): Promise<void> { | ||||
|         await router.push(`/assignment/${clsId}/${id}`); | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| const {mutate, data, isSuccess} = useDeleteAssignmentMutation(); | ||||
|     const { mutate, data, isSuccess } = useDeleteAssignmentMutation(); | ||||
| 
 | ||||
| watch([isSuccess, data], async ([success, oldData]) => { | ||||
|     watch([isSuccess, data], async ([success, oldData]) => { | ||||
|         if (success && oldData?.assignment) { | ||||
|             window.location.reload(); | ||||
|         } | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| async function goToDeleteAssignment(num: number, clsId: string): Promise<void> { | ||||
|     mutate({cid: clsId, an: num}); | ||||
| } | ||||
|     async function goToDeleteAssignment(num: number, clsId: string): Promise<void> { | ||||
|         mutate({ cid: clsId, an: num }); | ||||
|     } | ||||
| 
 | ||||
| function formatDate(date?: string | Date): string { | ||||
|     function formatDate(date?: string | Date): string { | ||||
|         if (!date) return "–"; | ||||
|         const d = new Date(date); | ||||
| 
 | ||||
|  | @ -111,9 +115,9 @@ function formatDate(date?: string | Date): string { | |||
|             hour: "numeric", | ||||
|             minute: "2-digit", | ||||
|         }); | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| function getDeadlineClass(deadline?: string | Date): string { | ||||
|     function getDeadlineClass(deadline?: string | Date): string { | ||||
|         if (!deadline) return ""; | ||||
| 
 | ||||
|         const date = new Date(deadline); | ||||
|  | @ -123,19 +127,17 @@ function getDeadlineClass(deadline?: string | Date): string { | |||
|         if (date.getTime() < now.getTime()) return "deadline-passed"; | ||||
|         if (date.getTime() <= in24Hours.getTime()) return "deadline-in24hours"; | ||||
|         return "deadline-upcoming"; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| onMounted(async () => { | ||||
|     onMounted(async () => { | ||||
|         const user = await auth.loadUser(); | ||||
|         username.value = user?.profile?.preferred_username ?? ""; | ||||
| }); | ||||
|     }); | ||||
| 
 | ||||
| onMounted(async () => { | ||||
|     onMounted(async () => { | ||||
|         const user = await auth.loadUser(); | ||||
|         username.value = user?.profile?.preferred_username ?? ""; | ||||
| }); | ||||
| 
 | ||||
| 
 | ||||
|     }); | ||||
| </script> | ||||
| 
 | ||||
| <template> | ||||
|  | @ -151,9 +153,7 @@ onMounted(async () => { | |||
|             {{ t("new-assignment") }} | ||||
|         </v-btn> | ||||
| 
 | ||||
|         <using-query-result | ||||
|             :query-result="assignmentsQueryResult" | ||||
|         > | ||||
|         <using-query-result :query-result="assignmentsQueryResult"> | ||||
|             <v-container> | ||||
|                 <v-row> | ||||
|                     <v-col | ||||
|  | @ -214,87 +214,88 @@ onMounted(async () => { | |||
| </template> | ||||
| 
 | ||||
| <style scoped> | ||||
| .assignments-container { | ||||
|     .assignments-container { | ||||
|         width: 100%; | ||||
|         margin: 0 auto; | ||||
|         box-sizing: border-box; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .center-btn { | ||||
|     .center-btn { | ||||
|         display: block; | ||||
|         margin: 0 auto 2rem auto; | ||||
|         font-weight: 600; | ||||
|         background-color: #10ad61; | ||||
|         color: white; | ||||
|         transition: background-color 0.2s; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .center-btn:hover { | ||||
|     .center-btn:hover { | ||||
|         background-color: #0e6942; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .assignment-card { | ||||
|     .assignment-card { | ||||
|         padding: 1.25rem; | ||||
|         border-radius: 16px; | ||||
|         box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); | ||||
|         background-color: white; | ||||
|     transition: transform 0.2s, | ||||
|         transition: | ||||
|             transform 0.2s, | ||||
|             box-shadow 0.2s; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .assignment-card:hover { | ||||
|     .assignment-card:hover { | ||||
|         box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .top-content { | ||||
|     .top-content { | ||||
|         margin-bottom: 1rem; | ||||
|         word-break: break-word; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .assignment-title { | ||||
|     .assignment-title { | ||||
|         font-weight: 700; | ||||
|         font-size: 1.4rem; | ||||
|         color: #0e6942; | ||||
|         margin-bottom: 0.3rem; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .assignment-class, | ||||
| .assignment-deadline { | ||||
|     .assignment-class, | ||||
|     .assignment-deadline { | ||||
|         font-size: 0.95rem; | ||||
|         color: #444; | ||||
|         margin-bottom: 0.2rem; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .class-name { | ||||
|     .class-name { | ||||
|         font-weight: 600; | ||||
|         color: #097180; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .assignment-deadline.deadline-passed { | ||||
|     .assignment-deadline.deadline-passed { | ||||
|         color: #d32f2f; | ||||
|         font-weight: bold; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .assignment-deadline.deadline-in24hours { | ||||
|     .assignment-deadline.deadline-in24hours { | ||||
|         color: #f57c00; | ||||
|         font-weight: bold; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .spacer { | ||||
|     .spacer { | ||||
|         flex: 1; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .button-row { | ||||
|     .button-row { | ||||
|         display: flex; | ||||
|         justify-content: flex-end; | ||||
|         gap: 0.75rem; | ||||
|         flex-wrap: wrap; | ||||
| } | ||||
|     } | ||||
| 
 | ||||
| .no-assignments { | ||||
|     .no-assignments { | ||||
|         text-align: center; | ||||
|         font-size: 1.2rem; | ||||
|         color: #777; | ||||
|         padding: 3rem 0; | ||||
| } | ||||
|     } | ||||
| </style> | ||||
|  |  | |||
							
								
								
									
										3606
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										3606
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana