Merge pull request #280 from SELab-2/feat/232-assignments-pagina-ui-ux
feat: Assignments UI/UX
This commit is contained in:
		
						commit
						a649713e15
					
				
					 42 changed files with 2241 additions and 767 deletions
				
			
		|  | @ -11,8 +11,7 @@ import { | ||||||
| import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; | import { AssignmentDTO } from '@dwengo-1/common/interfaces/assignment'; | ||||||
| import { requireFields } from './error-helper.js'; | import { requireFields } from './error-helper.js'; | ||||||
| import { BadRequestException } from '../exceptions/bad-request-exception.js'; | import { BadRequestException } from '../exceptions/bad-request-exception.js'; | ||||||
| import { Assignment } from '../entities/assignments/assignment.entity.js'; | import { FALLBACK_LANG } from '../config.js'; | ||||||
| import { EntityDTO } from '@mikro-orm/core'; |  | ||||||
| 
 | 
 | ||||||
| function getAssignmentParams(req: Request): { classid: string; assignmentNumber: number; full: boolean } { | function getAssignmentParams(req: Request): { classid: string; assignmentNumber: number; full: boolean } { | ||||||
|     const classid = req.params.classid; |     const classid = req.params.classid; | ||||||
|  | @ -38,14 +37,19 @@ export async function getAllAssignmentsHandler(req: Request, res: Response): Pro | ||||||
| 
 | 
 | ||||||
| export async function createAssignmentHandler(req: Request, res: Response): Promise<void> { | export async function createAssignmentHandler(req: Request, res: Response): Promise<void> { | ||||||
|     const classid = req.params.classid; |     const classid = req.params.classid; | ||||||
|     const description = req.body.description; |     const description = req.body.description || ''; | ||||||
|     const language = req.body.language; |     const language = req.body.language || FALLBACK_LANG; | ||||||
|     const learningPath = req.body.learningPath; |     const learningPath = req.body.learningPath || ''; | ||||||
|     const title = req.body.title; |     const title = req.body.title; | ||||||
| 
 | 
 | ||||||
|     requireFields({ description, language, learningPath, title }); |     requireFields({ title }); | ||||||
| 
 | 
 | ||||||
|     const assignmentData = req.body as AssignmentDTO; |     const assignmentData = { | ||||||
|  |         description: description, | ||||||
|  |         language: language, | ||||||
|  |         learningPath: learningPath, | ||||||
|  |         title: title, | ||||||
|  |     } as AssignmentDTO; | ||||||
|     const assignment = await createAssignment(classid, assignmentData); |     const assignment = await createAssignment(classid, assignmentData); | ||||||
| 
 | 
 | ||||||
|     res.json({ assignment }); |     res.json({ assignment }); | ||||||
|  | @ -62,7 +66,7 @@ export async function getAssignmentHandler(req: Request, res: Response): Promise | ||||||
| export async function putAssignmentHandler(req: Request, res: Response): Promise<void> { | export async function putAssignmentHandler(req: Request, res: Response): Promise<void> { | ||||||
|     const { classid, assignmentNumber } = getAssignmentParams(req); |     const { classid, assignmentNumber } = getAssignmentParams(req); | ||||||
| 
 | 
 | ||||||
|     const assignmentData = req.body as Partial<EntityDTO<Assignment>>; |     const assignmentData = req.body as Partial<AssignmentDTO>; | ||||||
|     const assignment = await putAssignment(classid, assignmentNumber, assignmentData); |     const assignment = await putAssignment(classid, assignmentNumber, assignmentData); | ||||||
| 
 | 
 | ||||||
|     res.json({ assignment }); |     res.json({ assignment }); | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ import { | ||||||
|     getJoinRequestsByClass, |     getJoinRequestsByClass, | ||||||
|     getStudentsByTeacher, |     getStudentsByTeacher, | ||||||
|     getTeacher, |     getTeacher, | ||||||
|  |     getTeacherAssignments, | ||||||
|     updateClassJoinRequestStatus, |     updateClassJoinRequestStatus, | ||||||
| } from '../services/teachers.js'; | } from '../services/teachers.js'; | ||||||
| import { requireFields } from './error-helper.js'; | import { requireFields } from './error-helper.js'; | ||||||
|  | @ -59,6 +60,16 @@ export async function getTeacherClassHandler(req: Request, res: Response): Promi | ||||||
|     res.json({ classes }); |     res.json({ classes }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export async function getTeacherAssignmentsHandler(req: Request, res: Response): Promise<void> { | ||||||
|  |     const username = req.params.username; | ||||||
|  |     const full = req.query.full === 'true'; | ||||||
|  |     requireFields({ username }); | ||||||
|  | 
 | ||||||
|  |     const assignments = await getTeacherAssignments(username, full); | ||||||
|  | 
 | ||||||
|  |     res.json({ assignments }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> { | export async function getTeacherStudentHandler(req: Request, res: Response): Promise<void> { | ||||||
|     const username = req.params.username; |     const username = req.params.username; | ||||||
|     const full = req.query.full === 'true'; |     const full = req.query.full === 'true'; | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | ||||||
|         return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] }); |         return this.findOne({ within: within, id: id }, { populate: ['groups', 'groups.members'] }); | ||||||
|     } |     } | ||||||
|     public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise<Assignment | null> { |     public async findByClassIdAndAssignmentId(withinClass: string, id: number): Promise<Assignment | null> { | ||||||
|         return this.findOne({ within: { classId: withinClass }, id: id }); |         return this.findOne({ within: { classId: withinClass }, id: id }, { populate: ['groups', 'groups.members'] }); | ||||||
|     } |     } | ||||||
|     public async findAllByResponsibleTeacher(teacherUsername: string): Promise<Assignment[]> { |     public async findAllByResponsibleTeacher(teacherUsername: string): Promise<Assignment[]> { | ||||||
|         return this.findAll({ |         return this.findAll({ | ||||||
|  | @ -20,6 +20,7 @@ export class AssignmentRepository extends DwengoEntityRepository<Assignment> { | ||||||
|                     }, |                     }, | ||||||
|                 }, |                 }, | ||||||
|             }, |             }, | ||||||
|  |             populate: ['groups', 'groups.members'], | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|     public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { |     public async findAllAssignmentsInClass(within: Class): Promise<Assignment[]> { | ||||||
|  |  | ||||||
|  | @ -28,4 +28,9 @@ export class GroupRepository extends DwengoEntityRepository<Group> { | ||||||
|             groupNumber: groupNumber, |             groupNumber: groupNumber, | ||||||
|         }); |         }); | ||||||
|     } |     } | ||||||
|  |     public async deleteAllByAssignment(assignment: Assignment): Promise<void> { | ||||||
|  |         return this.deleteAllWhere({ | ||||||
|  |             assignment: assignment, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -16,4 +16,13 @@ export abstract class DwengoEntityRepository<T extends object> extends EntityRep | ||||||
|             await em.flush(); |             await em.flush(); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |     public async deleteAllWhere(query: FilterQuery<T>): Promise<void> { | ||||||
|  |         const toDelete = await this.find(query); | ||||||
|  |         const em = this.getEntityManager(); | ||||||
|  | 
 | ||||||
|  |         if (toDelete) { | ||||||
|  |             em.remove(toDelete); | ||||||
|  |             await em.flush(); | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -20,7 +20,7 @@ export function mapToAssignmentDTO(assignment: Assignment): AssignmentDTO { | ||||||
|         description: assignment.description, |         description: assignment.description, | ||||||
|         learningPath: assignment.learningPathHruid, |         learningPath: assignment.learningPathHruid, | ||||||
|         language: assignment.learningPathLanguage, |         language: assignment.learningPathLanguage, | ||||||
|         deadline: assignment.deadline ?? new Date(), |         deadline: assignment.deadline ?? null, | ||||||
|         groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), |         groups: assignment.groups.map((group) => mapToGroupDTO(group, assignment.within)), | ||||||
|     }; |     }; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -10,10 +10,13 @@ import { AccountType } from '@dwengo-1/common/util/account-types'; | ||||||
| import { fetchClass } from '../../../services/classes.js'; | import { fetchClass } from '../../../services/classes.js'; | ||||||
| import { fetchGroup } from '../../../services/groups.js'; | import { fetchGroup } from '../../../services/groups.js'; | ||||||
| import { requireFields } from '../../../controllers/error-helper.js'; | import { requireFields } from '../../../controllers/error-helper.js'; | ||||||
|  | import { SubmissionDTO } from '@dwengo-1/common/interfaces/submission'; | ||||||
| 
 | 
 | ||||||
| export const onlyAllowSubmitter = authorize( | export const onlyAllowSubmitter = authorize((auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|     (auth: AuthenticationInfo, req: AuthenticatedRequest) => (req.body as { submitter: string }).submitter === auth.username |     const submittedFor = (req.body as SubmissionDTO).submitter.username; | ||||||
| ); |     const submittedBy = auth.username; | ||||||
|  |     return submittedFor === submittedBy; | ||||||
|  | }); | ||||||
| 
 | 
 | ||||||
| export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | export const onlyAllowIfHasAccessToSubmission = authorize(async (auth: AuthenticationInfo, req: AuthenticatedRequest) => { | ||||||
|     const { hruid: lohruid, id: submissionNumber } = req.params; |     const { hruid: lohruid, id: submissionNumber } = req.params; | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import { | ||||||
|     deleteTeacherHandler, |     deleteTeacherHandler, | ||||||
|     getAllTeachersHandler, |     getAllTeachersHandler, | ||||||
|     getStudentJoinRequestHandler, |     getStudentJoinRequestHandler, | ||||||
|  |     getTeacherAssignmentsHandler, | ||||||
|     getTeacherClassHandler, |     getTeacherClassHandler, | ||||||
|     getTeacherHandler, |     getTeacherHandler, | ||||||
|     getTeacherStudentHandler, |     getTeacherStudentHandler, | ||||||
|  | @ -28,6 +29,8 @@ router.get('/:username/classes', preventImpersonation, getTeacherClassHandler); | ||||||
| 
 | 
 | ||||||
| router.get('/:username/students', preventImpersonation, getTeacherStudentHandler); | router.get('/:username/students', preventImpersonation, getTeacherStudentHandler); | ||||||
| 
 | 
 | ||||||
|  | router.get(`/:username/assignments`, getTeacherAssignmentsHandler); | ||||||
|  | 
 | ||||||
| router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); | router.get('/:username/joinRequests/:classId', onlyAllowTeacherOfClass, getStudentJoinRequestHandler); | ||||||
| 
 | 
 | ||||||
| router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); | router.put('/:username/joinRequests/:classId/:studentUsername', onlyAllowTeacherOfClass, updateStudentJoinRequestHandler); | ||||||
|  |  | ||||||
|  | @ -14,10 +14,13 @@ import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submissi | ||||||
| import { fetchClass } from './classes.js'; | import { fetchClass } from './classes.js'; | ||||||
| import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; | import { QuestionDTO, QuestionId } from '@dwengo-1/common/interfaces/question'; | ||||||
| import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; | import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; | ||||||
| import { EntityDTO } from '@mikro-orm/core'; | import { EntityDTO, ForeignKeyConstraintViolationException } from '@mikro-orm/core'; | ||||||
| import { putObject } from './service-helper.js'; | import { putObject } from './service-helper.js'; | ||||||
| import { fetchStudents } from './students.js'; | import { fetchStudents } from './students.js'; | ||||||
| import { ServerErrorException } from '../exceptions/server-error-exception.js'; | import { ServerErrorException } from '../exceptions/server-error-exception.js'; | ||||||
|  | import { BadRequestException } from '../exceptions/bad-request-exception.js'; | ||||||
|  | import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||||
|  | import { PostgreSqlExceptionConverter } from '@mikro-orm/postgresql'; | ||||||
| 
 | 
 | ||||||
| export async function fetchAssignment(classid: string, assignmentNumber: number): Promise<Assignment> { | export async function fetchAssignment(classid: string, assignmentNumber: number): Promise<Assignment> { | ||||||
|     const classRepository = getClassRepository(); |     const classRepository = getClassRepository(); | ||||||
|  | @ -93,10 +96,36 @@ export async function getAssignment(classid: string, id: number): Promise<Assign | ||||||
|     return mapToAssignmentDTO(assignment); |     return mapToAssignmentDTO(assignment); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function putAssignment(classid: string, id: number, assignmentData: Partial<EntityDTO<Assignment>>): Promise<AssignmentDTO> { | function hasDuplicates(arr: string[]): boolean { | ||||||
|  |     return new Set(arr).size !== arr.length; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export async function putAssignment(classid: string, id: number, assignmentData: Partial<AssignmentDTO>): Promise<AssignmentDTO> { | ||||||
|     const assignment = await fetchAssignment(classid, id); |     const assignment = await fetchAssignment(classid, id); | ||||||
| 
 | 
 | ||||||
|     await putObject<Assignment>(assignment, assignmentData, getAssignmentRepository()); |     if (assignmentData.groups) { | ||||||
|  |         if (hasDuplicates(assignmentData.groups.flat() as string[])) { | ||||||
|  |             throw new BadRequestException('Student can only be in one group'); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         const studentLists = await Promise.all((assignmentData.groups as string[][]).map(async (group) => await fetchStudents(group))); | ||||||
|  | 
 | ||||||
|  |         const groupRepository = getGroupRepository(); | ||||||
|  |         await groupRepository.deleteAllByAssignment(assignment); | ||||||
|  |         await Promise.all( | ||||||
|  |             studentLists.map(async (students) => { | ||||||
|  |                 const newGroup = groupRepository.create({ | ||||||
|  |                     assignment: assignment, | ||||||
|  |                     members: students, | ||||||
|  |                 }); | ||||||
|  |                 await groupRepository.save(newGroup); | ||||||
|  |             }) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         delete assignmentData.groups; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     await putObject<Assignment>(assignment, assignmentData as Partial<EntityDTO<Assignment>>, getAssignmentRepository()); | ||||||
| 
 | 
 | ||||||
|     return mapToAssignmentDTO(assignment); |     return mapToAssignmentDTO(assignment); | ||||||
| } | } | ||||||
|  | @ -106,7 +135,16 @@ export async function deleteAssignment(classid: string, id: number): Promise<Ass | ||||||
|     const cls = await fetchClass(classid); |     const cls = await fetchClass(classid); | ||||||
| 
 | 
 | ||||||
|     const assignmentRepository = getAssignmentRepository(); |     const assignmentRepository = getAssignmentRepository(); | ||||||
|  | 
 | ||||||
|  |     try { | ||||||
|         await assignmentRepository.deleteByClassAndId(cls, id); |         await assignmentRepository.deleteByClassAndId(cls, id); | ||||||
|  |     } catch (e: unknown) { | ||||||
|  |         if (e instanceof ForeignKeyConstraintViolationException || e instanceof PostgreSqlExceptionConverter) { | ||||||
|  |             throw new ConflictException('Cannot delete assigment with questions or submissions'); | ||||||
|  |         } else { | ||||||
|  |             throw e; | ||||||
|  |         } | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     return mapToAssignmentDTO(assignment); |     return mapToAssignmentDTO(assignment); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -70,6 +70,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb | ||||||
|     // Convert the learning object notes as retrieved from the database into the expected response format-
 |     // Convert the learning object notes as retrieved from the database into the expected response format-
 | ||||||
|     const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); |     const convertedNodes = await convertNodes(nodesToLearningObjects, personalizedFor); | ||||||
| 
 | 
 | ||||||
|  |     const nodesActuallyOnPath = traverseLearningPath(convertedNodes); | ||||||
|  | 
 | ||||||
|     return { |     return { | ||||||
|         _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
 |         _id: `${learningPath.hruid}/${learningPath.language}`, // For backwards compatibility with the original Dwengo API.
 | ||||||
|         __order: order, |         __order: order, | ||||||
|  | @ -79,8 +81,8 @@ async function convertLearningPath(learningPath: LearningPathEntity, order: numb | ||||||
|         image: image, |         image: image, | ||||||
|         title: learningPath.title, |         title: learningPath.title, | ||||||
|         nodes: convertedNodes, |         nodes: convertedNodes, | ||||||
|         num_nodes: learningPath.nodes.length, |         num_nodes: nodesActuallyOnPath.length, | ||||||
|         num_nodes_left: convertedNodes.filter((it) => !it.done).length, |         num_nodes_left: nodesActuallyOnPath.filter((it) => !it.done).length, | ||||||
|         keywords: keywords.join(' '), |         keywords: keywords.join(' '), | ||||||
|         target_ages: targetAges, |         target_ages: targetAges, | ||||||
|         max_age: Math.max(...targetAges), |         max_age: Math.max(...targetAges), | ||||||
|  | @ -180,7 +182,6 @@ function convertTransition( | ||||||
|         return { |         return { | ||||||
|             _id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
 |             _id: String(index), // Retained for backwards compatibility. The index uniquely identifies the transition within the learning path.
 | ||||||
|             default: false, // We don't work with default transitions but retain this for backwards compatibility.
 |             default: false, // We don't work with default transitions but retain this for backwards compatibility.
 | ||||||
|             condition: transition.condition, |  | ||||||
|             next: { |             next: { | ||||||
|                 _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
 |                 _id: nextNode._id ? nextNode._id + index : v4(), // Construct a unique ID for the transition for backwards compatibility.
 | ||||||
|                 hruid: transition.next.learningObjectHruid, |                 hruid: transition.next.learningObjectHruid, | ||||||
|  | @ -191,6 +192,29 @@ function convertTransition( | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | /** | ||||||
|  |  * Start from the start node and then always take the first transition until there are no transitions anymore. | ||||||
|  |  * Returns the traversed nodes as an array. (This effectively filters outs nodes that cannot be reached.) | ||||||
|  |  */ | ||||||
|  | function traverseLearningPath(nodes: LearningObjectNode[]): LearningObjectNode[] { | ||||||
|  |     const traversedNodes: LearningObjectNode[] = []; | ||||||
|  |     let currentNode = nodes.find((it) => it.start_node); | ||||||
|  | 
 | ||||||
|  |     while (currentNode) { | ||||||
|  |         traversedNodes.push(currentNode); | ||||||
|  | 
 | ||||||
|  |         const next = currentNode.transitions[0]?.next; | ||||||
|  | 
 | ||||||
|  |         if (next) { | ||||||
|  |             currentNode = nodes.find((it) => it.learningobject_hruid === next.hruid && it.language === next.language && it.version === next.version); | ||||||
|  |         } else { | ||||||
|  |             currentNode = undefined; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return traversedNodes; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| /** | /** | ||||||
|  * Service providing access to data about learning paths from the database. |  * Service providing access to data about learning paths from the database. | ||||||
|  */ |  */ | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ import { mapToClassDTO } from '../interfaces/class.js'; | ||||||
| import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; | import { mapToGroupDTO, mapToGroupDTOId } from '../interfaces/group.js'; | ||||||
| import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js'; | import { mapToStudent, mapToStudentDTO } from '../interfaces/student.js'; | ||||||
| import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; | import { mapToSubmissionDTO, mapToSubmissionDTOId } from '../interfaces/submission.js'; | ||||||
| import { getAllAssignments } from './assignments.js'; | import { fetchAssignment } from './assignments.js'; | ||||||
| import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js'; | import { mapToQuestionDTO, mapToQuestionDTOId } from '../interfaces/question.js'; | ||||||
| import { mapToStudentRequest, mapToStudentRequestDTO } from '../interfaces/student-request.js'; | import { mapToStudentRequest, mapToStudentRequestDTO } from '../interfaces/student-request.js'; | ||||||
| import { Student } from '../entities/users/student.entity.js'; | import { Student } from '../entities/users/student.entity.js'; | ||||||
|  | @ -26,6 +26,7 @@ import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-requ | ||||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||||
| import { Submission } from '../entities/assignments/submission.entity.js'; | import { Submission } from '../entities/assignments/submission.entity.js'; | ||||||
| import { mapToUsername } from '../interfaces/user.js'; | import { mapToUsername } from '../interfaces/user.js'; | ||||||
|  | import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; | ||||||
| 
 | 
 | ||||||
| export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { | export async function getAllStudents(full: boolean): Promise<StudentDTO[] | string[]> { | ||||||
|     const studentRepository = getStudentRepository(); |     const studentRepository = getStudentRepository(); | ||||||
|  | @ -50,8 +51,7 @@ export async function fetchStudent(username: string): Promise<Student> { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function fetchStudents(usernames: string[]): Promise<Student[]> { | export async function fetchStudents(usernames: string[]): Promise<Student[]> { | ||||||
|     const members = await Promise.all(usernames.map(async (username) => await fetchStudent(username))); |     return await Promise.all(usernames.map(async (username) => await fetchStudent(username))); | ||||||
|     return members; |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getStudent(username: string): Promise<StudentDTO> { | export async function getStudent(username: string): Promise<StudentDTO> { | ||||||
|  | @ -102,10 +102,14 @@ export async function getStudentClasses(username: string, full: boolean): Promis | ||||||
| export async function getStudentAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> { | export async function getStudentAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> { | ||||||
|     const student = await fetchStudent(username); |     const student = await fetchStudent(username); | ||||||
| 
 | 
 | ||||||
|     const classRepository = getClassRepository(); |     const groupRepository = getGroupRepository(); | ||||||
|     const classes = await classRepository.findByStudent(student); |     const groups = await groupRepository.findAllGroupsWithStudent(student); | ||||||
|  |     const assignments = await Promise.all(groups.map(async (group) => await fetchAssignment(group.assignment.within.classId!, group.assignment.id!))); | ||||||
| 
 | 
 | ||||||
|     return (await Promise.all(classes.map(async (cls) => await getAllAssignments(cls.classId!, full)))).flat(); |     if (full) { | ||||||
|  |         return assignments.map(mapToAssignmentDTO); | ||||||
|  |     } | ||||||
|  |     return assignments.map(mapToAssignmentDTOId); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[] | GroupDTOId[]> { | export async function getStudentGroups(username: string, full: boolean): Promise<GroupDTO[] | GroupDTOId[]> { | ||||||
|  |  | ||||||
|  | @ -1,4 +1,4 @@ | ||||||
| import { getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js'; | import { getAssignmentRepository, getClassJoinRequestRepository, getClassRepository, getTeacherRepository } from '../data/repositories.js'; | ||||||
| import { mapToClassDTO } from '../interfaces/class.js'; | import { mapToClassDTO } from '../interfaces/class.js'; | ||||||
| import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; | import { mapToTeacher, mapToTeacherDTO } from '../interfaces/teacher.js'; | ||||||
| import { Teacher } from '../entities/users/teacher.entity.js'; | import { Teacher } from '../entities/users/teacher.entity.js'; | ||||||
|  | @ -18,6 +18,8 @@ import { StudentDTO } from '@dwengo-1/common/interfaces/student'; | ||||||
| import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | import { ClassJoinRequestDTO } from '@dwengo-1/common/interfaces/class-join-request'; | ||||||
| import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; | import { ClassStatus } from '@dwengo-1/common/util/class-join-request'; | ||||||
| import { ConflictException } from '../exceptions/conflict-exception.js'; | import { ConflictException } from '../exceptions/conflict-exception.js'; | ||||||
|  | import { AssignmentDTO, AssignmentDTOId } from '@dwengo-1/common/interfaces/assignment'; | ||||||
|  | import { mapToAssignmentDTO, mapToAssignmentDTOId } from '../interfaces/assignment.js'; | ||||||
| import { mapToUsername } from '../interfaces/user.js'; | import { mapToUsername } from '../interfaces/user.js'; | ||||||
| 
 | 
 | ||||||
| export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { | export async function getAllTeachers(full: boolean): Promise<TeacherDTO[] | string[]> { | ||||||
|  | @ -91,6 +93,17 @@ export async function getClassesByTeacher(username: string, full: boolean): Prom | ||||||
|     return classes.map((cls) => cls.id); |     return classes.map((cls) => cls.id); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export async function getTeacherAssignments(username: string, full: boolean): Promise<AssignmentDTO[] | AssignmentDTOId[]> { | ||||||
|  |     const assignmentRepository = getAssignmentRepository(); | ||||||
|  |     const assignments = await assignmentRepository.findAllByResponsibleTeacher(username); | ||||||
|  | 
 | ||||||
|  |     if (full) { | ||||||
|  |         return assignments.map(mapToAssignmentDTO); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return assignments.map(mapToAssignmentDTOId); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export async function getStudentsByTeacher(username: string, full: boolean): Promise<StudentDTO[] | string[]> { | export async function getStudentsByTeacher(username: string, full: boolean): Promise<StudentDTO[] | string[]> { | ||||||
|     const classes: ClassDTO[] = await fetchClassesByTeacher(username); |     const classes: ClassDTO[] = await fetchClassesByTeacher(username); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -14,6 +14,7 @@ import { | ||||||
|     getStudentRequestsHandler, |     getStudentRequestsHandler, | ||||||
|     deleteClassJoinRequestHandler, |     deleteClassJoinRequestHandler, | ||||||
|     getStudentRequestHandler, |     getStudentRequestHandler, | ||||||
|  |     getStudentAssignmentsHandler, | ||||||
| } from '../../src/controllers/students.js'; | } from '../../src/controllers/students.js'; | ||||||
| import { getDireStraits, getNoordkaap, getTheDoors, TEST_STUDENTS } from '../test_assets/users/students.testdata.js'; | import { getDireStraits, getNoordkaap, getTheDoors, TEST_STUDENTS } from '../test_assets/users/students.testdata.js'; | ||||||
| import { NotFoundException } from '../../src/exceptions/not-found-exception.js'; | import { NotFoundException } from '../../src/exceptions/not-found-exception.js'; | ||||||
|  | @ -150,6 +151,19 @@ describe('Student controllers', () => { | ||||||
|         expect(result.groups).to.have.length.greaterThan(0); |         expect(result.groups).to.have.length.greaterThan(0); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     it('Student assignments', async () => { | ||||||
|  |         const group = getTestGroup01(); | ||||||
|  |         const member = group.members[0]; | ||||||
|  |         req = { params: { username: member.username }, query: {} }; | ||||||
|  | 
 | ||||||
|  |         await getStudentAssignmentsHandler(req as Request, res as Response); | ||||||
|  | 
 | ||||||
|  |         expect(jsonMock).toHaveBeenCalledWith(expect.objectContaining({ assignments: expect.anything() })); | ||||||
|  | 
 | ||||||
|  |         const result = jsonMock.mock.lastCall?.[0]; | ||||||
|  |         expect(result.assignments).to.have.length.greaterThan(0); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     it('Student submissions', async () => { |     it('Student submissions', async () => { | ||||||
|         const submission = getSubmission01(); |         const submission = getSubmission01(); | ||||||
|         req = { params: { username: submission.submitter.username }, query: { full: 'true' } }; |         req = { params: { username: submission.submitter.username }, query: { full: 'true' } }; | ||||||
|  |  | ||||||
|  | @ -51,7 +51,7 @@ export function makeTestGroups(em: EntityManager): Group[] { | ||||||
|      */ |      */ | ||||||
|     group05 = em.create(Group, { |     group05 = em.create(Group, { | ||||||
|         assignment: getAssignment04(), |         assignment: getAssignment04(), | ||||||
|         groupNumber: 21001, |         groupNumber: 21006, | ||||||
|         members: [getNoordkaap(), getDireStraits()], |         members: [getNoordkaap(), getDireStraits()], | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -5,8 +5,22 @@ import { makeTestTeachers } from '../tests/test_assets/users/teachers.testdata'; | ||||||
| import { makeTestLearningObjects } from '../tests/test_assets/content/learning-objects.testdata'; | import { makeTestLearningObjects } from '../tests/test_assets/content/learning-objects.testdata'; | ||||||
| import { makeTestLearningPaths } from '../tests/test_assets/content/learning-paths.testdata'; | import { makeTestLearningPaths } from '../tests/test_assets/content/learning-paths.testdata'; | ||||||
| import { makeTestClasses } from '../tests/test_assets/classes/classes.testdata'; | import { makeTestClasses } from '../tests/test_assets/classes/classes.testdata'; | ||||||
| import { makeTestAssignemnts } from '../tests/test_assets/assignments/assignments.testdata'; | import { | ||||||
| import { getTestGroup01, getTestGroup02, getTestGroup03, getTestGroup04, makeTestGroups } from '../tests/test_assets/assignments/groups.testdata'; |     getAssignment01, | ||||||
|  |     getAssignment02, | ||||||
|  |     getAssignment04, | ||||||
|  |     getConditionalPathAssignment, | ||||||
|  |     makeTestAssignemnts, | ||||||
|  | } from '../tests/test_assets/assignments/assignments.testdata'; | ||||||
|  | import { | ||||||
|  |     getGroup1ConditionalLearningPath, | ||||||
|  |     getTestGroup01, | ||||||
|  |     getTestGroup02, | ||||||
|  |     getTestGroup03, | ||||||
|  |     getTestGroup04, | ||||||
|  |     getTestGroup05, | ||||||
|  |     makeTestGroups, | ||||||
|  | } from '../tests/test_assets/assignments/groups.testdata'; | ||||||
| import { Group } from '../src/entities/assignments/group.entity'; | import { Group } from '../src/entities/assignments/group.entity'; | ||||||
| import { makeTestTeacherInvitations } from '../tests/test_assets/classes/teacher-invitations.testdata'; | import { makeTestTeacherInvitations } from '../tests/test_assets/classes/teacher-invitations.testdata'; | ||||||
| import { makeTestClassJoinRequests } from '../tests/test_assets/classes/class-join-requests.testdata'; | import { makeTestClassJoinRequests } from '../tests/test_assets/classes/class-join-requests.testdata'; | ||||||
|  | @ -36,8 +50,14 @@ export async function seedORM(orm: MikroORM): Promise<void> { | ||||||
| 
 | 
 | ||||||
|     const groups = makeTestGroups(em); |     const groups = makeTestGroups(em); | ||||||
| 
 | 
 | ||||||
|     assignments[0].groups = new Collection<Group>([getTestGroup01(), getTestGroup02(), getTestGroup03()]); |     let assignment = getAssignment01(); | ||||||
|     assignments[1].groups = new Collection<Group>([getTestGroup04()]); |     assignment.groups = new Collection<Group>([getTestGroup01(), getTestGroup02(), getTestGroup03()]); | ||||||
|  |     assignment = getAssignment02(); | ||||||
|  |     assignment.groups = new Collection<Group>([getTestGroup04()]); | ||||||
|  |     assignment = getAssignment04(); | ||||||
|  |     assignment.groups = new Collection<Group>([getTestGroup05()]); | ||||||
|  |     assignment = getConditionalPathAssignment(); | ||||||
|  |     assignment.groups = new Collection<Group>([getGroup1ConditionalLearningPath()]); | ||||||
| 
 | 
 | ||||||
|     const teacherInvitations = makeTestTeacherInvitations(em); |     const teacherInvitations = makeTestTeacherInvitations(em); | ||||||
|     const classJoinRequests = makeTestClassJoinRequests(em); |     const classJoinRequests = makeTestClassJoinRequests(em); | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ export interface AssignmentDTO { | ||||||
|     description: string; |     description: string; | ||||||
|     learningPath: string; |     learningPath: string; | ||||||
|     language: string; |     language: string; | ||||||
|     deadline: Date; |     deadline: Date | null; | ||||||
|     groups: GroupDTO[] | string[][]; |     groups: GroupDTO[] | string[][]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -18,10 +18,19 @@ | ||||||
|     font-size: 1.1rem; |     font-size: 1.1rem; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .top-right-btn { | .top-buttons-wrapper { | ||||||
|     position: absolute; |     display: flex; | ||||||
|     right: 2%; |     justify-content: space-between; | ||||||
|     color: red; |     align-items: center; | ||||||
|  |     padding: 1rem; | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .right-buttons { | ||||||
|  |     display: flex; | ||||||
|  |     gap: 0.5rem; | ||||||
|  |     align-items: center; | ||||||
|  |     color: #0e6942; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .group-section { | .group-section { | ||||||
|  |  | ||||||
							
								
								
									
										52
									
								
								frontend/src/components/DwengoTable.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								frontend/src/components/DwengoTable.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,52 @@ | ||||||
|  | <template> | ||||||
|  |     <v-table class="table"> | ||||||
|  |         <thead> | ||||||
|  |             <tr | ||||||
|  |                 v-for="name in columns" | ||||||
|  |                 :key="column" | ||||||
|  |             > | ||||||
|  |                 <th class="header">{{ name }}</th> | ||||||
|  |             </tr> | ||||||
|  |         </thead> | ||||||
|  |         <tbody> | ||||||
|  |             <tr | ||||||
|  |                 v-for="([item1, item2, item3], index) in listItems" | ||||||
|  |                 :key="index" | ||||||
|  |             > | ||||||
|  |                 <td></td> | ||||||
|  |                 <td> | ||||||
|  |                     <v-btn | ||||||
|  |                         :to="`/class/${c.id}`" | ||||||
|  |                         variant="text" | ||||||
|  |                     > | ||||||
|  |                         {{ c.displayName }} | ||||||
|  |                         <v-icon end> mdi-menu-right </v-icon> | ||||||
|  |                     </v-btn> | ||||||
|  |                 </td> | ||||||
|  |                 <td> | ||||||
|  |                     <span v-if="!isMdAndDown">{{ c.id }}</span> | ||||||
|  |                     <span | ||||||
|  |                         v-else | ||||||
|  |                         style="cursor: pointer" | ||||||
|  |                         @click="openCodeDialog(c.id)" | ||||||
|  |                         ><v-icon icon="mdi-eye"></v-icon | ||||||
|  |                     ></span> | ||||||
|  |                 </td> | ||||||
|  | 
 | ||||||
|  |                 <td>{{ c.students.length }}</td> | ||||||
|  |             </tr> | ||||||
|  |         </tbody> | ||||||
|  |     </v-table> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  |     export default { | ||||||
|  |         name: "columnList", | ||||||
|  |         props: { | ||||||
|  |             items: { | ||||||
|  |                 type: Array, | ||||||
|  |                 required: true, | ||||||
|  |             }, | ||||||
|  |         }, | ||||||
|  |     }; | ||||||
|  | </script> | ||||||
							
								
								
									
										47
									
								
								frontend/src/components/GroupProgressRow.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								frontend/src/components/GroupProgressRow.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,47 @@ | ||||||
|  | <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"; | ||||||
|  | 
 | ||||||
|  |     const props = defineProps<{ | ||||||
|  |         groupNumber: number; | ||||||
|  |         learningPath: string; | ||||||
|  |         language: Language; | ||||||
|  |         assignmentId: number; | ||||||
|  |         classId: string; | ||||||
|  |     }>(); | ||||||
|  | 
 | ||||||
|  |     const query = useGetLearningPathQuery( | ||||||
|  |         () => props.learningPath, | ||||||
|  |         () => props.language, | ||||||
|  |         () => ({ | ||||||
|  |             forGroup: props.groupNumber, | ||||||
|  |             assignmentNo: props.assignmentId, | ||||||
|  |             classId: props.classId, | ||||||
|  |         }), | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     const progress = computed(() => { | ||||||
|  |         if (!query.data.value) return 0; | ||||||
|  |         return calculateProgress(query.data.value); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const progressColor = computed(() => { | ||||||
|  |         if (progress.value < 50) return "error"; | ||||||
|  |         if (progress.value < 80) return "warning"; | ||||||
|  |         return "success"; | ||||||
|  |     }); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <v-progress-linear | ||||||
|  |         :model-value="progress" | ||||||
|  |         :color="progressColor" | ||||||
|  |         height="25" | ||||||
|  |     > | ||||||
|  |         <template v-slot:default="{ value }"> | ||||||
|  |             <strong>{{ Math.ceil(value) }}%</strong> | ||||||
|  |         </template> | ||||||
|  |     </v-progress-linear> | ||||||
|  | </template> | ||||||
							
								
								
									
										50
									
								
								frontend/src/components/GroupSubmissionStatus.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								frontend/src/components/GroupSubmissionStatus.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | ||||||
|  | <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 { watch } from "vue"; | ||||||
|  | 
 | ||||||
|  |     const props = defineProps<{ | ||||||
|  |         group: object; | ||||||
|  |         assignmentId: number; | ||||||
|  |         classId: string; | ||||||
|  |         goToGroupSubmissionLink: (groupNo: number) => void; | ||||||
|  |     }>(); | ||||||
|  | 
 | ||||||
|  |     const emit = defineEmits<(e: "update:hasSubmission", hasSubmission: boolean) => void>(); | ||||||
|  | 
 | ||||||
|  |     const { t } = useI18n(); | ||||||
|  |     const submissionsQuery = useAssignmentSubmissionsQuery( | ||||||
|  |         () => props.classId, | ||||||
|  |         () => props.assignmentId, | ||||||
|  |         () => props.group.originalGroupNo, | ||||||
|  |         () => true, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     watch( | ||||||
|  |         () => submissionsQuery.data.value, | ||||||
|  |         (data) => { | ||||||
|  |             if (data) { | ||||||
|  |                 emit("update:hasSubmission", data.submissions.length > 0); | ||||||
|  |             } | ||||||
|  |         }, | ||||||
|  |         { immediate: true }, | ||||||
|  |     ); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |     <using-query-result | ||||||
|  |         :query-result="submissionsQuery" | ||||||
|  |         v-slot="{ data }: { data: SubmissionsResponse }" | ||||||
|  |     > | ||||||
|  |         <v-btn | ||||||
|  |             :color="data?.submissions?.length > 0 ? 'green' : 'red'" | ||||||
|  |             variant="text" | ||||||
|  |             :to="data.submissions.length > 0 ? goToGroupSubmissionLink(props.group.groupNo) : undefined" | ||||||
|  |             :disabled="data.submissions.length === 0" | ||||||
|  |         > | ||||||
|  |             {{ data.submissions.length > 0 ? t("submission") : t("noSubmissionsYet") }} | ||||||
|  |         </v-btn> | ||||||
|  |     </using-query-result> | ||||||
|  | </template> | ||||||
							
								
								
									
										0
									
								
								frontend/src/components/assignments/AssignmentCard.vue
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								frontend/src/components/assignments/AssignmentCard.vue
									
										
									
									
									
										Normal file
									
								
							|  | @ -1,18 +1,42 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import { ref, watch } from "vue"; |     import { ref, watch } from "vue"; | ||||||
|     import { deadlineRules } from "@/utils/assignment-rules.ts"; |     import { useI18n } from "vue-i18n"; | ||||||
| 
 | 
 | ||||||
|     const emit = defineEmits<(e: "update:deadline", value: Date) => void>(); |     const { t } = useI18n(); | ||||||
|  | 
 | ||||||
|  |     const emit = defineEmits<(e: "update:deadline", value: Date | null) => void>(); | ||||||
|  |     const props = defineProps<{ deadline: Date | null }>(); | ||||||
| 
 | 
 | ||||||
|     const datetime = ref(""); |     const datetime = ref(""); | ||||||
| 
 | 
 | ||||||
|  |     datetime.value = props.deadline ? new Date(props.deadline).toISOString().slice(0, 16) : ""; | ||||||
|  | 
 | ||||||
|     // Watch the datetime value and emit the update |     // Watch the datetime value and emit the update | ||||||
|     watch(datetime, (val) => { |     watch(datetime, (val) => { | ||||||
|         const newDate = new Date(val); |         const newDate = new Date(val); | ||||||
|         if (!isNaN(newDate.getTime())) { |         if (!isNaN(newDate.getTime())) { | ||||||
|             emit("update:deadline", newDate); |             emit("update:deadline", newDate); | ||||||
|  |         } else { | ||||||
|  |             emit("update:deadline", null); | ||||||
|         } |         } | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     const deadlineRules = [ | ||||||
|  |         (value: string): string | boolean => { | ||||||
|  |             const selectedDateTime = new Date(value); | ||||||
|  |             const now = new Date(); | ||||||
|  | 
 | ||||||
|  |             if (isNaN(selectedDateTime.getTime())) { | ||||||
|  |                 return t("deadline-invalid"); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             if (selectedDateTime <= now) { | ||||||
|  |                 return t("deadline-past"); | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|  |             return true; | ||||||
|  |         }, | ||||||
|  |     ]; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|  |  | ||||||
|  | @ -1,75 +1,680 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import { ref } from "vue"; |     import { computed, ref, watch } from "vue"; | ||||||
|     import { useI18n } from "vue-i18n"; |     import { useI18n } from "vue-i18n"; | ||||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; |     import { useClassStudentsQuery } from "@/queries/classes"; | ||||||
|     import type { StudentsResponse } from "@/controllers/students.ts"; |  | ||||||
|     import { useClassStudentsQuery } from "@/queries/classes.ts"; |  | ||||||
| 
 | 
 | ||||||
|     const props = defineProps<{ |     const props = defineProps<{ | ||||||
|         classId: string | undefined; |         classId: string | undefined; | ||||||
|         groups: string[][]; |         groups: object[]; | ||||||
|     }>(); |     }>(); | ||||||
|     const emit = defineEmits(["groupCreated"]); |     const emit = defineEmits(["close", "groupsUpdated", "done"]); | ||||||
|     const { t } = useI18n(); |     const { t } = useI18n(); | ||||||
| 
 | 
 | ||||||
|     const selectedStudents = ref([]); |     interface StudentItem { | ||||||
| 
 |         username: string; | ||||||
|     const studentQueryResult = useClassStudentsQuery(() => props.classId, true); |         fullName: string; | ||||||
| 
 |  | ||||||
|     function filterStudents(data: StudentsResponse): { title: string; value: string }[] { |  | ||||||
|         const students = data.students; |  | ||||||
|         const studentsInGroups = props.groups.flat(); |  | ||||||
| 
 |  | ||||||
|         return students |  | ||||||
|             ?.map((st) => ({ |  | ||||||
|                 title: `${st.firstName} ${st.lastName}`, |  | ||||||
|                 value: st.username, |  | ||||||
|             })) |  | ||||||
|             .filter((student) => !studentsInGroups.includes(student.value)); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     function createGroup(): void { |     const { data: studentsData } = useClassStudentsQuery(() => props.classId, true); | ||||||
|         if (selectedStudents.value.length) { | 
 | ||||||
|             // Extract only usernames (student.value) |     // Dialog states for group editing | ||||||
|             const usernames = selectedStudents.value.map((student) => student.value); |     const activeDialog = ref<"random" | "dragdrop" | null>(null); | ||||||
|             emit("groupCreated", usernames); | 
 | ||||||
|             selectedStudents.value = []; // Reset selection after creating group |     // Drag state for the drag and drop | ||||||
|  |     const draggedItem = ref<{ groupIndex: number; studentIndex: number } | null>(null); | ||||||
|  | 
 | ||||||
|  |     const currentGroups = ref<StudentItem[][]>([]); | ||||||
|  |     const unassignedStudents = ref<StudentItem[]>([]); | ||||||
|  |     const allStudents = ref<StudentItem[]>([]); | ||||||
|  | 
 | ||||||
|  |     // Random groups state | ||||||
|  |     const groupSize = ref(1); | ||||||
|  |     const randomGroupsPreview = ref<StudentItem[][]>([]); | ||||||
|  | 
 | ||||||
|  |     // Initialize data | ||||||
|  |     watch( | ||||||
|  |         () => [studentsData.value, props.groups], | ||||||
|  |         ([studentsVal, existingGroups]) => { | ||||||
|  |             if (!studentsVal) return; | ||||||
|  | 
 | ||||||
|  |             // Initialize all students | ||||||
|  |             allStudents.value = studentsVal.students.map((s) => ({ | ||||||
|  |                 username: s.username, | ||||||
|  |                 fullName: `${s.firstName} ${s.lastName}`, | ||||||
|  |             })); | ||||||
|  | 
 | ||||||
|  |             // Initialize groups if they exist | ||||||
|  |             if (existingGroups && existingGroups.length > 0) { | ||||||
|  |                 currentGroups.value = existingGroups.map((group) => | ||||||
|  |                     group.members.map((member) => ({ | ||||||
|  |                         username: member.username, | ||||||
|  |                         fullName: `${member.firstName} ${member.lastName}`, | ||||||
|  |                     })), | ||||||
|  |                 ); | ||||||
|  |                 const assignedUsernames = new Set( | ||||||
|  |                     existingGroups.flatMap((g) => g.members.map((m: StudentItem) => m.username)), | ||||||
|  |                 ); | ||||||
|  |                 unassignedStudents.value = allStudents.value.filter((s) => !assignedUsernames.has(s.username)); | ||||||
|  |             } else { | ||||||
|  |                 currentGroups.value = []; | ||||||
|  |                 unassignedStudents.value = [...allStudents.value]; | ||||||
|             } |             } | ||||||
|  | 
 | ||||||
|  |             randomGroupsPreview.value = [...currentGroups.value]; | ||||||
|  |         }, | ||||||
|  |         { immediate: true }, | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     /** Random groups functions */ | ||||||
|  |     function generateRandomGroups(): void { | ||||||
|  |         if (groupSize.value < 1) return; | ||||||
|  | 
 | ||||||
|  |         // Shuffle students | ||||||
|  |         const shuffled = [...allStudents.value].sort(() => Math.random() - 0.5); | ||||||
|  | 
 | ||||||
|  |         // Create new groups | ||||||
|  |         const newGroups: StudentItem[][] = []; | ||||||
|  |         const groupCount = Math.ceil(shuffled.length / groupSize.value); | ||||||
|  | 
 | ||||||
|  |         for (let i = 0; i < groupCount; i++) { | ||||||
|  |             newGroups.push([]); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         // Distribute students | ||||||
|  |         shuffled.forEach((student, index) => { | ||||||
|  |             const groupIndex = index % groupCount; | ||||||
|  |             newGroups[groupIndex].push(student); | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         randomGroupsPreview.value = newGroups; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     function saveRandomGroups(): void { | ||||||
|  |         emit( | ||||||
|  |             "groupsUpdated", | ||||||
|  |             randomGroupsPreview.value.map((g) => g.map((s) => s.username)), | ||||||
|  |         ); | ||||||
|  |         activeDialog.value = null; | ||||||
|  |         emit("done"); | ||||||
|  |         emit("close"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     function addNewGroup(): void { | ||||||
|  |         currentGroups.value.push([]); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     function removeGroup(index: number): void { | ||||||
|  |         // 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 { | ||||||
|  |         emit( | ||||||
|  |             "groupsUpdated", | ||||||
|  |             currentGroups.value | ||||||
|  |                 .filter((g) => g.length > 0) // Filter out empty groups | ||||||
|  |                 .map((g) => g.map((s) => s.username)), | ||||||
|  |         ); | ||||||
|  |         activeDialog.value = null; | ||||||
|  |         emit("done"); | ||||||
|  |         emit("close"); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const showGroupsPreview = computed(() => 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> | ||||||
|     <using-query-result |     <v-card class="pa-4 minimal-card"> | ||||||
|         :query-result="studentQueryResult" |         <!-- Current groups and unassigned students Preview --> | ||||||
|         v-slot="{ data }: { data: StudentsResponse }" |         <div | ||||||
|  |             v-if="showGroupsPreview" | ||||||
|  |             class="mb-6" | ||||||
|         > |         > | ||||||
|         <h3>{{ t("create-groups") }}</h3> |             <h3 class="mb-2">{{ t("current-groups") }}</h3> | ||||||
|         <v-card-text> |             <div> | ||||||
|             <v-combobox |                 <div class="d-flex flex-wrap"> | ||||||
|                 v-model="selectedStudents" |                     <label>{{ currentGroups.length }}</label> | ||||||
|                 :items="filterStudents(data)" |                 </div> | ||||||
|                 item-title="title" |             </div> | ||||||
|                 item-value="value" |         </div> | ||||||
|                 :label="t('choose-students')" |  | ||||||
|                 variant="outlined" |  | ||||||
|                 clearable |  | ||||||
|                 multiple |  | ||||||
|                 hide-details |  | ||||||
|                 density="compact" |  | ||||||
|                 chips |  | ||||||
|                 append-inner-icon="mdi-magnify" |  | ||||||
|             ></v-combobox> |  | ||||||
| 
 | 
 | ||||||
|             <v-btn |         <v-row | ||||||
|                 @click="createGroup" |             justify="center" | ||||||
|                 color="primary" |             class="mb-4" | ||||||
|                 class="mt-2" |  | ||||||
|                 size="small" |  | ||||||
|         > |         > | ||||||
|                 {{ t("create-group") }} |             <v-btn | ||||||
|  |                 color="primary" | ||||||
|  |                 @click="activeDialog = 'random'" | ||||||
|  |                 prepend-icon="mdi-shuffle" | ||||||
|  |             > | ||||||
|  |                 {{ t("random-grouping") }} | ||||||
|             </v-btn> |             </v-btn> | ||||||
|  |             <v-btn | ||||||
|  |                 color="secondary" | ||||||
|  |                 class="ml-4" | ||||||
|  |                 @click="activeDialog = 'dragdrop'" | ||||||
|  |                 prepend-icon="mdi-drag" | ||||||
|  |             > | ||||||
|  |                 {{ t("drag-and-drop") }} | ||||||
|  |             </v-btn> | ||||||
|  |         </v-row> | ||||||
|  | 
 | ||||||
|  |         <!-- Random Groups selection Dialog --> | ||||||
|  |         <v-dialog | ||||||
|  |             :model-value="activeDialog === 'random'" | ||||||
|  |             @update:model-value="(val) => (val ? (activeDialog = 'random') : (activeDialog = null))" | ||||||
|  |             max-width="600" | ||||||
|  |         > | ||||||
|  |             <v-card class="custom-dialog"> | ||||||
|  |                 <v-card-title class="dialog-title">{{ t("auto-generate-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 || groupSize > allStudents.length" | ||||||
|  |                                 block | ||||||
|  |                             > | ||||||
|  |                                 {{ t("generate-groups") }} | ||||||
|  |                             </v-btn> | ||||||
|  |                         </v-col> | ||||||
|  |                     </v-row> | ||||||
|  | 
 | ||||||
|  |                     <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" | ||||||
|  |                             > | ||||||
|  |                                 <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-text> | ||||||
|     </using-query-result> | 
 | ||||||
|  |                 <v-card-actions class="dialog-actions"> | ||||||
|  |                     <v-spacer /> | ||||||
|  |                     <v-btn | ||||||
|  |                         text | ||||||
|  |                         @click="activeDialog = null" | ||||||
|  |                         >{{ t("cancel") }}</v-btn | ||||||
|  |                     > | ||||||
|  |                     <v-btn | ||||||
|  |                         color="success" | ||||||
|  |                         @click="saveRandomGroups" | ||||||
|  |                         :disabled="randomGroupsPreview.length === 0" | ||||||
|  |                     > | ||||||
|  |                         {{ t("save") }} | ||||||
|  |                     </v-btn> | ||||||
|  |                 </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" | ||||||
|  |         > | ||||||
|  |             <v-card class="custom-dialog"> | ||||||
|  |                 <v-card-title class="dialog-title d-flex justify-space-between align-center"> | ||||||
|  |                     <div>{{ t("drag-and-drop") }}</div> | ||||||
|  |                     <v-btn | ||||||
|  |                         color="primary" | ||||||
|  |                         small | ||||||
|  |                         @click="addNewGroup" | ||||||
|  |                         >+</v-btn | ||||||
|  |                     > | ||||||
|  |                 </v-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" | ||||||
|  |                             > | ||||||
|  |                                 <div> | ||||||
|  |                                     <v-icon | ||||||
|  |                                         icon="mdi-information-outline" | ||||||
|  |                                         size="small" | ||||||
|  |                                     /> | ||||||
|  |                                     {{ t("currently-no-groups") }} | ||||||
|  |                                 </div> | ||||||
|  |                             </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" | ||||||
|  |                                             @touchstart="handleTouchStart($event, groupIndex, studentIndex)" | ||||||
|  |                                             @touchmove="handleTouchMove($event)" | ||||||
|  |                                             @touchend="handleTouchEnd($event)" | ||||||
|  |                                             @dragstart="handleDragStart($event, 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" | ||||||
|  |                                     @touchstart="handleTouchStart($event, -1, studentIndex)" | ||||||
|  |                                     @touchmove="handleTouchMove($event)" | ||||||
|  |                                     @touchend="handleTouchEnd($event)" | ||||||
|  |                                     @dragstart="handleDragStart($event, -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" | ||||||
|  |                     > | ||||||
|  |                         {{ t("save") }} | ||||||
|  |                     </v-btn> | ||||||
|  |                 </v-card-actions> | ||||||
|  |             </v-card> | ||||||
|  |         </v-dialog> | ||||||
|  |     </v-card> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <style scoped></style> | <style scoped> | ||||||
|  |     .group-box { | ||||||
|  |         min-height: 100px; | ||||||
|  |         max-height: 200px; | ||||||
|  |         overflow-y: auto; | ||||||
|  |         background-color: #fafafa; | ||||||
|  |         border-radius: 4px; | ||||||
|  |         transition: all 0.2s; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .group-box.highlight { | ||||||
|  |         background-color: #e3f2fd; | ||||||
|  |         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; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .custom-dialog { | ||||||
|  |         border-radius: 16px; | ||||||
|  |         padding: 24px; | ||||||
|  |         box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .dialog-title { | ||||||
|  |         color: #00796b; /* teal-like green */ | ||||||
|  |         font-weight: bold; | ||||||
|  |         font-size: 1.25rem; | ||||||
|  |         margin-bottom: 16px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .dialog-actions { | ||||||
|  |         display: flex; | ||||||
|  |         justify-content: flex-end; | ||||||
|  |         gap: 12px; | ||||||
|  |         margin-top: 24px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .v-btn.custom-green { | ||||||
|  |         background-color: #43a047; | ||||||
|  |         color: white; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .v-btn.custom-green:hover { | ||||||
|  |         background-color: #388e3c; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .v-btn.custom-blue { | ||||||
|  |         background-color: #1e88e5; | ||||||
|  |         color: white; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .v-btn.custom-blue:hover { | ||||||
|  |         background-color: #1565c0; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .v-btn.cancel-button { | ||||||
|  |         background-color: #e0f2f1; | ||||||
|  |         color: #00695c; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .minimal-card { | ||||||
|  |         box-shadow: none; /* remove card shadow */ | ||||||
|  |         border: none; /* remove border */ | ||||||
|  |         background-color: transparent; /* make background transparent */ | ||||||
|  |         padding: 0; /* reduce padding */ | ||||||
|  |         margin-bottom: 1rem; /* keep some spacing below */ | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /* Optionally, keep some padding only around buttons */ | ||||||
|  |     .minimal-card > .v-row { | ||||||
|  |         padding: 1rem 0; /* give vertical padding around buttons */ | ||||||
|  |     } | ||||||
|  | </style> | ||||||
|  |  | ||||||
|  | @ -25,7 +25,7 @@ export class AssignmentController extends BaseController { | ||||||
|         return this.get<AssignmentResponse>(`/${num}`); |         return this.get<AssignmentResponse>(`/${num}`); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async createAssignment(data: AssignmentDTO): Promise<AssignmentResponse> { |     async createAssignment(data: Partial<AssignmentDTO>): Promise<AssignmentResponse> { | ||||||
|         return this.post<AssignmentResponse>(`/`, data); |         return this.post<AssignmentResponse>(`/`, data); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import { BaseController } from "@/controllers/base-controller.ts"; | ||||||
| import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; | import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; | ||||||
| import type { ClassesResponse } from "@/controllers/classes.ts"; | import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||||
| import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | ||||||
|  | import type { AssignmentsResponse } from "./assignments"; | ||||||
| 
 | 
 | ||||||
| export interface TeachersResponse { | export interface TeachersResponse { | ||||||
|     teachers: TeacherDTO[] | string[]; |     teachers: TeacherDTO[] | string[]; | ||||||
|  | @ -35,6 +36,10 @@ export class TeacherController extends BaseController { | ||||||
|         return this.get<ClassesResponse>(`/${username}/classes`, { full }); |         return this.get<ClassesResponse>(`/${username}/classes`, { full }); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     async getAssignments(username: string, full = true): Promise<AssignmentsResponse> { | ||||||
|  |         return this.get<AssignmentsResponse>(`/${username}/assignments`, { full }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     async getStudents(username: string, full = false): Promise<StudentsResponse> { |     async getStudents(username: string, full = false): Promise<StudentsResponse> { | ||||||
|         return this.get<StudentsResponse>(`/${username}/students`, { full }); |         return this.get<StudentsResponse>(`/${username}/students`, { full }); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -105,7 +105,6 @@ | ||||||
|     "assignLearningPath": "Als Aufgabe geben", |     "assignLearningPath": "Als Aufgabe geben", | ||||||
|     "group": "Gruppe", |     "group": "Gruppe", | ||||||
|     "description": "Beschreibung", |     "description": "Beschreibung", | ||||||
|     "no-submission": "keine vorlage", |  | ||||||
|     "submission": "Einreichung", |     "submission": "Einreichung", | ||||||
|     "progress": "Fortschritte", |     "progress": "Fortschritte", | ||||||
|     "remove": "entfernen", |     "remove": "entfernen", | ||||||
|  | @ -166,5 +165,21 @@ | ||||||
|     "pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.", |     "pathContainsNonExistingLearningObjects": "Mindestens eines der in diesem Pfad referenzierten Lernobjekte existiert nicht.", | ||||||
|     "targetAgesMandatory": "Zielalter müssen angegeben werden.", |     "targetAgesMandatory": "Zielalter müssen angegeben werden.", | ||||||
|     "hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)", |     "hintRemoveIfUnconditionalTransition": "(entfernen, wenn dies ein bedingungsloser Übergang sein soll)", | ||||||
|     "hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt" |     "hintKeywordsSeparatedBySpaces": "Schlüsselwörter durch Leerzeichen getrennt", | ||||||
|  |     "title-required": "Titel darf nicht leer sein.", | ||||||
|  |     "class-required": "Du musst eine Klasse auswählen.", | ||||||
|  |     "deadline-invalid": "Ungültiges Datum oder Uhrzeit.", | ||||||
|  |     "deadline-past": "Die Frist muss in der Zukunft liegen.", | ||||||
|  |     "lp-required": "Du musst einen Lernpfad auswählen.", | ||||||
|  |     "lp-invalid": "Der ausgewählte Lernpfad existiert nicht.", | ||||||
|  |     "currently-no-groups": "Es gibt keine Gruppen für diese Aufgabe.", | ||||||
|  |     "random-grouping": "Gruppen zufällig erstellen", | ||||||
|  |     "drag-and-drop": "Gruppen manuell erstellen", | ||||||
|  |     "generate-groups": "erzeugen", | ||||||
|  |     "auto-generate-groups": "Gruppen gleicher Größe erstellen", | ||||||
|  |     "preview": "Vorschau", | ||||||
|  |     "current-groups": "Aktuelle Gruppen", | ||||||
|  |     "group-size-label": "Gruppengröße", | ||||||
|  |     "save": "Speichern", | ||||||
|  |     "unassigned": "Nicht zugewiesen" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -104,7 +104,6 @@ | ||||||
|     "assignLearningPath": "assign", |     "assignLearningPath": "assign", | ||||||
|     "group": "Group", |     "group": "Group", | ||||||
|     "description": "Description", |     "description": "Description", | ||||||
|     "no-submission": "no submission", |  | ||||||
|     "submission": "Submission", |     "submission": "Submission", | ||||||
|     "progress": "Progress", |     "progress": "Progress", | ||||||
|     "created": "created", |     "created": "created", | ||||||
|  | @ -122,6 +121,7 @@ | ||||||
|     "invite": "invite", |     "invite": "invite", | ||||||
|     "assignmentIndicator": "ASSIGNMENT", |     "assignmentIndicator": "ASSIGNMENT", | ||||||
|     "searchAllLearningPathsTitle": "Search all learning paths", |     "searchAllLearningPathsTitle": "Search all learning paths", | ||||||
|  |     "not-in-group-message": "You are not part of a group yet", | ||||||
|     "searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.", |     "searchAllLearningPathsDescription": "You didn't find what you were looking for? Click here to search our whole database of available learning paths.", | ||||||
|     "no-students-found": "This class has no students.", |     "no-students-found": "This class has no students.", | ||||||
|     "no-invitations-found": "You have no pending invitations.", |     "no-invitations-found": "You have no pending invitations.", | ||||||
|  | @ -166,5 +166,21 @@ | ||||||
|     "pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.", |     "pathContainsNonExistingLearningObjects": "At least one of the learning objects referenced in this path does not exist.", | ||||||
|     "targetAgesMandatory": "Target ages must be specified.", |     "targetAgesMandatory": "Target ages must be specified.", | ||||||
|     "hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)", |     "hintRemoveIfUnconditionalTransition": "(remove this if this should be an unconditional transition)", | ||||||
|     "hintKeywordsSeparatedBySpaces": "Keywords separated by spaces" |     "hintKeywordsSeparatedBySpaces": "Keywords separated by spaces", | ||||||
|  |     "title-required": "Title cannot be empty.", | ||||||
|  |     "class-required": "You must select a class.", | ||||||
|  |     "deadline-invalid": "Invalid date or time.", | ||||||
|  |     "deadline-past": "The deadline must be in the future.", | ||||||
|  |     "lp-required": "You must select a learning path.", | ||||||
|  |     "lp-invalid": "The selected learning path doesn't exist.", | ||||||
|  |     "currently-no-groups": "There are no groups for this assignment.", | ||||||
|  |     "random-grouping": "Randomly create groups", | ||||||
|  |     "drag-and-drop": "Manually create groups", | ||||||
|  |     "generate-groups": "generate", | ||||||
|  |     "auto-generate-groups": "Create groups of equal size", | ||||||
|  |     "preview": "Preview", | ||||||
|  |     "current-groups": "Current groups", | ||||||
|  |     "group-size-label": "Group size", | ||||||
|  |     "save": "Save", | ||||||
|  |     "unassigned": "Unassigned" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -88,7 +88,7 @@ | ||||||
|     "deny": "refuser", |     "deny": "refuser", | ||||||
|     "sent": "envoyé", |     "sent": "envoyé", | ||||||
|     "failed": "échoué", |     "failed": "échoué", | ||||||
|     "wrong": "quelque chose n'a pas fonctionné", |     "wrong": "Il y a une erreur", | ||||||
|     "created": "créé", |     "created": "créé", | ||||||
|     "callbackLoading": "Vous serez connecté...", |     "callbackLoading": "Vous serez connecté...", | ||||||
|     "loginUnexpectedError": "La connexion a échoué", |     "loginUnexpectedError": "La connexion a échoué", | ||||||
|  | @ -98,14 +98,13 @@ | ||||||
|     "groupSubmissions": "Soumissions de ce groupe", |     "groupSubmissions": "Soumissions de ce groupe", | ||||||
|     "taskCompleted": "Tâche terminée.", |     "taskCompleted": "Tâche terminée.", | ||||||
|     "submittedBy": "Soumis par", |     "submittedBy": "Soumis par", | ||||||
|     "timestamp": "Horodatage", |     "timestamp": "Date et heure", | ||||||
|     "loadSubmission": "Charger", |     "loadSubmission": "Charger", | ||||||
|     "noSubmissionsYet": "Pas encore de soumissions.", |     "noSubmissionsYet": "Pas encore de soumissions.", | ||||||
|     "viewAsGroup": "Voir la progression du groupe...", |     "viewAsGroup": "Voir la progression du groupe...", | ||||||
|     "assignLearningPath": "donner comme tâche", |     "assignLearningPath": "donner comme tâche", | ||||||
|     "group": "Groupe", |     "group": "Groupe", | ||||||
|     "description": "Description", |     "description": "Description", | ||||||
|     "no-submission": "aucune soumission", |  | ||||||
|     "submission": "Soumission", |     "submission": "Soumission", | ||||||
|     "progress": "Progrès", |     "progress": "Progrès", | ||||||
|     "remove": "supprimer", |     "remove": "supprimer", | ||||||
|  | @ -167,5 +166,21 @@ | ||||||
|     "pathContainsNonExistingLearningObjects": "Au moins un des objets d’apprentissage référencés dans ce chemin n’existe pas.", |     "pathContainsNonExistingLearningObjects": "Au moins un des objets d’apprentissage référencés dans ce chemin n’existe pas.", | ||||||
|     "targetAgesMandatory": "Les âges cibles doivent être spécifiés.", |     "targetAgesMandatory": "Les âges cibles doivent être spécifiés.", | ||||||
|     "hintRemoveIfUnconditionalTransition": "(supprimer ceci s’il s’agit d’une transition inconditionnelle)", |     "hintRemoveIfUnconditionalTransition": "(supprimer ceci s’il s’agit d’une transition inconditionnelle)", | ||||||
|     "hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces" |     "hintKeywordsSeparatedBySpaces": "Mots-clés séparés par des espaces", | ||||||
|  |     "title-required": "Le titre ne peut pas être vide.", | ||||||
|  |     "class-required": "Vous devez sélectionner une classe.", | ||||||
|  |     "deadline-invalid": "Date ou heure invalide.", | ||||||
|  |     "deadline-past": "La date limite doit être dans le futur.", | ||||||
|  |     "lp-required": "Vous devez sélectionner un parcours d'apprentissage.", | ||||||
|  |     "lp-invalid": "Le parcours d'apprentissage sélectionné n'existe pas.", | ||||||
|  |     "currently-no-groups": "Il n’y a pas de groupes pour cette tâche.", | ||||||
|  |     "random-grouping": "Créer des groupes aléatoirement", | ||||||
|  |     "drag-and-drop": "Créer des groupes manuellement", | ||||||
|  |     "generate-groups": "générer", | ||||||
|  |     "auto-generate-groups": "Créer des groupes de taille égale", | ||||||
|  |     "preview": "Aperçu", | ||||||
|  |     "current-groups": "Groupes actuels", | ||||||
|  |     "group-size-label": "Taille des groupes", | ||||||
|  |     "save": "Enregistrer", | ||||||
|  |     "unassigned": "Non assigné" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -105,7 +105,6 @@ | ||||||
|     "assignLearningPath": "Als opdracht geven", |     "assignLearningPath": "Als opdracht geven", | ||||||
|     "group": "Groep", |     "group": "Groep", | ||||||
|     "description": "Beschrijving", |     "description": "Beschrijving", | ||||||
|     "no-submission": "geen indiening", |  | ||||||
|     "submission": "Indiening", |     "submission": "Indiening", | ||||||
|     "progress": "Vooruitgang", |     "progress": "Vooruitgang", | ||||||
|     "remove": "verwijder", |     "remove": "verwijder", | ||||||
|  | @ -166,5 +165,21 @@ | ||||||
|     "pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.", |     "pathContainsNonExistingLearningObjects": "Ten minste één van de leerobjecten in dit pad bestaat niet.", | ||||||
|     "targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.", |     "targetAgesMandatory": "Doelleeftijden moeten worden opgegeven.", | ||||||
|     "hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)", |     "hintRemoveIfUnconditionalTransition": "(verwijder dit voor onvoorwaardelijke overgangen)", | ||||||
|     "hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties" |     "hintKeywordsSeparatedBySpaces": "Trefwoorden gescheiden door spaties", | ||||||
|  |     "title-required": "Titel mag niet leeg zijn.", | ||||||
|  |     "class-required": "Je moet een klas selecteren.", | ||||||
|  |     "deadline-invalid": "Ongeldige datum of tijd.", | ||||||
|  |     "deadline-past": "De deadline moet in de toekomst liggen.", | ||||||
|  |     "lp-required": "Je moet een leerpad selecteren.", | ||||||
|  |     "lp-invalid": "Het geselecteerde leerpad bestaat niet.", | ||||||
|  |     "currently-no-groups": "Er zijn geen groepen voor deze opdracht.", | ||||||
|  |     "random-grouping": "Groepeer willekeurig", | ||||||
|  |     "drag-and-drop": "Stel groepen handmatig samen", | ||||||
|  |     "generate-groups": "genereren", | ||||||
|  |     "auto-generate-groups": "Maak groepen van gelijke grootte", | ||||||
|  |     "preview": "Voorbeeld", | ||||||
|  |     "current-groups": "Huidige groepen", | ||||||
|  |     "group-size-label": "Grootte van groepen", | ||||||
|  |     "save": "Opslaan", | ||||||
|  |     "unassigned": "Niet toegewezen" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -117,7 +117,7 @@ export function useAssignmentQuery( | ||||||
| export function useCreateAssignmentMutation(): UseMutationReturnType< | export function useCreateAssignmentMutation(): UseMutationReturnType< | ||||||
|     AssignmentResponse, |     AssignmentResponse, | ||||||
|     Error, |     Error, | ||||||
|     { cid: string; data: AssignmentDTO }, |     { cid: string; data: Partial<AssignmentDTO> }, | ||||||
|     unknown |     unknown | ||||||
| > { | > { | ||||||
|     const queryClient = useQueryClient(); |     const queryClient = useQueryClient(); | ||||||
|  | @ -181,7 +181,7 @@ export function useAssignmentSubmissionsQuery( | ||||||
| 
 | 
 | ||||||
|     return useQuery({ |     return useQuery({ | ||||||
|         queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)), |         queryKey: computed(() => assignmentSubmissionsQueryKey(cid!, an!, f)), | ||||||
|         queryFn: async () => new AssignmentController(cid!).getSubmissions(gn!, f), |         queryFn: async () => new AssignmentController(cid!).getSubmissions(an!, f), | ||||||
|         enabled: () => checkEnabled(cid, an, gn), |         enabled: () => checkEnabled(cid, an, gn), | ||||||
|     }); |     }); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -12,6 +12,7 @@ import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||||
| import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; | import type { JoinRequestResponse, JoinRequestsResponse, StudentsResponse } from "@/controllers/students.ts"; | ||||||
| import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | import type { TeacherDTO } from "@dwengo-1/common/interfaces/teacher"; | ||||||
| import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts"; | import { studentJoinRequestQueryKey, studentJoinRequestsQueryKey } from "@/queries/students.ts"; | ||||||
|  | import type { AssignmentsResponse } from "@/controllers/assignments"; | ||||||
| 
 | 
 | ||||||
| const teacherController = new TeacherController(); | const teacherController = new TeacherController(); | ||||||
| 
 | 
 | ||||||
|  | @ -28,6 +29,10 @@ function teacherClassesQueryKey(username: string, full: boolean): [string, strin | ||||||
|     return ["teacher-classes", username, full]; |     return ["teacher-classes", username, full]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function teacherAssignmentsQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||||
|  |     return ["teacher-assignments", username, full]; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| function teacherStudentsQueryKey(username: string, full: boolean): [string, string, boolean] { | function teacherStudentsQueryKey(username: string, full: boolean): [string, string, boolean] { | ||||||
|     return ["teacher-students", username, full]; |     return ["teacher-students", username, full]; | ||||||
| } | } | ||||||
|  | @ -64,6 +69,17 @@ export function useTeacherClassesQuery( | ||||||
|     }); |     }); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export function useTeacherAssignmentsQuery( | ||||||
|  |     username: MaybeRefOrGetter<string | undefined>, | ||||||
|  |     full: MaybeRefOrGetter<boolean> = false, | ||||||
|  | ): UseQueryReturnType<AssignmentsResponse, Error> { | ||||||
|  |     return useQuery({ | ||||||
|  |         queryKey: computed(() => teacherAssignmentsQueryKey(toValue(username)!, toValue(full))), | ||||||
|  |         queryFn: async () => teacherController.getAssignments(toValue(username)!, toValue(full)), | ||||||
|  |         enabled: () => Boolean(toValue(username)), | ||||||
|  |     }); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export function useTeacherStudentsQuery( | export function useTeacherStudentsQuery( | ||||||
|     username: MaybeRefOrGetter<string | undefined>, |     username: MaybeRefOrGetter<string | undefined>, | ||||||
|     full: MaybeRefOrGetter<boolean> = false, |     full: MaybeRefOrGetter<boolean> = false, | ||||||
|  |  | ||||||
|  | @ -1,76 +0,0 @@ | ||||||
| /** |  | ||||||
|  * Validation rule for the assignment title. |  | ||||||
|  * |  | ||||||
|  * Ensures that the title is not empty. |  | ||||||
|  */ |  | ||||||
| export const assignmentTitleRules = [ |  | ||||||
|     (value: string): string | boolean => { |  | ||||||
|         if (value?.length >= 1) { |  | ||||||
|             return true; |  | ||||||
|         } // Title must not be empty
 |  | ||||||
|         return "Title cannot be empty."; |  | ||||||
|     }, |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Validation rule for the learning path selection. |  | ||||||
|  * |  | ||||||
|  * Ensures that a valid learning path is selected. |  | ||||||
|  */ |  | ||||||
| export const learningPathRules = [ |  | ||||||
|     (value: { hruid: string; title: string }): string | boolean => { |  | ||||||
|         if (value && value.hruid) { |  | ||||||
|             return true; // Valid if hruid is present
 |  | ||||||
|         } |  | ||||||
|         return "You must select a learning path."; |  | ||||||
|     }, |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Validation rule for the classes selection. |  | ||||||
|  * |  | ||||||
|  * Ensures that at least one class is selected. |  | ||||||
|  */ |  | ||||||
| export const classRules = [ |  | ||||||
|     (value: string): string | boolean => { |  | ||||||
|         if (value) { |  | ||||||
|             return true; |  | ||||||
|         } |  | ||||||
|         return "You must select at least one class."; |  | ||||||
|     }, |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| /** |  | ||||||
|  * Validation rule for the deadline field. |  | ||||||
|  * |  | ||||||
|  * Ensures that a valid deadline is selected and is in the future. |  | ||||||
|  */ |  | ||||||
| export const deadlineRules = [ |  | ||||||
|     (value: string): string | boolean => { |  | ||||||
|         if (!value) { |  | ||||||
|             return "You must set a deadline."; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         const selectedDateTime = new Date(value); |  | ||||||
|         const now = new Date(); |  | ||||||
| 
 |  | ||||||
|         if (isNaN(selectedDateTime.getTime())) { |  | ||||||
|             return "Invalid date or time."; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         if (selectedDateTime <= now) { |  | ||||||
|             return "The deadline must be in the future."; |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         return true; |  | ||||||
|     }, |  | ||||||
| ]; |  | ||||||
| 
 |  | ||||||
| export const descriptionRules = [ |  | ||||||
|     (value: string): string | boolean => { |  | ||||||
|         if (!value || value.trim() === "") { |  | ||||||
|             return "Description cannot be empty."; |  | ||||||
|         } |  | ||||||
|         return true; |  | ||||||
|     }, |  | ||||||
| ]; |  | ||||||
							
								
								
									
										5
									
								
								frontend/src/utils/assignment-utils.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								frontend/src/utils/assignment-utils.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,5 @@ | ||||||
|  | import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|  | 
 | ||||||
|  | export function calculateProgress(lp: LearningPath): number { | ||||||
|  |     return ((lp.amountOfNodes - lp.amountOfNodesLeft) / lp.amountOfNodes) * 100; | ||||||
|  | } | ||||||
|  | @ -1,19 +1,15 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import { useI18n } from "vue-i18n"; |     import { useI18n } from "vue-i18n"; | ||||||
|     import { computed, onMounted, ref, watch } from "vue"; |     import { computed, onMounted, ref, watch } from "vue"; | ||||||
|     import GroupSelector from "@/components/assignments/GroupSelector.vue"; |  | ||||||
|     import { assignmentTitleRules, classRules, descriptionRules, learningPathRules } from "@/utils/assignment-rules.ts"; |  | ||||||
|     import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue"; |  | ||||||
|     import auth from "@/services/auth/auth-service.ts"; |     import auth from "@/services/auth/auth-service.ts"; | ||||||
|     import { useTeacherClassesQuery } from "@/queries/teachers.ts"; |     import { useTeacherClassesQuery } from "@/queries/teachers.ts"; | ||||||
|     import { useRouter } from "vue-router"; |     import { useRouter, useRoute } from "vue-router"; | ||||||
|     import { useGetAllLearningPaths } from "@/queries/learning-paths.ts"; |     import { useGetAllLearningPaths } from "@/queries/learning-paths.ts"; | ||||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; |     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; |     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
|     import type { ClassesResponse } from "@/controllers/classes.ts"; |     import type { ClassesResponse } from "@/controllers/classes.ts"; | ||||||
|     import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; |     import type { AssignmentDTO } from "@dwengo-1/common/interfaces/assignment"; | ||||||
|     import { useCreateAssignmentMutation } from "@/queries/assignments.ts"; |     import { useCreateAssignmentMutation } from "@/queries/assignments.ts"; | ||||||
|     import { useRoute } from "vue-router"; |  | ||||||
|     import { AccountType } from "@dwengo-1/common/util/account-types"; |     import { AccountType } from "@dwengo-1/common/util/account-types"; | ||||||
| 
 | 
 | ||||||
|     const route = useRoute(); |     const route = useRoute(); | ||||||
|  | @ -23,12 +19,9 @@ | ||||||
|     const username = ref<string>(""); |     const username = ref<string>(""); | ||||||
| 
 | 
 | ||||||
|     onMounted(async () => { |     onMounted(async () => { | ||||||
|         // Redirect student |  | ||||||
|         if (role.value === AccountType.Student) { |         if (role.value === AccountType.Student) { | ||||||
|             await router.push("/user"); |             await router.push("/user"); | ||||||
|         } |         } | ||||||
| 
 |  | ||||||
|         // Get the user's username |  | ||||||
|         const user = await auth.loadUser(); |         const user = await auth.loadUser(); | ||||||
|         username.value = user?.profile?.preferred_username ?? ""; |         username.value = user?.profile?.preferred_username ?? ""; | ||||||
|     }); |     }); | ||||||
|  | @ -36,32 +29,25 @@ | ||||||
|     const language = computed(() => locale.value); |     const language = computed(() => locale.value); | ||||||
|     const form = ref(); |     const form = ref(); | ||||||
| 
 | 
 | ||||||
|     //Fetch all learning paths |  | ||||||
|     const learningPathsQueryResults = useGetAllLearningPaths(language); |     const learningPathsQueryResults = useGetAllLearningPaths(language); | ||||||
| 
 |  | ||||||
|     // Fetch and store all the teacher's classes |  | ||||||
|     const classesQueryResults = useTeacherClassesQuery(username, true); |     const classesQueryResults = useTeacherClassesQuery(username, true); | ||||||
| 
 | 
 | ||||||
|     const selectedClass = ref(undefined); |     const selectedClass = ref(undefined); | ||||||
| 
 |  | ||||||
|     const assignmentTitle = ref(""); |     const assignmentTitle = ref(""); | ||||||
|     const selectedLearningPath = ref(route.query.hruid || undefined); |  | ||||||
| 
 | 
 | ||||||
|     // Disable combobox when learningPath prop is passed |     const selectedLearningPath = ref<LearningPath | undefined>(undefined); | ||||||
|     const lpIsSelected = route.query.hruid !== undefined; |     const lpIsSelected = ref(false); | ||||||
|     const deadline = ref(new Date()); |  | ||||||
|     const description = ref(""); |  | ||||||
|     const groups = ref<string[][]>([]); |  | ||||||
| 
 | 
 | ||||||
|     // New group is added to the list |     watch(learningPathsQueryResults.data, (data) => { | ||||||
|     function addGroupToList(students: string[]): void { |         const hruidFromRoute = route.query.hruid?.toString(); | ||||||
|         if (students.length) { |         if (!hruidFromRoute || !data) return; | ||||||
|             groups.value = [...groups.value, students]; | 
 | ||||||
|  |         // Verify if the hruid given in the url is valid before accepting it | ||||||
|  |         const matchedLP = data.find((lp) => lp.hruid === hruidFromRoute); | ||||||
|  |         if (matchedLP) { | ||||||
|  |             selectedLearningPath.value = matchedLP; | ||||||
|  |             lpIsSelected.value = true; | ||||||
|         } |         } | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     watch(selectedClass, () => { |  | ||||||
|         groups.value = []; |  | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     const { mutate, data, isSuccess } = useCreateAssignmentMutation(); |     const { mutate, data, isSuccess } = useCreateAssignmentMutation(); | ||||||
|  | @ -76,134 +62,144 @@ | ||||||
|         const { valid } = await form.value.validate(); |         const { valid } = await form.value.validate(); | ||||||
|         if (!valid) return; |         if (!valid) return; | ||||||
| 
 | 
 | ||||||
|         let lp = selectedLearningPath.value; |         const lp = lpIsSelected.value ? route.query.hruid?.toString() : selectedLearningPath.value?.hruid; | ||||||
|         if (!lpIsSelected) { |         if (!lp) { | ||||||
|             lp = selectedLearningPath.value?.hruid; |             return; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         const assignmentDTO: AssignmentDTO = { |         const assignmentDTO: AssignmentDTO = { | ||||||
|             id: 0, |             id: 0, | ||||||
|             within: selectedClass.value?.id || "", |             within: selectedClass.value?.id || "", | ||||||
|             title: assignmentTitle.value, |             title: assignmentTitle.value, | ||||||
|             description: description.value, |             description: "", | ||||||
|             learningPath: lp || "", |             learningPath: lp, | ||||||
|             deadline: deadline.value, |  | ||||||
|             language: language.value, |             language: language.value, | ||||||
|             groups: groups.value, |             deadline: null, | ||||||
|  |             groups: [], | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         mutate({ cid: assignmentDTO.within, data: assignmentDTO }); |         mutate({ cid: assignmentDTO.within, data: assignmentDTO }); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     const learningPathRules = [ | ||||||
|  |         (value: LearningPath): string | boolean => { | ||||||
|  |             if (lpIsSelected.value) return true; | ||||||
|  | 
 | ||||||
|  |             if (!value) return t("lp-required"); | ||||||
|  | 
 | ||||||
|  |             const allLPs = learningPathsQueryResults.data.value ?? []; | ||||||
|  |             const valid = allLPs.some((lp) => lp.hruid === value?.hruid); | ||||||
|  |             return valid || t("lp-invalid"); | ||||||
|  |         }, | ||||||
|  |     ]; | ||||||
|  | 
 | ||||||
|  |     const assignmentTitleRules = [ | ||||||
|  |         (value: string): string | boolean => { | ||||||
|  |             if (value?.length >= 1) { | ||||||
|  |                 return true; | ||||||
|  |             } // Title must not be empty | ||||||
|  |             return t("title-required"); | ||||||
|  |         }, | ||||||
|  |     ]; | ||||||
|  | 
 | ||||||
|  |     const classRules = [ | ||||||
|  |         (value: string): string | boolean => { | ||||||
|  |             if (value) { | ||||||
|  |                 return true; | ||||||
|  |             } | ||||||
|  |             return t("class-required"); | ||||||
|  |         }, | ||||||
|  |     ]; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|     <div class="main-container"> |     <div class="main-container"> | ||||||
|         <h1 class="h1">{{ t("new-assignment") }}</h1> |         <h1 class="h1">{{ t("new-assignment") }}</h1> | ||||||
|         <v-card class="form-card"> | 
 | ||||||
|  |         <v-card class="form-card elevation-2 pa-6"> | ||||||
|             <v-form |             <v-form | ||||||
|                 ref="form" |                 ref="form" | ||||||
|                 class="form-container" |                 class="form-container" | ||||||
|                 validate-on="submit lazy" |                 validate-on="submit lazy" | ||||||
|                 @submit.prevent="submitFormHandler" |                 @submit.prevent="submitFormHandler" | ||||||
|             > |             > | ||||||
|                 <v-container class="step-container"> |                 <v-container class="step-container pa-0"> | ||||||
|                     <v-card-text> |                     <!-- Title field --> | ||||||
|                     <v-text-field |                     <v-text-field | ||||||
|                         v-model="assignmentTitle" |                         v-model="assignmentTitle" | ||||||
|                         :label="t('title')" |                         :label="t('title')" | ||||||
|                         :rules="assignmentTitleRules" |                         :rules="assignmentTitleRules" | ||||||
|                             density="compact" |                         density="comfortable" | ||||||
|                             variant="outlined" |                         variant="solo-filled" | ||||||
|  |                         prepend-inner-icon="mdi-format-title" | ||||||
|                         clearable |                         clearable | ||||||
|                         required |                         required | ||||||
|                         ></v-text-field> |                     /> | ||||||
|                     </v-card-text> |  | ||||||
| 
 | 
 | ||||||
|  |                     <!-- Learning Path keuze --> | ||||||
|                     <using-query-result |                     <using-query-result | ||||||
|                         :query-result="learningPathsQueryResults" |                         :query-result="learningPathsQueryResults" | ||||||
|                         v-slot="{ data }: { data: LearningPath[] }" |                         v-slot="{ data }: { data: LearningPath[] }" | ||||||
|                     > |                     > | ||||||
|                         <v-card-text> |  | ||||||
|                         <v-combobox |                         <v-combobox | ||||||
|                             v-model="selectedLearningPath" |                             v-model="selectedLearningPath" | ||||||
|                             :items="data" |                             :items="data" | ||||||
|                             :label="t('choose-lp')" |                             :label="t('choose-lp')" | ||||||
|                                 :rules="learningPathRules" |                             :rules="lpIsSelected ? [] : learningPathRules" | ||||||
|                                 variant="outlined" |                             variant="solo-filled" | ||||||
|                             clearable |                             clearable | ||||||
|                                 hide-details |  | ||||||
|                                 density="compact" |  | ||||||
|                                 append-inner-icon="mdi-magnify" |  | ||||||
|                             item-title="title" |                             item-title="title" | ||||||
|                                 item-value="hruid" |  | ||||||
|                                 required |  | ||||||
|                             :disabled="lpIsSelected" |                             :disabled="lpIsSelected" | ||||||
|                                 :filter=" |                             return-object | ||||||
|                                     (item, query: string) => item.title.toLowerCase().includes(query.toLowerCase()) |                         /> | ||||||
|                                 " |  | ||||||
|                             ></v-combobox> |  | ||||||
|                         </v-card-text> |  | ||||||
|                     </using-query-result> |                     </using-query-result> | ||||||
| 
 | 
 | ||||||
|  |                     <!-- Klas keuze --> | ||||||
|                     <using-query-result |                     <using-query-result | ||||||
|                         :query-result="classesQueryResults" |                         :query-result="classesQueryResults" | ||||||
|                         v-slot="{ data }: { data: ClassesResponse }" |                         v-slot="{ data }: { data: ClassesResponse }" | ||||||
|                     > |                     > | ||||||
|                         <v-card-text> |  | ||||||
|                         <v-combobox |                         <v-combobox | ||||||
|                             v-model="selectedClass" |                             v-model="selectedClass" | ||||||
|                             :items="data?.classes ?? []" |                             :items="data?.classes ?? []" | ||||||
|                             :label="t('pick-class')" |                             :label="t('pick-class')" | ||||||
|                             :rules="classRules" |                             :rules="classRules" | ||||||
|                                 variant="outlined" |                             variant="solo-filled" | ||||||
|                             clearable |                             clearable | ||||||
|                                 hide-details |                             density="comfortable" | ||||||
|                                 density="compact" |                             chips | ||||||
|                                 append-inner-icon="mdi-magnify" |                             hide-no-data | ||||||
|  |                             hide-selected | ||||||
|                             item-title="displayName" |                             item-title="displayName" | ||||||
|                             item-value="id" |                             item-value="id" | ||||||
|                                 required |                             prepend-inner-icon="mdi-account-multiple" | ||||||
|                             ></v-combobox> |                         /> | ||||||
|                         </v-card-text> |  | ||||||
|                     </using-query-result> |                     </using-query-result> | ||||||
| 
 | 
 | ||||||
|                     <GroupSelector |                     <!-- Submit & Cancel --> | ||||||
|                         :classId="selectedClass?.id" |                     <v-divider class="my-6" /> | ||||||
|                         :groups="groups" |  | ||||||
|                         @groupCreated="addGroupToList" |  | ||||||
|                     /> |  | ||||||
| 
 | 
 | ||||||
|                     <!-- Counter for created groups --> |                     <div class="d-flex justify-end ga-2"> | ||||||
|                     <v-card-text v-if="groups.length"> |  | ||||||
|                         <strong>Created Groups: {{ groups.length }}</strong> |  | ||||||
|                     </v-card-text> |  | ||||||
|                     <DeadlineSelector v-model:deadline="deadline" /> |  | ||||||
|                     <v-card-text> |  | ||||||
|                         <v-textarea |  | ||||||
|                             v-model="description" |  | ||||||
|                             :label="t('description')" |  | ||||||
|                             variant="outlined" |  | ||||||
|                             density="compact" |  | ||||||
|                             auto-grow |  | ||||||
|                             rows="3" |  | ||||||
|                             :rules="descriptionRules" |  | ||||||
|                         ></v-textarea> |  | ||||||
|                     </v-card-text> |  | ||||||
|                     <v-card-text> |  | ||||||
|                         <v-btn |                         <v-btn | ||||||
|                             class="mt-2" |                             color="primary" | ||||||
|                             color="secondary" |  | ||||||
|                             type="submit" |                             type="submit" | ||||||
|                             block |                             size="small" | ||||||
|                             >{{ t("submit") }} |                             prepend-icon="mdi-check-circle" | ||||||
|  |                             elevation="1" | ||||||
|  |                         > | ||||||
|  |                             {{ t("submit") }} | ||||||
|                         </v-btn> |                         </v-btn> | ||||||
|  | 
 | ||||||
|                         <v-btn |                         <v-btn | ||||||
|                             to="/user/assignment" |                             to="/user/assignment" | ||||||
|                             color="grey" |                             color="grey" | ||||||
|                             block |                             size="small" | ||||||
|                             >{{ t("cancel") }} |                             variant="text" | ||||||
|  |                             prepend-icon="mdi-close-circle" | ||||||
|  |                         > | ||||||
|  |                             {{ t("cancel") }} | ||||||
|                         </v-btn> |                         </v-btn> | ||||||
|                     </v-card-text> |                     </div> | ||||||
|                 </v-container> |                 </v-container> | ||||||
|             </v-form> |             </v-form> | ||||||
|         </v-card> |         </v-card> | ||||||
|  | @ -215,46 +211,55 @@ | ||||||
|         display: flex; |         display: flex; | ||||||
|         flex-direction: column; |         flex-direction: column; | ||||||
|         align-items: center; |         align-items: center; | ||||||
|         justify-content: center; |         justify-content: start; | ||||||
|  |         padding-top: 32px; | ||||||
|         text-align: center; |         text-align: center; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     .form-card { |     .form-card { | ||||||
|         display: flex; |         width: 100%; | ||||||
|         flex-direction: column; |         max-width: 720px; | ||||||
|         align-items: center; |         border-radius: 16px; | ||||||
|         justify-content: center; |  | ||||||
|         width: 55%; |  | ||||||
|         /*padding: 1%;*/ |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     .form-container { |     .form-container { | ||||||
|         width: 100%; |  | ||||||
|         display: flex; |         display: flex; | ||||||
|         flex-direction: column; |         flex-direction: column; | ||||||
|  |         gap: 24px; | ||||||
|  |         width: 100%; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     .step-container { |     .step-container { | ||||||
|         display: flex; |         display: flex; | ||||||
|         justify-content: center; |  | ||||||
|         flex-direction: column; |         flex-direction: column; | ||||||
|         min-height: 200px; |         gap: 24px; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @media (max-width: 1000px) { |     @media (max-width: 1000px) { | ||||||
|         .form-card { |         .form-card { | ||||||
|             width: 70%; |             width: 85%; | ||||||
|             padding: 1%; |             padding: 1%; | ||||||
|         } |         } | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|         .step-container { |     @media (max-width: 600px) { | ||||||
|             min-height: 300px; |         h1 { | ||||||
|  |             font-size: 32px; | ||||||
|  |             text-align: center; | ||||||
|  |             margin-left: 0; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     @media (max-width: 650px) { |     @media (max-width: 400px) { | ||||||
|         .form-card { |         h1 { | ||||||
|             width: 95%; |             font-size: 24px; | ||||||
|  |             text-align: center; | ||||||
|  |             margin-left: 0; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     .v-card { | ||||||
|  |         border: 2px solid #0e6942; | ||||||
|  |         border-radius: 12px; | ||||||
|  |     } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|  | @ -1,13 +1,9 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import auth from "@/services/auth/auth-service.ts"; |     import auth from "@/services/auth/auth-service.ts"; | ||||||
|     import { computed, type Ref, ref, watchEffect } from "vue"; |     import { computed, ref } from "vue"; | ||||||
|     import StudentAssignment from "@/views/assignments/StudentAssignment.vue"; |     import StudentAssignment from "@/views/assignments/StudentAssignment.vue"; | ||||||
|     import TeacherAssignment from "@/views/assignments/TeacherAssignment.vue"; |     import TeacherAssignment from "@/views/assignments/TeacherAssignment.vue"; | ||||||
|     import { useRoute } from "vue-router"; |     import { useRoute } from "vue-router"; | ||||||
|     import type { Language } from "@/data-objects/language.ts"; |  | ||||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; |  | ||||||
|     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; |  | ||||||
|     import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; |  | ||||||
|     import { AccountType } from "@dwengo-1/common/util/account-types"; |     import { AccountType } from "@dwengo-1/common/util/account-types"; | ||||||
| 
 | 
 | ||||||
|     const role = auth.authState.activeRole; |     const role = auth.authState.activeRole; | ||||||
|  | @ -16,58 +12,18 @@ | ||||||
|     const route = useRoute(); |     const route = useRoute(); | ||||||
|     const classId = ref<string>(route.params.classId as string); |     const classId = ref<string>(route.params.classId as string); | ||||||
|     const assignmentId = ref(Number(route.params.id)); |     const assignmentId = ref(Number(route.params.id)); | ||||||
| 
 |  | ||||||
|     function useGroupsWithProgress( |  | ||||||
|         groups: Ref<GroupDTO[]>, |  | ||||||
|         hruid: Ref<string>, |  | ||||||
|         language: Ref<string>, |  | ||||||
|     ): { groupProgressMap: Map<number, number> } { |  | ||||||
|         const groupProgressMap: Map<number, number> = new Map<number, number>(); |  | ||||||
| 
 |  | ||||||
|         watchEffect(() => { |  | ||||||
|             // Clear existing entries to avoid stale data |  | ||||||
|             groupProgressMap.clear(); |  | ||||||
| 
 |  | ||||||
|             const lang = ref(language.value as Language); |  | ||||||
| 
 |  | ||||||
|             groups.value.forEach((group) => { |  | ||||||
|                 const groupKey = group.groupNumber; |  | ||||||
|                 const forGroup = ref({ |  | ||||||
|                     forGroup: groupKey, |  | ||||||
|                     assignmentNo: assignmentId, |  | ||||||
|                     classId: classId, |  | ||||||
|                 }); |  | ||||||
| 
 |  | ||||||
|                 const query = useGetLearningPathQuery(hruid.value, lang, forGroup); |  | ||||||
| 
 |  | ||||||
|                 const data = query.data.value; |  | ||||||
| 
 |  | ||||||
|                 groupProgressMap.set(groupKey, data ? calculateProgress(data) : 0); |  | ||||||
|             }); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         return { |  | ||||||
|             groupProgressMap, |  | ||||||
|         }; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     function calculateProgress(lp: LearningPath): number { |  | ||||||
|         return ((lp.amountOfNodes - lp.amountOfNodesLeft) / lp.amountOfNodes) * 100; |  | ||||||
|     } |  | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|     <TeacherAssignment |     <TeacherAssignment | ||||||
|         :class-id="classId" |         :class-id="classId" | ||||||
|         :assignment-id="assignmentId" |         :assignment-id="assignmentId" | ||||||
|         :use-groups-with-progress="useGroupsWithProgress" |  | ||||||
|         v-if="isTeacher" |         v-if="isTeacher" | ||||||
|     > |     > | ||||||
|     </TeacherAssignment> |     </TeacherAssignment> | ||||||
|     <StudentAssignment |     <StudentAssignment | ||||||
|         :class-id="classId" |         :class-id="classId" | ||||||
|         :assignment-id="assignmentId" |         :assignment-id="assignmentId" | ||||||
|         :use-groups-with-progress="useGroupsWithProgress" |  | ||||||
|         v-else |         v-else | ||||||
|     > |     > | ||||||
|     </StudentAssignment> |     </StudentAssignment> | ||||||
|  |  | ||||||
|  | @ -1,28 +1,26 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import { ref, computed, type Ref } from "vue"; |     import { ref, computed, watchEffect } from "vue"; | ||||||
|     import auth from "@/services/auth/auth-service.ts"; |     import auth from "@/services/auth/auth-service.ts"; | ||||||
|     import { useI18n } from "vue-i18n"; |     import { useI18n } from "vue-i18n"; | ||||||
|     import { useAssignmentQuery } from "@/queries/assignments.ts"; |  | ||||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; |     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||||
|     import type { AssignmentResponse } from "@/controllers/assignments.ts"; |  | ||||||
|     import { asyncComputed } from "@vueuse/core"; |     import { asyncComputed } from "@vueuse/core"; | ||||||
|     import { useStudentsByUsernamesQuery } from "@/queries/students.ts"; |     import { | ||||||
|     import { useGroupsQuery } from "@/queries/groups.ts"; |         useStudentAssignmentsQuery, | ||||||
|  |         useStudentGroupsQuery, | ||||||
|  |         useStudentsByUsernamesQuery, | ||||||
|  |     } from "@/queries/students.ts"; | ||||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; |     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||||
|     import type { Language } from "@/data-objects/language.ts"; |     import type { Language } from "@/data-objects/language.ts"; | ||||||
|     import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; |     import { calculateProgress } from "@/utils/assignment-utils.ts"; | ||||||
|  |     import type { LearningPath } from "@/data-objects/learning-paths/learning-path.ts"; | ||||||
| 
 | 
 | ||||||
|     const props = defineProps<{ |     const props = defineProps<{ | ||||||
|         classId: string; |         classId: string; | ||||||
|         assignmentId: number; |         assignmentId: number; | ||||||
|         useGroupsWithProgress: ( |  | ||||||
|             groups: Ref<GroupDTO[]>, |  | ||||||
|             hruid: Ref<string>, |  | ||||||
|             language: Ref<Language>, |  | ||||||
|         ) => { groupProgressMap: Map<number, number> }; |  | ||||||
|     }>(); |     }>(); | ||||||
| 
 | 
 | ||||||
|     const { t } = useI18n(); |     const { t } = useI18n(); | ||||||
|  |     const lang = ref(); | ||||||
|     const learningPath = ref(); |     const learningPath = ref(); | ||||||
|     // Get the user's username/id |     // Get the user's username/id | ||||||
|     const username = asyncComputed(async () => { |     const username = asyncComputed(async () => { | ||||||
|  | @ -30,45 +28,70 @@ | ||||||
|         return user?.profile?.preferred_username ?? undefined; |         return user?.profile?.preferred_username ?? undefined; | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); |     const assignmentsQueryResult = useStudentAssignmentsQuery(username, true); | ||||||
|     learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath; |  | ||||||
| 
 | 
 | ||||||
|     const submitted = ref(false); //TODO: update by fetching submissions and check if group submitted |     const assignment = computed(() => { | ||||||
|  |         const assignments = assignmentsQueryResult.data.value?.assignments; | ||||||
|  |         if (!assignments) return undefined; | ||||||
|  | 
 | ||||||
|  |         return assignments.find((a) => a.id === props.assignmentId && a.within === props.classId); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     learningPath.value = assignment.value?.learningPath; | ||||||
|  | 
 | ||||||
|  |     const groupsQueryResult = useStudentGroupsQuery(username, true); | ||||||
|  |     const group = computed(() => { | ||||||
|  |         const groups = groupsQueryResult.data.value?.groups as GroupDTO[]; | ||||||
|  | 
 | ||||||
|  |         if (!groups) return undefined; | ||||||
|  | 
 | ||||||
|  |         // Sort by original groupNumber | ||||||
|  |         const sortedGroups = [...groups].sort((a, b) => a.groupNumber - b.groupNumber); | ||||||
|  | 
 | ||||||
|  |         return sortedGroups | ||||||
|  |             .map((group, index) => ({ | ||||||
|  |                 ...group, | ||||||
|  |                 groupNo: index + 1, // Renumbered index | ||||||
|  |             })) | ||||||
|  |             .find((group) => group.members?.some((m) => m.username === username.value)); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     watchEffect(() => { | ||||||
|  |         learningPath.value = assignment.value?.learningPath; | ||||||
|  |         lang.value = assignment.value?.language as Language; | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const learningPathParams = computed(() => { | ||||||
|  |         if (!group.value || !learningPath.value || !lang.value) return undefined; | ||||||
|  | 
 | ||||||
|  |         return { | ||||||
|  |             forGroup: group.value.groupNumber, | ||||||
|  |             assignmentNo: props.assignmentId, | ||||||
|  |             classId: props.classId, | ||||||
|  |         }; | ||||||
|  |     }); | ||||||
| 
 | 
 | ||||||
|     const lpQueryResult = useGetLearningPathQuery( |     const lpQueryResult = useGetLearningPathQuery( | ||||||
|         computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), |         () => learningPath.value, | ||||||
|         computed(() => assignmentQueryResult.data.value?.assignment.language as Language), |         () => lang.value, | ||||||
|  |         () => learningPathParams.value, | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); |     const progressColor = computed(() => { | ||||||
|     const group = computed(() => |         const progress = calculateProgress(lpQueryResult.data.value as LearningPath); | ||||||
|         groupsQueryResult?.data.value?.groups.find((group) => |         if (progress >= 100) return "success"; | ||||||
|             group.members?.some((m) => m.username === username.value), |         if (progress >= 50) return "warning"; | ||||||
|         ), |         return "error"; | ||||||
|     ); |     }); | ||||||
| 
 | 
 | ||||||
|     const _groupArray = computed(() => (group.value ? [group.value] : [])); |     const studentQueries = useStudentsByUsernamesQuery(() => (group.value?.members as string[]) ?? undefined); | ||||||
|     const progressValue = ref(0); |  | ||||||
|     /* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar |  | ||||||
| Const {groupProgressMap} = props.useGroupsWithProgress( |  | ||||||
| groupArray, |  | ||||||
| learningPath, |  | ||||||
| language |  | ||||||
| ); |  | ||||||
| */ |  | ||||||
| 
 |  | ||||||
|     // Assuming group.value.members is a list of usernames TODO: case when it's StudentDTO's |  | ||||||
|     const studentQueries = useStudentsByUsernamesQuery(() => group.value?.members as string[]); |  | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|     <div class="container"> |     <div class="container"> | ||||||
|         <using-query-result |         <using-query-result :query-result="assignmentsQueryResult"> | ||||||
|             :query-result="assignmentQueryResult" |  | ||||||
|             v-slot="{ data }: { data: AssignmentResponse }" |  | ||||||
|         > |  | ||||||
|             <v-card |             <v-card | ||||||
|                 v-if="data" |                 v-if="assignment" | ||||||
|                 class="assignment-card" |                 class="assignment-card" | ||||||
|             > |             > | ||||||
|                 <div class="top-buttons"> |                 <div class="top-buttons"> | ||||||
|  | @ -80,17 +103,8 @@ language | ||||||
|                     > |                     > | ||||||
|                         <v-icon>mdi-arrow-left</v-icon> |                         <v-icon>mdi-arrow-left</v-icon> | ||||||
|                     </v-btn> |                     </v-btn> | ||||||
| 
 |  | ||||||
|                     <v-chip |  | ||||||
|                         v-if="submitted" |  | ||||||
|                         class="ma-2 top-right-btn" |  | ||||||
|                         label |  | ||||||
|                         color="success" |  | ||||||
|                     > |  | ||||||
|                         {{ t("submitted") }} |  | ||||||
|                     </v-chip> |  | ||||||
|                 </div> |                 </div> | ||||||
|                 <v-card-title class="text-h4 assignmentTopTitle">{{ data.assignment.title }}</v-card-title> |                 <v-card-title class="text-h4 assignmentTopTitle">{{ assignment.title }} </v-card-title> | ||||||
| 
 | 
 | ||||||
|                 <v-card-subtitle class="subtitle-section"> |                 <v-card-subtitle class="subtitle-section"> | ||||||
|                     <using-query-result |                     <using-query-result | ||||||
|  | @ -99,7 +113,12 @@ language | ||||||
|                     > |                     > | ||||||
|                         <v-btn |                         <v-btn | ||||||
|                             v-if="lpData" |                             v-if="lpData" | ||||||
|                             :to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?forGroup=${group?.groupNumber}&assignmentNo=${assignmentId}&classId=${classId}`" |                             :to=" | ||||||
|  |                                 group | ||||||
|  |                                     ? `/learningPath/${lpData.hruid}/${assignment.language}/${lpData.startNode.learningobjectHruid}?forGroup=${group.groupNumber}&assignmentNo=${assignment.id}&classId=${assignment.within}` | ||||||
|  |                                     : undefined | ||||||
|  |                             " | ||||||
|  |                             :disabled="!group" | ||||||
|                             variant="tonal" |                             variant="tonal" | ||||||
|                             color="primary" |                             color="primary" | ||||||
|                         > |                         > | ||||||
|  | @ -109,20 +128,19 @@ language | ||||||
|                 </v-card-subtitle> |                 </v-card-subtitle> | ||||||
| 
 | 
 | ||||||
|                 <v-card-text class="description"> |                 <v-card-text class="description"> | ||||||
|                     {{ data.assignment.description }} |                     {{ assignment.description }} | ||||||
|                 </v-card-text> |                 </v-card-text> | ||||||
|                 <v-card-text> |                 <v-card-text> | ||||||
|                     <v-row |                     <v-card-text> | ||||||
|                         align="center" |                         <h3 class="mb-2">{{ t("progress") }}</h3> | ||||||
|                         no-gutters |                         <using-query-result | ||||||
|  |                             :query-result="lpQueryResult" | ||||||
|  |                             v-slot="{ data: learningPData }" | ||||||
|                         > |                         > | ||||||
|                         <v-col cols="auto"> |  | ||||||
|                             <span class="progress-label">{{ t("progress") + ": " }}</span> |  | ||||||
|                         </v-col> |  | ||||||
|                         <v-col> |  | ||||||
|                             <v-progress-linear |                             <v-progress-linear | ||||||
|                                 :model-value="progressValue" |                                 v-if="group" | ||||||
|                                 color="primary" |                                 :model-value="calculateProgress(learningPData)" | ||||||
|  |                                 :color="progressColor" | ||||||
|                                 height="20" |                                 height="20" | ||||||
|                                 class="progress-bar" |                                 class="progress-bar" | ||||||
|                             > |                             > | ||||||
|  | @ -130,16 +148,20 @@ language | ||||||
|                                     <strong>{{ Math.ceil(value) }}%</strong> |                                     <strong>{{ Math.ceil(value) }}%</strong> | ||||||
|                                 </template> |                                 </template> | ||||||
|                             </v-progress-linear> |                             </v-progress-linear> | ||||||
|                         </v-col> |                         </using-query-result> | ||||||
|                     </v-row> |                     </v-card-text> | ||||||
|                 </v-card-text> |                 </v-card-text> | ||||||
| 
 | 
 | ||||||
|                 <v-card-text class="group-section"> |                 <v-card-text | ||||||
|                     <h3>{{ t("group") }}</h3> |                     class="group-section" | ||||||
|                     <div v-if="studentQueries"> |                     v-if="group && studentQueries" | ||||||
|  |                 > | ||||||
|  |                     <h3>{{ `${t("group")} ${group.groupNo}` }}</h3> | ||||||
|  | 
 | ||||||
|  |                     <div> | ||||||
|                         <ul> |                         <ul> | ||||||
|                             <li |                             <li | ||||||
|                                 v-for="student in group?.members" |                                 v-for="student in group.members" | ||||||
|                                 :key="student.username" |                                 :key="student.username" | ||||||
|                             > |                             > | ||||||
|                                 {{ student.firstName + " " + student.lastName }} |                                 {{ student.firstName + " " + student.lastName }} | ||||||
|  | @ -147,6 +169,21 @@ language | ||||||
|                         </ul> |                         </ul> | ||||||
|                     </div> |                     </div> | ||||||
|                 </v-card-text> |                 </v-card-text> | ||||||
|  |                 <v-card-text | ||||||
|  |                     class="group-section" | ||||||
|  |                     v-else | ||||||
|  |                 > | ||||||
|  |                     <h3>{{ t("group") }}</h3> | ||||||
|  |                     <div> | ||||||
|  |                         <v-alert class="empty-message"> | ||||||
|  |                             <v-icon | ||||||
|  |                                 icon="mdi-information-outline" | ||||||
|  |                                 size="small" | ||||||
|  |                             /> | ||||||
|  |                             {{ t("currently-no-groups") }} | ||||||
|  |                         </v-alert> | ||||||
|  |                     </div> | ||||||
|  |                 </v-card-text> | ||||||
|             </v-card> |             </v-card> | ||||||
|         </using-query-result> |         </using-query-result> | ||||||
|     </div> |     </div> | ||||||
|  | @ -155,11 +192,6 @@ language | ||||||
| <style scoped> | <style scoped> | ||||||
|     @import "@/assets/assignment.css"; |     @import "@/assets/assignment.css"; | ||||||
| 
 | 
 | ||||||
|     .progress-label { |  | ||||||
|         font-weight: bold; |  | ||||||
|         margin-right: 5px; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     .progress-bar { |     .progress-bar { | ||||||
|         width: 40%; |         width: 40%; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -1,99 +1,204 @@ | ||||||
| <script setup lang="ts"> | <script setup lang="ts"> | ||||||
|     import { computed, type Ref, ref } from "vue"; |     import { computed, ref, watch, watchEffect } from "vue"; | ||||||
|     import { useI18n } from "vue-i18n"; |     import { useI18n } from "vue-i18n"; | ||||||
|     import { useAssignmentQuery, useDeleteAssignmentMutation } from "@/queries/assignments.ts"; |     import { | ||||||
|  |         useAssignmentQuery, | ||||||
|  |         useDeleteAssignmentMutation, | ||||||
|  |         useUpdateAssignmentMutation, | ||||||
|  |     } from "@/queries/assignments.ts"; | ||||||
|     import UsingQueryResult from "@/components/UsingQueryResult.vue"; |     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||||
|     import { useGroupsQuery } from "@/queries/groups.ts"; |     import { useGroupsQuery } from "@/queries/groups.ts"; | ||||||
|     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; |     import { useGetLearningPathQuery } from "@/queries/learning-paths.ts"; | ||||||
|     import type { Language } from "@/data-objects/language.ts"; |     import type { Language } from "@/data-objects/language.ts"; | ||||||
|     import type { AssignmentResponse } from "@/controllers/assignments.ts"; |     import type { AssignmentResponse } from "@/controllers/assignments.ts"; | ||||||
|     import type { GroupDTO } from "@dwengo-1/common/interfaces/group"; |     import type { GroupDTO, GroupDTOId } from "@dwengo-1/common/interfaces/group"; | ||||||
|  |     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"; | ||||||
|  |     import DeadlineSelector from "@/components/assignments/DeadlineSelector.vue"; | ||||||
| 
 | 
 | ||||||
|     const props = defineProps<{ |     const props = defineProps<{ | ||||||
|         classId: string; |         classId: string; | ||||||
|         assignmentId: number; |         assignmentId: number; | ||||||
|         useGroupsWithProgress: ( |  | ||||||
|             groups: Ref<GroupDTO[]>, |  | ||||||
|             hruid: Ref<string>, |  | ||||||
|             language: Ref<Language>, |  | ||||||
|         ) => { groupProgressMap: Map<number, number> }; |  | ||||||
|     }>(); |     }>(); | ||||||
| 
 | 
 | ||||||
|  |     const isEditing = ref(false); | ||||||
|  | 
 | ||||||
|     const { t } = useI18n(); |     const { t } = useI18n(); | ||||||
|     const groups = ref(); |     const lang = ref(); | ||||||
|  |     const groups = ref<GroupDTO[] | GroupDTOId[]>([]); | ||||||
|     const learningPath = ref(); |     const learningPath = ref(); | ||||||
|  |     const form = ref(); | ||||||
|  | 
 | ||||||
|  |     const editingLearningPath = ref(learningPath); | ||||||
|  |     const description = ref(""); | ||||||
|  |     const deadline = ref<Date | null>(null); | ||||||
|  |     const editGroups = ref(false); | ||||||
| 
 | 
 | ||||||
|     const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); |     const assignmentQueryResult = useAssignmentQuery(() => props.classId, props.assignmentId); | ||||||
|     learningPath.value = assignmentQueryResult.data.value?.assignment?.learningPath; |  | ||||||
|     // Get learning path object |     // Get learning path object | ||||||
|     const lpQueryResult = useGetLearningPathQuery( |     const lpQueryResult = useGetLearningPathQuery( | ||||||
|         computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), |         computed(() => assignmentQueryResult.data.value?.assignment?.learningPath ?? ""), | ||||||
|         computed(() => assignmentQueryResult.data.value?.assignment.language as Language), |         computed(() => assignmentQueryResult.data.value?.assignment?.language as Language), | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|     // Get all the groups withing the assignment |     // Get all the groups withing the assignment | ||||||
|     const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); |     const groupsQueryResult = useGroupsQuery(props.classId, props.assignmentId, true); | ||||||
|     groups.value = groupsQueryResult.data.value?.groups; |     groups.value = groupsQueryResult.data.value?.groups ?? []; | ||||||
| 
 | 
 | ||||||
|     /* Crashes right now cause api data has inexistent hruid TODO: uncomment later and use it in progress bar |     watchEffect(() => { | ||||||
| Const {groupProgressMap} = props.useGroupsWithProgress( |         const assignment = assignmentQueryResult.data.value?.assignment; | ||||||
| groups, |         if (assignment) { | ||||||
| learningPath, |             learningPath.value = assignment.learningPath; | ||||||
| language |             lang.value = assignment.language as Language; | ||||||
| ); |             deadline.value = assignment.deadline ? new Date(assignment.deadline) : null; | ||||||
| */ | 
 | ||||||
|  |             if (lpQueryResult.data.value) { | ||||||
|  |                 editingLearningPath.value = lpQueryResult.data.value; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     const hasSubmissions = ref<boolean>(false); | ||||||
| 
 | 
 | ||||||
|     const allGroups = computed(() => { |     const allGroups = computed(() => { | ||||||
|         const groups = groupsQueryResult.data.value?.groups; |         const groups = groupsQueryResult.data.value?.groups; | ||||||
|  | 
 | ||||||
|         if (!groups) return []; |         if (!groups) return []; | ||||||
| 
 | 
 | ||||||
|         return groups.map((group) => ({ |         // Sort by original groupNumber | ||||||
|             name: `${t("group")} ${group.groupNumber}`, |         const sortedGroups = [...groups].sort((a, b) => a.groupNumber - b.groupNumber); | ||||||
|             progress: 0, //GroupProgressMap[group.groupNumber], | 
 | ||||||
|  |         // Assign new sequential numbers starting from 1 | ||||||
|  |         return sortedGroups.map((group, index) => ({ | ||||||
|  |             groupNo: index + 1, // New group number that will be used | ||||||
|  |             name: `${t("group")} ${index + 1}`, | ||||||
|             members: group.members, |             members: group.members, | ||||||
|             submitted: false, //TODO: fetch from submission |             originalGroupNo: group.groupNumber, | ||||||
|         })); |         })); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     const dialog = ref(false); |     const dialog = ref(false); | ||||||
|     const selectedGroup = ref({}); |     const selectedGroup = ref({}); | ||||||
| 
 | 
 | ||||||
|     function openGroupDetails(group): void { |     function openGroupDetails(group: object): void { | ||||||
|         selectedGroup.value = group; |         selectedGroup.value = group; | ||||||
|         dialog.value = true; |         dialog.value = true; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const headers = computed(() => [ |     const snackbar = ref({ | ||||||
|         { title: t("group"), align: "start", key: "name" }, |         visible: false, | ||||||
|         { title: t("progress"), align: "center", key: "progress" }, |         message: "", | ||||||
|         { title: t("submission"), align: "center", key: "submission" }, |         color: "success", | ||||||
|     ]); |     }); | ||||||
| 
 | 
 | ||||||
|     const { mutate } = useDeleteAssignmentMutation(); |     function showSnackbar(message: string, color: string): void { | ||||||
|  |         snackbar.value.message = message; | ||||||
|  |         snackbar.value.color = color; | ||||||
|  |         snackbar.value.visible = true; | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|  |     const deleteAssignmentMutation = useDeleteAssignmentMutation(); | ||||||
|     async function deleteAssignment(num: number, clsId: string): Promise<void> { |     async function deleteAssignment(num: number, clsId: string): Promise<void> { | ||||||
|         mutate( |         deleteAssignmentMutation.mutate( | ||||||
|             { cid: clsId, an: num }, |             { cid: clsId, an: num }, | ||||||
|             { |             { | ||||||
|                 onSuccess: () => { |                 onSuccess: () => { | ||||||
|                     window.location.href = "/user/assignment"; |                     window.location.href = "/user/assignment"; | ||||||
|                 }, |                 }, | ||||||
|  |                 onError: (e) => { | ||||||
|  |                     showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); | ||||||
|  |                 }, | ||||||
|             }, |             }, | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     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 { | ||||||
|  |         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 { mutate, data, isSuccess } = useUpdateAssignmentMutation(); | ||||||
|  | 
 | ||||||
|  |     watch([isSuccess, data], async ([success, newData]) => { | ||||||
|  |         if (success && newData?.assignment) { | ||||||
|  |             await assignmentQueryResult.refetch(); | ||||||
|  |         } | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     async function saveChanges(): Promise<void> { | ||||||
|  |         const { valid } = await form.value.validate(); | ||||||
|  |         if (!valid) return; | ||||||
|  | 
 | ||||||
|  |         isEditing.value = false; | ||||||
|  | 
 | ||||||
|  |         const assignmentDTO: AssignmentDTO = { | ||||||
|  |             description: description.value, | ||||||
|  |             deadline: deadline.value ?? null, | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         mutate({ | ||||||
|  |             cid: assignmentQueryResult.data.value?.assignment.within, | ||||||
|  |             an: assignmentQueryResult.data.value?.assignment.id, | ||||||
|  |             data: assignmentDTO, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async function handleGroupsUpdated(updatedGroups: string[][]): Promise<void> { | ||||||
|  |         const assignmentDTO: AssignmentDTO = { | ||||||
|  |             groups: updatedGroups, | ||||||
|  |         }; | ||||||
|  |         mutate({ | ||||||
|  |             cid: assignmentQueryResult.data.value?.assignment.within, | ||||||
|  |             an: assignmentQueryResult.data.value?.assignment.id, | ||||||
|  |             data: assignmentDTO, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|     <div class="container"> |     <div class="container"> | ||||||
|         <using-query-result |         <using-query-result | ||||||
|             :query-result="assignmentQueryResult" |             :query-result="assignmentQueryResult" | ||||||
|             v-slot="{ data }: { data: AssignmentResponse }" |             v-slot="assignmentResponse: { data: AssignmentResponse }" | ||||||
|  |         > | ||||||
|  |             <v-container | ||||||
|  |                 fluid | ||||||
|  |                 class="ma-4" | ||||||
|  |             > | ||||||
|  |                 <v-row | ||||||
|  |                     no-gutters | ||||||
|  |                     class="custom-breakpoint" | ||||||
|  |                 > | ||||||
|  |                     <v-col | ||||||
|  |                         cols="12" | ||||||
|  |                         sm="6" | ||||||
|  |                         md="6" | ||||||
|  |                         class="responsive-col" | ||||||
|  |                     > | ||||||
|  |                         <v-form | ||||||
|  |                             ref="form" | ||||||
|  |                             validate-on="submit lazy" | ||||||
|  |                             @submit.prevent="saveChanges" | ||||||
|                         > |                         > | ||||||
|                             <v-card |                             <v-card | ||||||
|                 v-if="data" |                                 v-if="assignmentResponse" | ||||||
|                 class="assignment-card" |                                 class="assignment-card-teacher" | ||||||
|                             > |                             > | ||||||
|                                 <div class="top-buttons"> |                                 <div class="top-buttons"> | ||||||
|  |                                     <div class="top-buttons-wrapper"> | ||||||
|                                         <v-btn |                                         <v-btn | ||||||
|                                             icon |                                             icon | ||||||
|                                             variant="text" |                                             variant="text" | ||||||
|  | @ -102,17 +207,63 @@ language | ||||||
|                                         > |                                         > | ||||||
|                                             <v-icon>mdi-arrow-left</v-icon> |                                             <v-icon>mdi-arrow-left</v-icon> | ||||||
|                                         </v-btn> |                                         </v-btn> | ||||||
|  |                                         <div class="right-buttons"> | ||||||
|  |                                             <v-btn | ||||||
|  |                                                 v-if="!isEditing" | ||||||
|  |                                                 icon | ||||||
|  |                                                 variant="text" | ||||||
|  |                                                 class="top_next_to_right_button" | ||||||
|  |                                                 @click=" | ||||||
|  |                                                     () => { | ||||||
|  |                                                         isEditing = true; | ||||||
|  |                                                         description = assignmentResponse.data.assignment.description; | ||||||
|  |                                                     } | ||||||
|  |                                                 " | ||||||
|  |                                             > | ||||||
|  |                                                 <v-icon>mdi-pencil</v-icon> | ||||||
|  |                                             </v-btn> | ||||||
|  |                                             <v-btn | ||||||
|  |                                                 v-else | ||||||
|  |                                                 variant="text" | ||||||
|  |                                                 class="top-right-btn" | ||||||
|  |                                                 @click=" | ||||||
|  |                                                     () => { | ||||||
|  |                                                         isEditing = false; | ||||||
|  |                                                         editingLearningPath = learningPath; | ||||||
|  |                                                     } | ||||||
|  |                                                 " | ||||||
|  |                                                 >{{ t("cancel") }} | ||||||
|  |                                             </v-btn> | ||||||
| 
 | 
 | ||||||
|                                             <v-btn |                                             <v-btn | ||||||
|  |                                                 v-if="!isEditing" | ||||||
|                                                 icon |                                                 icon | ||||||
|                                                 variant="text" |                                                 variant="text" | ||||||
|                                                 class="top-right-btn" |                                                 class="top-right-btn" | ||||||
|                         @click="deleteAssignment(data.assignment.id, data.assignment.within)" |                                                 @click=" | ||||||
|  |                                                     deleteAssignment( | ||||||
|  |                                                         assignmentResponse.data.assignment.id, | ||||||
|  |                                                         assignmentResponse.data.assignment.within, | ||||||
|  |                                                     ) | ||||||
|  |                                                 " | ||||||
|                                             > |                                             > | ||||||
|                                                 <v-icon>mdi-delete</v-icon> |                                                 <v-icon>mdi-delete</v-icon> | ||||||
|                                             </v-btn> |                                             </v-btn> | ||||||
|  |                                             <v-btn | ||||||
|  |                                                 v-else | ||||||
|  |                                                 icon | ||||||
|  |                                                 variant="text" | ||||||
|  |                                                 class="top_next_to_right_button" | ||||||
|  |                                                 @click="saveChanges" | ||||||
|  |                                             > | ||||||
|  |                                                 <v-icon>mdi-content-save-edit-outline</v-icon> | ||||||
|  |                                             </v-btn> | ||||||
|                                         </div> |                                         </div> | ||||||
|                 <v-card-title class="text-h4 assignmentTopTitle">{{ data.assignment.title }}</v-card-title> |                                     </div> | ||||||
|  |                                 </div> | ||||||
|  |                                 <v-card-title class="text-h4 assignmentTopTitle" | ||||||
|  |                                     >{{ assignmentResponse.data.assignment.title }} | ||||||
|  |                                 </v-card-title> | ||||||
|                                 <v-card-subtitle class="subtitle-section"> |                                 <v-card-subtitle class="subtitle-section"> | ||||||
|                                     <using-query-result |                                     <using-query-result | ||||||
|                                         :query-result="lpQueryResult" |                                         :query-result="lpQueryResult" | ||||||
|  | @ -120,105 +271,215 @@ language | ||||||
|                                     > |                                     > | ||||||
|                                         <v-btn |                                         <v-btn | ||||||
|                                             v-if="lpData" |                                             v-if="lpData" | ||||||
|                             :to="`/learningPath/${lpData.hruid}/${assignmentQueryResult.data.value?.assignment.language}/${lpData.startNode.learningobjectHruid}?assignmentNo=${assignmentId}&classId=${classId}`" |                                             :to="goToLearningPathLink()" | ||||||
|                                             variant="tonal" |                                             variant="tonal" | ||||||
|                                             color="primary" |                                             color="primary" | ||||||
|  |                                             :disabled="isEditing" | ||||||
|                                         > |                                         > | ||||||
|                                             {{ t("learning-path") }} |                                             {{ t("learning-path") }} | ||||||
|                                         </v-btn> |                                         </v-btn> | ||||||
|  |                                         <v-alert | ||||||
|  |                                             v-else | ||||||
|  |                                             type="info" | ||||||
|  |                                         > | ||||||
|  |                                             {{ t("no-learning-path-selected") }} | ||||||
|  |                                         </v-alert> | ||||||
|                                     </using-query-result> |                                     </using-query-result> | ||||||
|                                 </v-card-subtitle> |                                 </v-card-subtitle> | ||||||
| 
 |                                 <v-card-text v-if="isEditing"> | ||||||
|                 <v-card-text class="description"> |                                     <deadline-selector v-model:deadline="deadline" /> | ||||||
|                     {{ data.assignment.description }} |  | ||||||
|                                 </v-card-text> |                                 </v-card-text> | ||||||
| 
 |                                 <v-card-text | ||||||
|                 <v-card-text class="group-section"> |                                     v-if="!isEditing" | ||||||
|                     <h3>{{ t("groups") }}</h3> |                                     class="description" | ||||||
|                     <div class="table-scroll"> |  | ||||||
|                         <v-data-table |  | ||||||
|                             :headers="headers" |  | ||||||
|                             :items="allGroups" |  | ||||||
|                             item-key="id" |  | ||||||
|                             class="elevation-1" |  | ||||||
|                                 > |                                 > | ||||||
|                             <template #[`item.name`]="{ item }"> |                                     {{ assignmentResponse.data.assignment.description }} | ||||||
|                                 <v-btn |  | ||||||
|                                     @click="openGroupDetails(item)" |  | ||||||
|                                     variant="text" |  | ||||||
|                                     color="primary" |  | ||||||
|                                 > |  | ||||||
|                                     {{ item.name }} |  | ||||||
|                                 </v-btn> |  | ||||||
|                             </template> |  | ||||||
| 
 |  | ||||||
|                             <template #[`item.progress`]="{ item }"> |  | ||||||
|                                 <v-progress-linear |  | ||||||
|                                     :model-value="item.progress" |  | ||||||
|                                     color="blue-grey" |  | ||||||
|                                     height="25" |  | ||||||
|                                 > |  | ||||||
|                                     <template v-slot:default="{ value }"> |  | ||||||
|                                         <strong>{{ Math.ceil(value) }}%</strong> |  | ||||||
|                                     </template> |  | ||||||
|                                 </v-progress-linear> |  | ||||||
|                             </template> |  | ||||||
| 
 |  | ||||||
|                             <template #[`item.submission`]="{ item }"> |  | ||||||
|                                 <v-btn |  | ||||||
|                                     :to="item.submitted ? `${props.assignmentId}/submissions/` : undefined" |  | ||||||
|                                     :color="item.submitted ? 'green' : 'red'" |  | ||||||
|                                     variant="text" |  | ||||||
|                                     class="text-capitalize" |  | ||||||
|                                 > |  | ||||||
|                                     {{ item.submitted ? t("see-submission") : t("no-submission") }} |  | ||||||
|                                 </v-btn> |  | ||||||
|                             </template> |  | ||||||
|                         </v-data-table> |  | ||||||
|                     </div> |  | ||||||
|                                 </v-card-text> |                                 </v-card-text> | ||||||
|  |                                 <v-card-text v-else> | ||||||
|  |                                     <v-textarea | ||||||
|  |                                         v-model="description" | ||||||
|  |                                         :label="t('description')" | ||||||
|  |                                         variant="outlined" | ||||||
|  |                                         density="compact" | ||||||
|  |                                         auto-grow | ||||||
|  |                                         rows="3" | ||||||
|  |                                     ></v-textarea> | ||||||
|  |                                 </v-card-text> | ||||||
|  |                             </v-card> | ||||||
|  |                         </v-form> | ||||||
| 
 | 
 | ||||||
|  |                         <!-- A pop up to show group members --> | ||||||
|                         <v-dialog |                         <v-dialog | ||||||
|                             v-model="dialog" |                             v-model="dialog" | ||||||
|                     max-width="50%" |                             max-width="600" | ||||||
|  |                             persistent | ||||||
|                         > |                         > | ||||||
|                     <v-card> |                             <v-card class="pa-4 rounded-xl elevation-6 group-members-dialog"> | ||||||
|                         <v-card-title class="headline">{{ t("members") }}</v-card-title> |                                 <v-card-title class="text-h6 font-weight-bold"> | ||||||
|  |                                     {{ t("members") }} | ||||||
|  |                                 </v-card-title> | ||||||
|  | 
 | ||||||
|  |                                 <v-divider class="my-2" /> | ||||||
|  | 
 | ||||||
|                                 <v-card-text> |                                 <v-card-text> | ||||||
|                                     <v-list> |                                     <v-list> | ||||||
|                                         <v-list-item |                                         <v-list-item | ||||||
|                                             v-for="(member, index) in selectedGroup.members" |                                             v-for="(member, index) in selectedGroup.members" | ||||||
|                                             :key="index" |                                             :key="index" | ||||||
|  |                                             class="py-2" | ||||||
|                                         > |                                         > | ||||||
|                                             <v-list-item-content> |                                             <v-list-item-content> | ||||||
|                                         <v-list-item-title |                                                 <v-list-item-title class="text-body-1"> | ||||||
|                                             >{{ member.firstName + " " + member.lastName }} |                                                     {{ member.firstName }} {{ member.lastName }} | ||||||
|                                                 </v-list-item-title> |                                                 </v-list-item-title> | ||||||
|                                             </v-list-item-content> |                                             </v-list-item-content> | ||||||
|                                         </v-list-item> |                                         </v-list-item> | ||||||
|                                     </v-list> |                                     </v-list> | ||||||
|                                 </v-card-text> |                                 </v-card-text> | ||||||
|                         <v-card-actions> | 
 | ||||||
|  |                                 <v-divider class="my-2" /> | ||||||
|  | 
 | ||||||
|  |                                 <v-card-actions class="justify-end"> | ||||||
|                                     <v-btn |                                     <v-btn | ||||||
|                                         color="primary" |                                         color="primary" | ||||||
|  |                                         variant="outlined" | ||||||
|                                         @click="dialog = false" |                                         @click="dialog = false" | ||||||
|                                 >Close |                                         prepend-icon="mdi-close-circle" | ||||||
|  |                                     > | ||||||
|  |                                         {{ t("close") }} | ||||||
|                                     </v-btn> |                                     </v-btn> | ||||||
|                                 </v-card-actions> |                                 </v-card-actions> | ||||||
|                             </v-card> |                             </v-card> | ||||||
|                         </v-dialog> |                         </v-dialog> | ||||||
|                 <!-- |                     </v-col> | ||||||
|                 <v-card-actions class="justify-end"> | 
 | ||||||
|  |                     <!-- The second column of the screen --> | ||||||
|  |                     <v-col | ||||||
|  |                         cols="12" | ||||||
|  |                         sm="6" | ||||||
|  |                         md="6" | ||||||
|  |                         class="responsive-col" | ||||||
|  |                     > | ||||||
|  |                         <div class="table-container"> | ||||||
|  |                             <v-table class="table"> | ||||||
|  |                                 <thead> | ||||||
|  |                                     <tr> | ||||||
|  |                                         <th class="header">{{ t("group") }}</th> | ||||||
|  |                                         <th class="header">{{ t("progress") }}</th> | ||||||
|  |                                         <th class="header">{{ t("submission") }}</th> | ||||||
|  |                                         <th class="header"> | ||||||
|                                             <v-btn |                                             <v-btn | ||||||
|                         size="large" |                                                 @click="editGroups = true" | ||||||
|                         color="success" |                                                 variant="text" | ||||||
|  |                                                 :disabled="hasSubmissions" | ||||||
|  |                                             > | ||||||
|  |                                                 <v-icon>mdi-pencil</v-icon> | ||||||
|  |                                             </v-btn> | ||||||
|  |                                         </th> | ||||||
|  |                                     </tr> | ||||||
|  |                                 </thead> | ||||||
|  |                                 <tbody v-if="allGroups.length > 0"> | ||||||
|  |                                     <tr | ||||||
|  |                                         v-for="g in allGroups" | ||||||
|  |                                         :key="g.originalGroupNo" | ||||||
|  |                                     > | ||||||
|  |                                         <td> | ||||||
|  |                                             <v-btn variant="text"> | ||||||
|  |                                                 {{ g.name }} | ||||||
|  |                                             </v-btn> | ||||||
|  |                                         </td> | ||||||
|  | 
 | ||||||
|  |                                         <td> | ||||||
|  |                                             <GroupProgressRow | ||||||
|  |                                                 :group-number="g.originalGroupNo" | ||||||
|  |                                                 :learning-path="learningPath.hruid" | ||||||
|  |                                                 :language="lang" | ||||||
|  |                                                 :assignment-id="assignmentId" | ||||||
|  |                                                 :class-id="classId" | ||||||
|  |                                             /> | ||||||
|  |                                         </td> | ||||||
|  | 
 | ||||||
|  |                                         <td> | ||||||
|  |                                             <GroupSubmissionStatus | ||||||
|  |                                                 :group="g" | ||||||
|  |                                                 :assignment-id="assignmentId" | ||||||
|  |                                                 :class-id="classId" | ||||||
|  |                                                 :language="lang" | ||||||
|  |                                                 :go-to-group-submission-link="goToGroupSubmissionLink" | ||||||
|  |                                                 @update:hasSubmission=" | ||||||
|  |                                                     (hasSubmission) => (hasSubmissions = hasSubmission) | ||||||
|  |                                                 " | ||||||
|  |                                             /> | ||||||
|  |                                         </td> | ||||||
|  | 
 | ||||||
|  |                                         <!-- Edit icon --> | ||||||
|  |                                         <td> | ||||||
|  |                                             <v-btn | ||||||
|  |                                                 @click="openGroupDetails(g)" | ||||||
|                                                 variant="text" |                                                 variant="text" | ||||||
|                                             > |                                             > | ||||||
|                         {{ t("view-submissions") }} |                                                 <v-icon>mdi-eye</v-icon> | ||||||
|  |                                             </v-btn> | ||||||
|  |                                         </td> | ||||||
|  |                                     </tr> | ||||||
|  |                                 </tbody> | ||||||
|  |                                 <template v-else> | ||||||
|  |                                     <tbody> | ||||||
|  |                                         <tr> | ||||||
|  |                                             <td | ||||||
|  |                                                 colspan="4" | ||||||
|  |                                                 class="empty-message" | ||||||
|  |                                             > | ||||||
|  |                                                 <v-icon | ||||||
|  |                                                     icon="mdi-information-outline" | ||||||
|  |                                                     size="small" | ||||||
|  |                                                 /> | ||||||
|  |                                                 {{ t("currently-no-groups") }} | ||||||
|  |                                             </td> | ||||||
|  |                                         </tr> | ||||||
|  |                                     </tbody> | ||||||
|  |                                 </template> | ||||||
|  |                             </v-table> | ||||||
|  |                         </div> | ||||||
|  |                     </v-col> | ||||||
|  |                 </v-row> | ||||||
|  |                 <v-dialog | ||||||
|  |                     v-model="editGroups" | ||||||
|  |                     max-width="800" | ||||||
|  |                     persistent | ||||||
|  |                 > | ||||||
|  |                     <v-card> | ||||||
|  |                         <v-card-text> | ||||||
|  |                             <GroupSelector | ||||||
|  |                                 :groups="allGroups" | ||||||
|  |                                 :class-id="props.classId" | ||||||
|  |                                 :assignment-id="props.assignmentId" | ||||||
|  |                                 @groupsUpdated="handleGroupsUpdated" | ||||||
|  |                                 @close="editGroups = false" | ||||||
|  |                             /> | ||||||
|  |                         </v-card-text> | ||||||
|  | 
 | ||||||
|  |                         <v-divider></v-divider> | ||||||
|  | 
 | ||||||
|  |                         <v-card-actions> | ||||||
|  |                             <v-spacer></v-spacer> | ||||||
|  |                             <v-btn | ||||||
|  |                                 text | ||||||
|  |                                 @click="editGroups = false" | ||||||
|  |                             > | ||||||
|  |                                 {{ t("cancel") }} | ||||||
|                             </v-btn> |                             </v-btn> | ||||||
|                         </v-card-actions> |                         </v-card-actions> | ||||||
|                 --> |  | ||||||
|                     </v-card> |                     </v-card> | ||||||
|  |                 </v-dialog> | ||||||
|  |             </v-container> | ||||||
|  |             <v-snackbar | ||||||
|  |                 v-model="snackbar.visible" | ||||||
|  |                 :color="snackbar.color" | ||||||
|  |                 timeout="3000" | ||||||
|  |             > | ||||||
|  |                 {{ snackbar.message }} | ||||||
|  |             </v-snackbar> | ||||||
|         </using-query-result> |         </using-query-result> | ||||||
|     </div> |     </div> | ||||||
| </template> | </template> | ||||||
|  | @ -226,8 +487,130 @@ language | ||||||
| <style scoped> | <style scoped> | ||||||
|     @import "@/assets/assignment.css"; |     @import "@/assets/assignment.css"; | ||||||
| 
 | 
 | ||||||
|  |     .assignment-card-teacher { | ||||||
|  |         width: 80%; | ||||||
|  |         padding: 2%; | ||||||
|  |         border-radius: 12px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     .table-scroll { |     .table-scroll { | ||||||
|         overflow-x: auto; |         overflow-x: auto; | ||||||
|         -webkit-overflow-scrolling: touch; |         -webkit-overflow-scrolling: touch; | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     .header { | ||||||
|  |         font-weight: bold; | ||||||
|  |         background-color: #0e6942; | ||||||
|  |         color: white; | ||||||
|  |         padding: 5px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     table thead th:first-child { | ||||||
|  |         border-top-left-radius: 10px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .table thead th:last-child { | ||||||
|  |         border-top-right-radius: 10px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .table tbody tr:nth-child(odd) { | ||||||
|  |         background-color: white; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .table tbody tr:nth-child(even) { | ||||||
|  |         background-color: #f6faf2; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     td, | ||||||
|  |     th { | ||||||
|  |         border-bottom: 1px solid #0e6942; | ||||||
|  |         border-top: 1px solid #0e6942; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     h1 { | ||||||
|  |         color: #0e6942; | ||||||
|  |         text-transform: uppercase; | ||||||
|  |         font-weight: bolder; | ||||||
|  |         padding-top: 2%; | ||||||
|  |         font-size: 50px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     h2 { | ||||||
|  |         color: #0e6942; | ||||||
|  |         font-size: 30px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .link { | ||||||
|  |         color: #0b75bb; | ||||||
|  |         text-decoration: underline; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     main { | ||||||
|  |         margin-left: 30px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .table-container { | ||||||
|  |         width: 100%; | ||||||
|  |         overflow-x: visible; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .table { | ||||||
|  |         width: 100%; | ||||||
|  |         min-width: auto; | ||||||
|  |         table-layout: auto; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     @media screen and (max-width: 1200px) { | ||||||
|  |         h1 { | ||||||
|  |             text-align: center; | ||||||
|  |             padding-left: 0; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .join { | ||||||
|  |             text-align: center; | ||||||
|  |             align-items: center; | ||||||
|  |             margin-left: 0; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .sheet { | ||||||
|  |             width: 90%; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         main { | ||||||
|  |             display: flex; | ||||||
|  |             flex-direction: column; | ||||||
|  |             align-items: center; | ||||||
|  |             justify-content: center; | ||||||
|  |             margin: 5px; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .custom-breakpoint { | ||||||
|  |             flex-direction: column !important; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .table { | ||||||
|  |             width: 100%; | ||||||
|  |             display: block; | ||||||
|  |             overflow-x: auto; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .table-container { | ||||||
|  |             overflow-x: auto; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .responsive-col { | ||||||
|  |             max-width: 100% !important; | ||||||
|  |             flex-basis: 100% !important; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         .assignment-card-teacher { | ||||||
|  |             width: 100%; | ||||||
|  |             border-radius: 12px; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .group-members-dialog { | ||||||
|  |         max-height: 80vh; | ||||||
|  |         overflow-y: auto; | ||||||
|  |     } | ||||||
| </style> | </style> | ||||||
|  |  | ||||||
|  | @ -2,46 +2,55 @@ | ||||||
|     import { ref, computed, onMounted, watch } from "vue"; |     import { ref, computed, onMounted, watch } from "vue"; | ||||||
|     import { useI18n } from "vue-i18n"; |     import { useI18n } from "vue-i18n"; | ||||||
|     import { useRouter } from "vue-router"; |     import { useRouter } from "vue-router"; | ||||||
|  |     import authState from "@/services/auth/auth-service.ts"; | ||||||
|     import auth from "@/services/auth/auth-service.ts"; |     import auth from "@/services/auth/auth-service.ts"; | ||||||
|     import { useTeacherClassesQuery } from "@/queries/teachers.ts"; |     import { useTeacherAssignmentsQuery, useTeacherClassesQuery } from "@/queries/teachers.ts"; | ||||||
|     import { useStudentClassesQuery } from "@/queries/students.ts"; |     import { useStudentAssignmentsQuery, useStudentClassesQuery } from "@/queries/students.ts"; | ||||||
|     import { ClassController } from "@/controllers/classes.ts"; |  | ||||||
|     import type { ClassDTO } from "@dwengo-1/common/interfaces/class"; |  | ||||||
|     import { asyncComputed } from "@vueuse/core"; |  | ||||||
|     import { useDeleteAssignmentMutation } from "@/queries/assignments.ts"; |     import { useDeleteAssignmentMutation } from "@/queries/assignments.ts"; | ||||||
|     import { AccountType } from "@dwengo-1/common/util/account-types"; |     import UsingQueryResult from "@/components/UsingQueryResult.vue"; | ||||||
|     import "../../assets/common.css"; |  | ||||||
| 
 | 
 | ||||||
|     const { t, locale } = useI18n(); |     const { t, locale } = useI18n(); | ||||||
|     const router = useRouter(); |     const router = useRouter(); | ||||||
| 
 | 
 | ||||||
|     const role = ref(auth.authState.activeRole); |     const role = ref(auth.authState.activeRole); | ||||||
|     const username = ref<string>(""); |     const isTeacher = computed(() => role.value === "teacher"); | ||||||
|  |     const username = ref<string | undefined>(undefined); | ||||||
|  |     const isLoading = ref(false); | ||||||
|  |     const isError = ref(false); | ||||||
|  |     const errorMessage = ref<string>(""); | ||||||
| 
 | 
 | ||||||
|     const isTeacher = computed(() => role.value === AccountType.Teacher); |     // Load current user before rendering the page | ||||||
| 
 |     onMounted(async () => { | ||||||
|     // Fetch and store all the teacher's classes |         isLoading.value = true; | ||||||
|     let classesQueryResults = undefined; |         try { | ||||||
| 
 |             const userObject = await authState.loadUser(); | ||||||
|     if (isTeacher.value) { |             username.value = userObject!.profile.preferred_username; | ||||||
|         classesQueryResults = useTeacherClassesQuery(username, true); |         } catch (error) { | ||||||
|     } else { |             isError.value = true; | ||||||
|         classesQueryResults = useStudentClassesQuery(username, true); |             errorMessage.value = error instanceof Error ? error.message : String(error); | ||||||
|  |         } finally { | ||||||
|  |             isLoading.value = false; | ||||||
|         } |         } | ||||||
|  |     }); | ||||||
| 
 | 
 | ||||||
|     const classController = new ClassController(); |     const classesQueryResult = isTeacher.value | ||||||
|  |         ? useTeacherClassesQuery(username, true) | ||||||
|  |         : useStudentClassesQuery(username, true); | ||||||
| 
 | 
 | ||||||
|     const assignments = asyncComputed( |     const assignmentsQueryResult = isTeacher.value | ||||||
|         async () => { |         ? useTeacherAssignmentsQuery(username, true) | ||||||
|             const classes = classesQueryResults?.data?.value?.classes; |         : useStudentAssignmentsQuery(username, true); | ||||||
|  | 
 | ||||||
|  |     const allAssignments = computed(() => { | ||||||
|  |         const assignments = assignmentsQueryResult.data.value?.assignments; | ||||||
|  |         if (!assignments) return []; | ||||||
|  | 
 | ||||||
|  |         const classes = classesQueryResult.data.value?.classes; | ||||||
|         if (!classes) return []; |         if (!classes) return []; | ||||||
| 
 | 
 | ||||||
|             const result = await Promise.all( |         const result = assignments.map((a) => ({ | ||||||
|                 (classes as ClassDTO[]).map(async (cls) => { |  | ||||||
|                     const { assignments } = await classController.getAssignments(cls.id); |  | ||||||
|                     return assignments.map((a) => ({ |  | ||||||
|             id: a.id, |             id: a.id, | ||||||
|                         class: cls, |             class: classes.find((cls) => cls?.id === a.within) ?? undefined, | ||||||
|             title: a.title, |             title: a.title, | ||||||
|             description: a.description, |             description: a.description, | ||||||
|             learningPath: a.learningPath, |             learningPath: a.learningPath, | ||||||
|  | @ -49,8 +58,6 @@ | ||||||
|             deadline: a.deadline, |             deadline: a.deadline, | ||||||
|             groups: a.groups, |             groups: a.groups, | ||||||
|         })); |         })); | ||||||
|                 }), |  | ||||||
|             ); |  | ||||||
| 
 | 
 | ||||||
|         // Order the assignments by deadline |         // Order the assignments by deadline | ||||||
|         return result.flat().sort((a, b) => { |         return result.flat().sort((a, b) => { | ||||||
|  | @ -66,10 +73,7 @@ | ||||||
| 
 | 
 | ||||||
|             return aTime - bTime; |             return aTime - bTime; | ||||||
|         }); |         }); | ||||||
|         }, |     }); | ||||||
|         [], |  | ||||||
|         { evaluating: true }, |  | ||||||
|     ); |  | ||||||
| 
 | 
 | ||||||
|     async function goToCreateAssignment(): Promise<void> { |     async function goToCreateAssignment(): Promise<void> { | ||||||
|         await router.push("/assignment/create"); |         await router.push("/assignment/create"); | ||||||
|  | @ -79,16 +83,35 @@ | ||||||
|         await router.push(`/assignment/${clsId}/${id}`); |         await router.push(`/assignment/${clsId}/${id}`); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const { mutate, data, isSuccess } = useDeleteAssignmentMutation(); |     const snackbar = ref({ | ||||||
| 
 |         visible: false, | ||||||
|     watch([isSuccess, data], async ([success, oldData]) => { |         message: "", | ||||||
|         if (success && oldData?.assignment) { |         color: "success", | ||||||
|             window.location.reload(); |  | ||||||
|         } |  | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     function showSnackbar(message: string, color: string): void { | ||||||
|  |         snackbar.value.message = message; | ||||||
|  |         snackbar.value.color = color; | ||||||
|  |         snackbar.value.visible = true; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const deleteAssignmentMutation = useDeleteAssignmentMutation(); | ||||||
|  | 
 | ||||||
|     async function goToDeleteAssignment(num: number, clsId: string): Promise<void> { |     async function goToDeleteAssignment(num: number, clsId: string): Promise<void> { | ||||||
|         mutate({ cid: clsId, an: num }); |         deleteAssignmentMutation.mutate( | ||||||
|  |             { cid: clsId, an: num }, | ||||||
|  |             { | ||||||
|  |                 onSuccess: (data) => { | ||||||
|  |                     if (data?.assignment) { | ||||||
|  |                         window.location.reload(); | ||||||
|  |                     } | ||||||
|  |                     showSnackbar(t("success"), "success"); | ||||||
|  |                 }, | ||||||
|  |                 onError: (e) => { | ||||||
|  |                     showSnackbar(t("failed") + ": " + e.response.data.error || e.message, "error"); | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     function formatDate(date?: string | Date): string { |     function formatDate(date?: string | Date): string { | ||||||
|  | @ -124,6 +147,11 @@ | ||||||
|         const user = await auth.loadUser(); |         const user = await auth.loadUser(); | ||||||
|         username.value = user?.profile?.preferred_username ?? ""; |         username.value = user?.profile?.preferred_username ?? ""; | ||||||
|     }); |     }); | ||||||
|  | 
 | ||||||
|  |     onMounted(async () => { | ||||||
|  |         const user = await auth.loadUser(); | ||||||
|  |         username.value = user?.profile?.preferred_username ?? ""; | ||||||
|  |     }); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <template> | <template> | ||||||
|  | @ -132,17 +160,18 @@ | ||||||
| 
 | 
 | ||||||
|         <v-btn |         <v-btn | ||||||
|             v-if="isTeacher" |             v-if="isTeacher" | ||||||
|             color="primary" |             :style="{ backgroundColor: '#0E6942' }" | ||||||
|             class="mb-4 center-btn" |             class="mb-4 center-btn" | ||||||
|             @click="goToCreateAssignment" |             @click="goToCreateAssignment" | ||||||
|         > |         > | ||||||
|             {{ t("new-assignment") }} |             {{ t("new-assignment") }} | ||||||
|         </v-btn> |         </v-btn> | ||||||
| 
 | 
 | ||||||
|  |         <using-query-result :query-result="assignmentsQueryResult"> | ||||||
|             <v-container> |             <v-container> | ||||||
|                 <v-row> |                 <v-row> | ||||||
|                     <v-col |                     <v-col | ||||||
|                     v-for="assignment in assignments" |                         v-for="assignment in allAssignments" | ||||||
|                         :key="assignment.id" |                         :key="assignment.id" | ||||||
|                         cols="12" |                         cols="12" | ||||||
|                     > |                     > | ||||||
|  | @ -151,9 +180,12 @@ | ||||||
|                                 <div class="assignment-title">{{ assignment.title }}</div> |                                 <div class="assignment-title">{{ assignment.title }}</div> | ||||||
|                                 <div class="assignment-class"> |                                 <div class="assignment-class"> | ||||||
|                                     {{ t("class") }}: |                                     {{ t("class") }}: | ||||||
|                                 <span class="class-name"> |                                     <a | ||||||
|                                     {{ assignment.class.displayName }} |                                         :href="`/class/${assignment?.class?.id}`" | ||||||
|                                 </span> |                                         class="class-name" | ||||||
|  |                                     > | ||||||
|  |                                         {{ assignment?.class?.displayName }} | ||||||
|  |                                     </a> | ||||||
|                                 </div> |                                 </div> | ||||||
|                                 <div |                                 <div | ||||||
|                                     class="assignment-deadline" |                                     class="assignment-deadline" | ||||||
|  | @ -170,7 +202,7 @@ | ||||||
|                                 <v-btn |                                 <v-btn | ||||||
|                                     color="primary" |                                     color="primary" | ||||||
|                                     variant="text" |                                     variant="text" | ||||||
|                                 @click="goToAssignmentDetails(assignment.id, assignment.class.id)" |                                     @click="goToAssignmentDetails(assignment.id, assignment?.class?.id)" | ||||||
|                                 > |                                 > | ||||||
|                                     {{ t("view-assignment") }} |                                     {{ t("view-assignment") }} | ||||||
|                                 </v-btn> |                                 </v-btn> | ||||||
|  | @ -178,7 +210,7 @@ | ||||||
|                                     v-if="isTeacher" |                                     v-if="isTeacher" | ||||||
|                                     color="red" |                                     color="red" | ||||||
|                                     variant="text" |                                     variant="text" | ||||||
|                                 @click="goToDeleteAssignment(assignment.id, assignment.class.id)" |                                     @click="goToDeleteAssignment(assignment.id, assignment?.class?.id)" | ||||||
|                                 > |                                 > | ||||||
|                                     {{ t("delete") }} |                                     {{ t("delete") }} | ||||||
|                                 </v-btn> |                                 </v-btn> | ||||||
|  | @ -186,14 +218,26 @@ | ||||||
|                         </v-card> |                         </v-card> | ||||||
|                     </v-col> |                     </v-col> | ||||||
|                 </v-row> |                 </v-row> | ||||||
|             <v-row v-if="assignments.length === 0"> |                 <v-row v-if="allAssignments.length === 0"> | ||||||
|                     <v-col cols="12"> |                     <v-col cols="12"> | ||||||
|                         <div class="no-assignments"> |                         <div class="no-assignments"> | ||||||
|  |                             <v-icon | ||||||
|  |                                 icon="mdi-information-outline" | ||||||
|  |                                 size="small" | ||||||
|  |                             /> | ||||||
|                             {{ t("no-assignments") }} |                             {{ t("no-assignments") }} | ||||||
|                         </div> |                         </div> | ||||||
|                     </v-col> |                     </v-col> | ||||||
|                 </v-row> |                 </v-row> | ||||||
|             </v-container> |             </v-container> | ||||||
|  |             <v-snackbar | ||||||
|  |                 v-model="snackbar.visible" | ||||||
|  |                 :color="snackbar.color" | ||||||
|  |                 timeout="3000" | ||||||
|  |             > | ||||||
|  |                 {{ snackbar.message }} | ||||||
|  |             </v-snackbar> | ||||||
|  |         </using-query-result> | ||||||
|     </div> |     </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
|  | @ -212,6 +256,7 @@ | ||||||
|         color: white; |         color: white; | ||||||
|         transition: background-color 0.2s; |         transition: background-color 0.2s; | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     .center-btn:hover { |     .center-btn:hover { | ||||||
|         background-color: #0e6942; |         background-color: #0e6942; | ||||||
|     } |     } | ||||||
|  | @ -225,6 +270,7 @@ | ||||||
|             transform 0.2s, |             transform 0.2s, | ||||||
|             box-shadow 0.2s; |             box-shadow 0.2s; | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     .assignment-card:hover { |     .assignment-card:hover { | ||||||
|         box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); |         box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); | ||||||
|     } |     } | ||||||
|  | @ -248,6 +294,10 @@ | ||||||
|         margin-bottom: 0.2rem; |         margin-bottom: 0.2rem; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     .assignment-class a { | ||||||
|  |         text-decoration: none; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     .class-name { |     .class-name { | ||||||
|         font-weight: 600; |         font-weight: 600; | ||||||
|         color: #097180; |         color: #097180; | ||||||
|  |  | ||||||
|  | @ -1,10 +1,58 @@ | ||||||
| import { describe, expect, it } from "vitest"; | import { describe, it, expect, beforeEach } from "vitest"; | ||||||
| import { ClassController } from "../../src/controllers/classes"; | import { ClassController } from "../../src/controllers/classes"; | ||||||
| 
 | 
 | ||||||
| describe("Test controller classes", () => { | describe("ClassController Tests", () => { | ||||||
|     it("Get classes", async () => { |     let controller: ClassController; | ||||||
|         const controller = new ClassController(); |     const testClassId = "X2J9QT"; | ||||||
|         const data = await controller.getAll(true); | 
 | ||||||
|         expect(data.classes).to.have.length.greaterThan(0); |     beforeEach(() => { | ||||||
|  |         controller = new ClassController(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should fetch all classes", async () => { | ||||||
|  |         const result = await controller.getAll(true); | ||||||
|  |         expect(result).toHaveProperty("classes"); | ||||||
|  |         expect(Array.isArray(result.classes)).toBe(true); | ||||||
|  |         expect(result.classes.length).toBeGreaterThan(0); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should fetch a class by ID", async () => { | ||||||
|  |         const result = await controller.getById(testClassId); | ||||||
|  |         expect(result).toHaveProperty("class"); | ||||||
|  |         expect(result.class).toHaveProperty("id", testClassId); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should fetch students for a class", async () => { | ||||||
|  |         const result = await controller.getStudents(testClassId, true); | ||||||
|  |         expect(result).toHaveProperty("students"); | ||||||
|  |         expect(Array.isArray(result.students)).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should fetch teachers for a class", async () => { | ||||||
|  |         const result = await controller.getTeachers(testClassId, true); | ||||||
|  |         expect(result).toHaveProperty("teachers"); | ||||||
|  |         expect(Array.isArray(result.teachers)).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should fetch teacher invitations for a class", async () => { | ||||||
|  |         const result = await controller.getTeacherInvitations(testClassId, true); | ||||||
|  |         expect(result).toHaveProperty("invitations"); | ||||||
|  |         expect(Array.isArray(result.invitations)).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should fetch assignments for a class", async () => { | ||||||
|  |         const result = await controller.getAssignments(testClassId, true); | ||||||
|  |         expect(result).toHaveProperty("assignments"); | ||||||
|  |         expect(Array.isArray(result.assignments)).toBe(true); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should handle fetching a non-existent class", async () => { | ||||||
|  |         const nonExistentId = "NON_EXISTENT_ID"; | ||||||
|  |         await expect(controller.getById(nonExistentId)).rejects.toThrow(); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should handle deleting a non-existent class", async () => { | ||||||
|  |         const nonExistentId = "NON_EXISTENT_ID"; | ||||||
|  |         await expect(controller.deleteClass(nonExistentId)).rejects.toThrow(); | ||||||
|     }); |     }); | ||||||
| }); | }); | ||||||
|  |  | ||||||
							
								
								
									
										49
									
								
								frontend/tests/utils/array-utils.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								frontend/tests/utils/array-utils.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,49 @@ | ||||||
|  | import { copyArrayWith } from "../../src/utils/array-utils"; | ||||||
|  | import { describe, it, expect } from "vitest"; | ||||||
|  | 
 | ||||||
|  | describe("copyArrayWith", () => { | ||||||
|  |     it("should replace the element at the specified index", () => { | ||||||
|  |         const original = [1, 2, 3, 4]; | ||||||
|  |         const result = copyArrayWith(2, 99, original); | ||||||
|  |         expect(result).toEqual([1, 2, 99, 4]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should not modify the original array", () => { | ||||||
|  |         const original = ["a", "b", "c"]; | ||||||
|  |         const result = copyArrayWith(1, "x", original); | ||||||
|  |         expect(original).toEqual(["a", "b", "c"]); // Original remains unchanged
 | ||||||
|  |         expect(result).toEqual(["a", "x", "c"]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should handle replacing the first element", () => { | ||||||
|  |         const original = [true, false, true]; | ||||||
|  |         const result = copyArrayWith(0, false, original); | ||||||
|  |         expect(result).toEqual([false, false, true]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should handle replacing the last element", () => { | ||||||
|  |         const original = ["apple", "banana", "cherry"]; | ||||||
|  |         const result = copyArrayWith(2, "date", original); | ||||||
|  |         expect(result).toEqual(["apple", "banana", "date"]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should work with complex objects", () => { | ||||||
|  |         const original = [{ id: 1 }, { id: 2 }, { id: 3 }]; | ||||||
|  |         const newValue = { id: 99 }; | ||||||
|  |         const result = copyArrayWith(1, newValue, original); | ||||||
|  |         expect(result).toEqual([{ id: 1 }, { id: 99 }, { id: 3 }]); | ||||||
|  |         expect(original[1].id).toBe(2); // Original remains unchanged
 | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should allow setting to undefined", () => { | ||||||
|  |         const original = [1, 2, 3]; | ||||||
|  |         const result = copyArrayWith(1, undefined, original); | ||||||
|  |         expect(result).toEqual([1, undefined, 3]); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should allow setting to null", () => { | ||||||
|  |         const original = [1, 2, 3]; | ||||||
|  |         const result = copyArrayWith(1, null, original); | ||||||
|  |         expect(result).toEqual([1, null, 3]); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
							
								
								
									
										86
									
								
								frontend/tests/utils/assignment-utils.test.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								frontend/tests/utils/assignment-utils.test.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,86 @@ | ||||||
|  | import { LearningPathNode } from "@dwengo-1/backend/dist/entities/content/learning-path-node.entity"; | ||||||
|  | import { calculateProgress } from "../../src/utils/assignment-utils"; | ||||||
|  | import { LearningPath } from "../../src/data-objects/learning-paths/learning-path"; | ||||||
|  | import { describe, it, expect } from "vitest"; | ||||||
|  | 
 | ||||||
|  | describe("calculateProgress", () => { | ||||||
|  |     it("should return 0 when no nodes are completed", () => { | ||||||
|  |         const lp = new LearningPath({ | ||||||
|  |             language: "en", | ||||||
|  |             hruid: "test-path", | ||||||
|  |             title: "Test Path", | ||||||
|  |             description: "Test Description", | ||||||
|  |             amountOfNodes: 10, | ||||||
|  |             amountOfNodesLeft: 10, | ||||||
|  |             keywords: ["test"], | ||||||
|  |             targetAges: { min: 10, max: 15 }, | ||||||
|  |             startNode: {} as LearningPathNode, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(calculateProgress(lp)).toBe(0); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should return 100 when all nodes are completed", () => { | ||||||
|  |         const lp = new LearningPath({ | ||||||
|  |             language: "en", | ||||||
|  |             hruid: "test-path", | ||||||
|  |             title: "Test Path", | ||||||
|  |             description: "Test Description", | ||||||
|  |             amountOfNodes: 10, | ||||||
|  |             amountOfNodesLeft: 0, | ||||||
|  |             keywords: ["test"], | ||||||
|  |             targetAges: { min: 10, max: 15 }, | ||||||
|  |             startNode: {} as LearningPathNode, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(calculateProgress(lp)).toBe(100); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should return 50 when half of the nodes are completed", () => { | ||||||
|  |         const lp = new LearningPath({ | ||||||
|  |             language: "en", | ||||||
|  |             hruid: "test-path", | ||||||
|  |             title: "Test Path", | ||||||
|  |             description: "Test Description", | ||||||
|  |             amountOfNodes: 10, | ||||||
|  |             amountOfNodesLeft: 5, | ||||||
|  |             keywords: ["test"], | ||||||
|  |             targetAges: { min: 10, max: 15 }, | ||||||
|  |             startNode: {} as LearningPathNode, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(calculateProgress(lp)).toBe(50); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should handle floating point progress correctly", () => { | ||||||
|  |         const lp = new LearningPath({ | ||||||
|  |             language: "en", | ||||||
|  |             hruid: "test-path", | ||||||
|  |             title: "Test Path", | ||||||
|  |             description: "Test Description", | ||||||
|  |             amountOfNodes: 3, | ||||||
|  |             amountOfNodesLeft: 1, | ||||||
|  |             keywords: ["test"], | ||||||
|  |             targetAges: { min: 10, max: 15 }, | ||||||
|  |             startNode: {} as LearningPathNode, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(calculateProgress(lp)).toBeCloseTo(66.666, 2); | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     it("should handle edge case where amountOfNodesLeft is negative", () => { | ||||||
|  |         const lp = new LearningPath({ | ||||||
|  |             language: "en", | ||||||
|  |             hruid: "test-path", | ||||||
|  |             title: "Test Path", | ||||||
|  |             description: "Test Description", | ||||||
|  |             amountOfNodes: 10, | ||||||
|  |             amountOfNodesLeft: -5, | ||||||
|  |             keywords: ["test"], | ||||||
|  |             targetAges: { min: 10, max: 15 }, | ||||||
|  |             startNode: {} as LearningPathNode, | ||||||
|  |         }); | ||||||
|  | 
 | ||||||
|  |         expect(calculateProgress(lp)).toBe(150); | ||||||
|  |     }); | ||||||
|  | }); | ||||||
|  | @ -1,82 +0,0 @@ | ||||||
| import { describe, expect, it } from "vitest"; |  | ||||||
| import { |  | ||||||
|     assignmentTitleRules, |  | ||||||
|     classRules, |  | ||||||
|     deadlineRules, |  | ||||||
|     descriptionRules, |  | ||||||
|     learningPathRules, |  | ||||||
| } from "../../src/utils/assignment-rules"; |  | ||||||
| 
 |  | ||||||
| describe("Validation Rules", () => { |  | ||||||
|     describe("assignmentTitleRules", () => { |  | ||||||
|         it("should return true for a valid title", () => { |  | ||||||
|             const result = assignmentTitleRules[0]("Valid Title"); |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         it("should return an error message for an empty title", () => { |  | ||||||
|             const result = assignmentTitleRules[0](""); |  | ||||||
|             expect(result).toBe("Title cannot be empty."); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     describe("learningPathRules", () => { |  | ||||||
|         it("should return true for a valid learning path", () => { |  | ||||||
|             const result = learningPathRules[0]({ hruid: "123", title: "Path Title" }); |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         it("should return an error message for an invalid learning path", () => { |  | ||||||
|             const result = learningPathRules[0]({ hruid: "", title: "" }); |  | ||||||
|             expect(result).toBe("You must select a learning path."); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     describe("classRules", () => { |  | ||||||
|         it("should return true for a valid class", () => { |  | ||||||
|             const result = classRules[0]("Class 1"); |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         it("should return an error message for an empty class", () => { |  | ||||||
|             const result = classRules[0](""); |  | ||||||
|             expect(result).toBe("You must select at least one class."); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     describe("deadlineRules", () => { |  | ||||||
|         it("should return true for a valid future deadline", () => { |  | ||||||
|             const futureDate = new Date(Date.now() + 1000 * 60 * 60).toISOString(); |  | ||||||
|             const result = deadlineRules[0](futureDate); |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         it("should return an error message for a past deadline", () => { |  | ||||||
|             const pastDate = new Date(Date.now() - 1000 * 60 * 60).toISOString(); |  | ||||||
|             const result = deadlineRules[0](pastDate); |  | ||||||
|             expect(result).toBe("The deadline must be in the future."); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         it("should return an error message for an invalid date", () => { |  | ||||||
|             const result = deadlineRules[0]("invalid-date"); |  | ||||||
|             expect(result).toBe("Invalid date or time."); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         it("should return an error message for an empty deadline", () => { |  | ||||||
|             const result = deadlineRules[0](""); |  | ||||||
|             expect(result).toBe("You must set a deadline."); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| 
 |  | ||||||
|     describe("descriptionRules", () => { |  | ||||||
|         it("should return true for a valid description", () => { |  | ||||||
|             const result = descriptionRules[0]("This is a valid description."); |  | ||||||
|             expect(result).toBe(true); |  | ||||||
|         }); |  | ||||||
| 
 |  | ||||||
|         it("should return an error message for an empty description", () => { |  | ||||||
|             const result = descriptionRules[0](""); |  | ||||||
|             expect(result).toBe("Description cannot be empty."); |  | ||||||
|         }); |  | ||||||
|     }); |  | ||||||
| }); |  | ||||||
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana