diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index 0534b02..8e423a3 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -1,4 +1,4 @@ -import { and, eq, ilike, or, sql } from "drizzle-orm"; +import { and, eq, ilike, isNull, inArray, or, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import db from "../../drizzle"; @@ -42,6 +42,133 @@ async function updateFilenameInDatabase(answerId: string, filename: string): Pro const assessmentsRoute = new Hono() .use(authInfo) + // Get all aspects + .get( + "/aspect", + // checkPermission("managementAspect.readAll"), + requestValidator( + "query", + z.object({ + includeTrashed: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(10), + q: z.string().default(""), + }) + ), + async (c) => { + const { includeTrashed, page, limit, q } = c.req.valid("query"); + + const totalCountQuery = includeTrashed + ? sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})` + : sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; + + const aspectIdsQuery = await db + .select({ + id: aspects.id, + }) + .from(aspects) + .where( + and( + includeTrashed ? undefined : isNull(aspects.deletedAt), + q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined + ) + ) + .offset(page * limit) + .limit(limit); + + const aspectIds = aspectIdsQuery.map(a => a.id); + + if (aspectIds.length === 0) { + return c.json({ + data: [], + _metadata: { + currentPage: page, + totalPages: 0, + totalItems: 0, + perPage: limit, + }, + }); + } + + // Main query to get aspects, sub-aspects, and number of questions + const result = await db + .select({ + id: aspects.id, + name: aspects.name, + createdAt: aspects.createdAt, + updatedAt: aspects.updatedAt, + ...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}), + subAspectId: subAspects.id, + subAspectName: subAspects.name, + // Increase the number of questions related to sub aspects + questionCount: sql`( + SELECT count(*) + FROM ${questions} + WHERE ${questions.subAspectId} = ${subAspects.id} + )`.as('questionCount'), + fullCount: totalCountQuery, + }) + .from(aspects) + .leftJoin(subAspects, eq(subAspects.aspectId, aspects.id)) + .where(inArray(aspects.id, aspectIds)); + + // Grouping sub aspects by aspect ID + const groupedResult = result.reduce((acc, curr) => { + const aspectId = curr.id; + + if (!acc[aspectId]) { + acc[aspectId] = { + id: curr.id, + name: curr.name, + createdAt: curr.createdAt ? new Date(curr.createdAt).toISOString() : null, + updatedAt: curr.updatedAt ? new Date(curr.updatedAt).toISOString() : null, + subAspects: curr.subAspectName + ? [{ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount }] + : [], + }; + } else { + if (curr.subAspectName) { + const exists = acc[aspectId].subAspects.some(sub => sub.id === curr.subAspectId); + if (!exists) { + acc[aspectId].subAspects.push({ + id: curr.subAspectId!, + name: curr.subAspectName, + questionCount: curr.questionCount, + }); + } + } + } + + return acc; + }, {} as Record); + + const groupedArray = Object.values(groupedResult); + + return c.json({ + data: groupedArray, + _metadata: { + currentPage: page, + totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit), + totalItems: Number(result[0]?.fullCount) ?? 0, + perPage: limit, + }, + }); + } + ) + // Get data for current Assessment Score from submitted options By Assessment Id .get( "/getCurrentAssessmentScore", @@ -54,7 +181,7 @@ const assessmentsRoute = new Hono() ), async (c) => { const { assessmentId } = c.req.valid("query"); - + // Query to sum the scores of selected options for the current assessment const result = await db .select({ @@ -64,20 +191,35 @@ const assessmentsRoute = new Hono() .leftJoin(options, eq(answers.optionId, options.id)) .where(eq(answers.assessmentId, assessmentId)) .execute(); - + return c.json({ assessmentId, totalScore: result[0]?.totalScore ?? 0, // Return 0 if no answers are found }); } ) - + // Get all Questions and Options that relate to Sub Aspects and Aspects .get( "/getAllQuestions", checkPermission("assessments.readAllQuestions"), async (c) => { - const totalCountQuery = + // Definisikan tipe untuk hasil query dan izinkan nilai null + type QuestionWithOptions = { + aspectsId: string | null; + aspectsName: string | null; + subAspectId: string | null; + subAspectName: string | null; + questionId: string | null; + questionText: string | null; + optionId: string; + optionText: string; + needFile: boolean | null; + optionScore: number; + fullCount?: number; + }; + + const totalCountQuery = sql`(SELECT count(*) FROM ${options} LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id} @@ -86,16 +228,18 @@ const assessmentsRoute = new Hono() WHERE ${questions.deletedAt} IS NULL )`; - const result = await db + // Sesuaikan tipe hasil query + const result: QuestionWithOptions[] = await db .select({ - optionId: options.id, aspectsId: aspects.id, aspectsName: aspects.name, subAspectId: subAspects.id, subAspectName: subAspects.name, questionId: questions.id, questionText: questions.question, + optionId: options.id, optionText: options.text, + needFile: questions.needFile, optionScore: options.score, fullCount: totalCountQuery, }) @@ -103,15 +247,63 @@ const assessmentsRoute = new Hono() .leftJoin(questions, eq(options.questionId, questions.id)) .leftJoin(subAspects, eq(questions.subAspectId, subAspects.id)) .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) - .where(sql`${questions.deletedAt} IS NULL`) + .where(sql`${questions.deletedAt} IS NULL`); + + // Definisikan tipe untuk hasil pengelompokan + type GroupedQuestion = { + questionId: string | null; + questionText: string | null; + needFile: boolean | null; + aspectsId: string | null; + aspectsName: string | null; + subAspectId: string | null; + subAspectName: string | null; + options: { + optionId: string; + optionText: string; + optionScore: number; + }[]; + }; + + // Mengelompokkan berdasarkan questionId + const groupedResult: GroupedQuestion[] = result.reduce((acc, current) => { + const { questionId, questionText, needFile, aspectsId, aspectsName, subAspectId, subAspectName, optionId, optionText, optionScore } = current; + + // Cek apakah questionId sudah ada dalam accumulator + const existingQuestion = acc.find(q => q.questionId === questionId); + + if (existingQuestion) { + // Tambahkan opsi baru ke array options dari pertanyaan yang ada + existingQuestion.options.push({ + optionId, + optionText, + optionScore + }); + } else { + // Jika pertanyaan belum ada, tambahkan objek baru + acc.push({ + questionId, + questionText, + needFile, + aspectsId, + aspectsName, + subAspectId, + subAspectName, + options: [ + { + optionId, + optionText, + optionScore + } + ] + }); + } + + return acc; + }, [] as GroupedQuestion[]); // Pastikan tipe untuk accumulator didefinisikan return c.json({ - data: result.map((d) => ( - { - ...d, - fullCount: undefined - } - )), + data: groupedResult, }); } ) @@ -135,13 +327,13 @@ const assessmentsRoute = new Hono() ), async (c) => { const { assessmentId, page, limit, q } = c.req.valid("query"); - + // Query to count total answers for the specific assessmentId - const totalCountQuery = + const totalCountQuery = sql`(SELECT count(*) FROM ${answers} WHERE ${answers.assessmentId} = ${assessmentId})`; - + // Query to retrieve answers for the specific assessmentId const result = await db .select({ @@ -159,16 +351,16 @@ const assessmentsRoute = new Hono() eq(answers.assessmentId, assessmentId), // Filter by assessmentId q ? or( - ilike(answers.filename, q), - ilike(answers.validationInformation, q), - eq(answers.id, q) - ) + ilike(answers.filename, q), + ilike(answers.validationInformation, q), + eq(answers.id, q) + ) : undefined ) ) .offset(page * limit) .limit(limit); - + return c.json({ data: result.map((d) => ({ ...d, fullCount: undefined })), _metadata: { @@ -185,48 +377,46 @@ const assessmentsRoute = new Hono() // Toggles the isFlagged field between true and false .patch( - "/:id/toggleFlag", + "/:questionId/toggleFlag", checkPermission("assessments.toggleFlag"), async (c) => { - const answerId = c.req.param("id"); - - // Retrieve the current state of isFlagged + const questionId = c.req.param("questionId"); + + // Join answers and options to retrieve answer based on questionId const currentAnswer = await db .select({ isFlagged: answers.isFlagged, + answerId: answers.id, }) .from(answers) - .where(eq(answers.id, answerId)) + .innerJoin(options, eq(answers.optionId, options.id)) + .where(eq(options.questionId, questionId)) .limit(1); - + if (!currentAnswer.length) { - throw notFound( - { - message: "Answer not found", - } - ) + throw notFound({ + message: "Answer not found", + }); } - + // Toggle the isFlagged value const newIsFlaggedValue = !currentAnswer[0].isFlagged; - + // Update the answer with the toggled value const updatedAnswer = await db .update(answers) .set({ isFlagged: newIsFlaggedValue, }) - .where(eq(answers.id, answerId)) + .where(eq(answers.id, currentAnswer[0].answerId)) .returning(); - + if (!updatedAnswer.length) { - throw notFound( - { - message: "Failed to update answer", - } - ) + throw notFound({ + message: "Failed to update answer", + }); } - + return c.json( { message: "Answer flag toggled successfully", @@ -235,7 +425,7 @@ const assessmentsRoute = new Hono() 200 ); } - ) + ) // Get data answers from table answers by optionId and assessmentId .post( @@ -243,7 +433,7 @@ const assessmentsRoute = new Hono() checkPermission("assessments.checkAnswer"), async (c) => { const { optionId, assessmentId } = await c.req.json(); - + const result = await db .select() .from(answers) @@ -251,24 +441,24 @@ const assessmentsRoute = new Hono() and(eq(answers.optionId, optionId), eq(answers.assessmentId, assessmentId)) ) .execute(); - + const existingAnswer = result[0]; let response; - + if (existingAnswer) { - response = { - exists: true, - answerId: existingAnswer.id + response = { + exists: true, + answerId: existingAnswer.id }; } else { - response = { - exists: false + response = { + exists: false }; } - + return c.json(response); } - ) + ) // Upload filename to the table answers and save the file on the local storage .post( @@ -282,7 +472,7 @@ const assessmentsRoute = new Hono() message: "Invalid Content-Type", }); } - + // Extract boundary const boundary = contentType.split('boundary=')[1]; if (!boundary) { @@ -290,16 +480,16 @@ const assessmentsRoute = new Hono() message: "Boundary not found", }); } - + // Get the raw body const body = await c.req.arrayBuffer(); const bodyString = Buffer.from(body).toString(); - + // Split the body by the boundary const parts = bodyString.split(`--${boundary}`); - + let fileUrl = null; - + for (const part of parts) { if (part.includes('Content-Disposition: form-data;')) { // Extract file name @@ -308,14 +498,14 @@ const assessmentsRoute = new Hono() const fileName = match[1]; const fileContentStart = part.indexOf('\r\n\r\n') + 4; const fileContentEnd = part.lastIndexOf('\r\n'); - + // Extract file content as Buffer const fileBuffer = Buffer.from(part.slice(fileContentStart, fileContentEnd), 'binary'); - + // Define file path and save the file const filePath = path.join('images', Date.now() + '-' + fileName); await saveFile(filePath, fileBuffer); - + // Assuming answerId is passed as a query parameter or in the form-data const answerId = c.req.query('answerId'); if (!answerId) { @@ -323,29 +513,29 @@ const assessmentsRoute = new Hono() message: "answerId is required", }); } - + await updateFilenameInDatabase(answerId, path.basename(filePath)); - + // Set the file URL for the final response fileUrl = `/images/${path.basename(filePath)}`; } } } - + if (!fileUrl) { throw notFound({ message: 'No file uploaded', }); } - + return c.json( - { - success: true, - imageUrl: fileUrl + { + success: true, + imageUrl: fileUrl } ); } - ) + ) // Submit option to table answers from use-form in frontend .post( @@ -384,7 +574,7 @@ const assessmentsRoute = new Hono() async (c) => { const answerId = c.req.param("id"); const answerData = c.req.valid("json"); - + const updatedAnswer = await db .update(answers) .set({ @@ -392,27 +582,27 @@ const assessmentsRoute = new Hono() }) .where(eq(answers.id, answerId)) .returning(); - + if (!updatedAnswer.length) { throw notFound({ message: "Answer not found or update failed" }) } - return c.json({ - message: "Answer updated successfully", - answer: updatedAnswer[0], + return c.json({ + message: "Answer updated successfully", + answer: updatedAnswer[0], }); } ) // Get data for One Sub Aspect average score By Sub Aspect Id and Assessment Id .get( - '/average-score/sub-aspects/:subAspectId/assessments/:assessmentId', - checkPermission("assessments.readAverageSubAspect"), + '/average-score/sub-aspects/:subAspectId/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), async (c) => { const { subAspectId, assessmentId } = c.req.param(); - + const averageScore = await db .select({ subAspectName: subAspects.name, @@ -427,7 +617,7 @@ const assessmentsRoute = new Hono() sql`sub_aspects.id = ${subAspectId} AND assessments.id = ${assessmentId}` ) .groupBy(subAspects.id); - + return c.json({ subAspectId, subAspectName: averageScore[0].subAspectName, @@ -437,13 +627,13 @@ const assessmentsRoute = new Hono() } ) - // Get data for All Sub Aspects average score By Assessment Id + // Get data for All Sub Aspects average score By Assessment Id .get( - '/average-score/sub-aspects/assessments/:assessmentId', - checkPermission("assessments.readAverageAllSubAspects"), + '/average-score/sub-aspects/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), async (c) => { const { assessmentId } = c.req.param(); - + const averageScores = await db .select({ aspectId: subAspects.aspectId, @@ -458,7 +648,7 @@ const assessmentsRoute = new Hono() .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) .where(eq(assessments.id, assessmentId)) .groupBy(subAspects.id); - + return c.json({ assessmentId, subAspects: averageScores.map(score => ({ @@ -473,11 +663,11 @@ const assessmentsRoute = new Hono() // Get data for One Aspect average score By Aspect Id and Assessment Id .get( - "/average-score/aspects/:aspectId/assessments/:assessmentId", - checkPermission("assessments.readAverageAspect"), + "/average-score/aspects/:aspectId/assessments/:assessmentId", + // checkPermission("assessments.readAverageAspect"), async (c) => { const { aspectId, assessmentId } = c.req.param(); - + const averageScore = await db .select({ aspectName: aspects.name, @@ -493,7 +683,7 @@ const assessmentsRoute = new Hono() sql`aspects.id = ${aspectId} AND assessments.id = ${assessmentId}` ) .groupBy(aspects.id); - + return c.json({ aspectId, aspectName: averageScore[0].aspectName, @@ -505,11 +695,11 @@ const assessmentsRoute = new Hono() // Get data for All Aspects average score By Assessment Id .get( - '/average-score/aspects/assessments/:assessmentId', - checkPermission("assessments.readAverageAllAspects"), + '/average-score/aspects/assessments/:assessmentId', + // checkPermission("assessments.readAssessmentScore"), async (c) => { const { assessmentId } = c.req.param(); - + const averageScores = await db .select({ AspectId: aspects.id, @@ -524,7 +714,7 @@ const assessmentsRoute = new Hono() .innerJoin(assessments, eq(answers.assessmentId, assessments.id)) .where(eq(assessments.id, assessmentId)) .groupBy(aspects.id); - + return c.json({ assessmentId, aspects: averageScores.map(score => ({ diff --git a/apps/frontend/src/components/AppNavbar.tsx b/apps/frontend/src/components/AppNavbar.tsx index 4d10139..d0b1dde 100644 --- a/apps/frontend/src/components/AppNavbar.tsx +++ b/apps/frontend/src/components/AppNavbar.tsx @@ -19,7 +19,7 @@ export default function AppNavbar() { // const {user} = useAuth(); const { pathname } = useLocation(); - const pathsThatCloseSidebar = ["/assessmentResult"]; + const pathsThatCloseSidebar = ["/assessmentResult", "/assessment"]; const [isSidebarOpen, setSidebarOpen] = useState(true); const toggleSidebar = () => { @@ -60,6 +60,12 @@ export default function AppNavbar() { } }; + useEffect(() => { + if (pathname === "/assessment"){ + setSidebarOpen(false); + } + }) + return ( <>
diff --git a/apps/frontend/src/modules/assessmentManagement/queries/assessmentQueries.ts b/apps/frontend/src/modules/assessmentManagement/queries/assessmentQueries.ts new file mode 100644 index 0000000..652c5a0 --- /dev/null +++ b/apps/frontend/src/modules/assessmentManagement/queries/assessmentQueries.ts @@ -0,0 +1,120 @@ +import client from "@/honoClient"; +import fetchRPC from "@/utils/fetchRPC"; +import { queryOptions } from "@tanstack/react-query"; +import { InferRequestType } from "hono"; + +// Query untuk mendapatkan skor assessment saat ini +export const getCurrentAssessmentScoreQueryOptions = (assessmentId: string) => + queryOptions({ + queryKey: ["assessment", { assessmentId }], + queryFn: () => + fetchRPC( + client.assessments.getCurrentAssessmentScore.$get({ + query: { + assessmentId, + }, + }) + ), + }); + +export const fetchAspects = async () => { + return await fetchRPC( + client.assessments.aspect.$get({ + query: {} + }) + ); +}; + +// Query untuk mendapatkan semua pertanyaan berdasarkan halaman dan limit +export const getQuestionsAllQueryOptions = (page: number, limit: number, q?: string) => + queryOptions({ + queryKey: ["assessment", { page, limit, q }], + queryFn: () => + fetchRPC( + client.assessments.getAllQuestions.$get({ + query: { + limit: String(limit), + page: String(page), + q: q || "", + }, + }) + ), + }); + +// Query untuk mendapatkan jawaban berdasarkan assessment ID +export const getAnswersQueryOptions = (assessmentId: string, page: number, limit: number, q?: string) => + queryOptions({ + queryKey: ["assessment", { assessmentId, page, limit, q }], + queryFn: () => + fetchRPC( + client.assessments.getAnswers.$get({ + query: { + assessmentId, + limit: String(limit), + page: String(page), + q: q || "", + }, + }) + ), + }); + +// Query untuk toggle flag jawaban berdasarkan questionId +export const toggleFlagAnswer = async (questionId: string) => { + return await fetchRPC( + client.assessments[":questionId"].toggleFlag.$patch({ + param: { questionId } + }) + ); +}; + +// Opsional: Jika Anda ingin menggunakan react-query untuk toggleFlag +export const toggleFlagAnswerMutationOptions = (questionId: string) => ({ + mutationFn: () => toggleFlagAnswer(questionId), +}); + +// Di file queries (sesuaikan tipe yang diperlukan untuk submitAnswer) +export const submitAnswer = async ( + form: { + optionId: string; + assessmentId: string; + validationInformation: string; + } +) => { + return await fetchRPC( + client.assessments.submitAnswer.$post({ + json: form, + }) + ); +}; + +// Opsional: Jika Anda ingin menggunakan react-query untuk submitAnswer +export const submitAnswerMutationOptions = () => ({ + mutationFn: submitAnswer, +}); + +// Query untuk mendapatkan rata-rata skor berdasarkan aspectId dan assessmentId +export const getAverageScoreQueryOptions = (assessmentId: string) => + queryOptions({ + queryKey: ["averageScore", { assessmentId }], + queryFn: () => + fetchRPC( + client.assessments["average-score"].aspects.assessments[":assessmentId"].$get({ + param: { + assessmentId, + }, + }) + ), + }); + +export const getAverageScoreSubAspectQueryOptions = (assessmentId: string) => + queryOptions({ + queryKey: ["averageScoreSubAspects", { assessmentId }], + queryFn: () => + fetchRPC( + client.assessments["average-score"]["sub-aspects"].assessments[":assessmentId"].$get({ + param: { + assessmentId, + }, + }) + ), + }); \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessment/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessment/index.lazy.tsx new file mode 100644 index 0000000..aae7df2 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessment/index.lazy.tsx @@ -0,0 +1,543 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import { + Card, + Flex, + Pagination, + Stack, + Radio, + Text, + Textarea, + Loader, + ActionIcon, + CloseButton, + Group, +} from "@mantine/core"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { + getAnswersQueryOptions, + getAverageScoreSubAspectQueryOptions, + getAverageScoreQueryOptions, + fetchAspects, + submitAnswerMutationOptions, + getQuestionsAllQueryOptions, + toggleFlagAnswer, +} from "@/modules/assessmentManagement/queries/assessmentQueries"; +import { TbFlagFilled, TbUpload, TbChevronRight, TbChevronUp } from "react-icons/tb"; +import { useState, useRef, useEffect } from "react"; + +const getQueryParam = (param: string) => { + const urlParams = new URLSearchParams(window.location.search); + return urlParams.get(param); +}; + +export const Route = createLazyFileRoute("/_dashboardLayout/assessment/")({ + component: AssessmentPage, +}); + +interface ToggleFlagResponse { + message: string; + answer: { + id: string; + createdAt: string | null; + updatedAt: string | null; + optionId: string | null; + assessmentId: string | null; + isFlagged: boolean | null; + filename: string | null; + validationInformation: string; + }; +} + +export default function AssessmentPage() { + const [page, setPage] = useState(1); + const limit = 10; + const questionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({}); + const [files, setFiles] = useState([]); + const [dragActive, setDragActive] = useState(false); + const [flaggedQuestions, setFlaggedQuestions] = useState<{ + [key: string]: boolean; + }>({}); + const fileInputRef = useRef(null); + + const [selectedSubAspectId, setSelectedSubAspectId] = useState(null); + const [assessmentId, setAssessmentId] = useState(null); + const [answers, setAnswers] = useState<{ [key: string]: string }>({}); + + // Fetch aspects and sub-aspects + const aspectsQuery = useQuery({ + queryKey: ["aspects"], + queryFn: fetchAspects, + }); + + // Fetching questions data using useQuery + const { data, isLoading, isError, error } = useQuery( + getQuestionsAllQueryOptions(page, limit) + ); + + useEffect(() => { + const id = getQueryParam("id"); + + if (!id) { + // Handle if no ID found + setAssessmentId(null); + } else { + setAssessmentId(id); + } + + // Check if aspectsQuery.data and aspectsQuery.data.data are defined + if (aspectsQuery.data?.data && aspectsQuery.data.data.length > 0) { + // If no sub-aspect is selected, find a suitable default + if (selectedSubAspectId === null) { + const firstMatchingSubAspect = aspectsQuery.data.data + .flatMap(aspect => aspect.subAspects) // Get all sub-aspects + .find(subAspect => data?.data.some(question => question.subAspectId === subAspect.id)); // Filter based on available questions + + if (firstMatchingSubAspect) { + setSelectedSubAspectId(firstMatchingSubAspect.id); + } + } + } + }, [aspectsQuery.data, selectedSubAspectId, data?.data]); + + // Tambahkan state untuk aspek yang terbuka + const [openAspects, setOpenAspects] = useState<{ [key: string]: boolean }>({}); + + const toggleAspect = (aspectId: string) => { + setOpenAspects((prev) => ({ + ...prev, + [aspectId]: !prev[aspectId], // Toggle state untuk aspek yang diklik + })); + }; + + // Fetch average scores berdasarkan assessmentId yang diambil dari URL + const averageScoreQuery = useQuery( + getAverageScoreQueryOptions(assessmentId || "") + ); + + // Fetch average scores for sub-aspects + const averageScoreSubAspectQuery = useQuery( + getAverageScoreSubAspectQueryOptions(assessmentId || "") + ); + + // Mutation function to toggle flag + const toggleFlagMutation = useMutation({ + mutationFn: toggleFlagAnswer, + onSuccess: (response) => { + if (response && response.answer) { + const { answer } = response; + setFlaggedQuestions((prevFlags) => ({ + ...prevFlags, + [answer.id]: answer.isFlagged !== null ? answer.isFlagged : false, + })); + } + }, + + onError: (error) => { + console.error("Error toggling flag:", error); + }, + }); + + // Inside the AssessmentPage function: + const submitAnswerMutation = useMutation(submitAnswerMutationOptions()); + + const handleAnswerChange = (optionId: string) => { + submitAnswerMutation.mutate({ + optionId: optionId, + assessmentId: assessmentId || "", + validationInformation: "someValidationInfo", // Sesuaikan validasi yang relevan + }); + }; + + // Drag and Drop handlers + const handleDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setDragActive(true); + }; + + const handleDragLeave = () => { + setDragActive(false); + }; + + const handleDrop = (event: React.DragEvent) => { + event.preventDefault(); + setDragActive(false); + const droppedFiles = Array.from(event.dataTransfer.files); + setFiles((prevFiles) => [...prevFiles, ...droppedFiles]); + }; + + const handleClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files) { + const fileArray = Array.from(event.target.files); // Ubah ke array hanya jika files tidak null + setFiles((prevFiles) => [...prevFiles, ...fileArray]); + } + }; + + const handleRemoveFile = (fileIndex: number) => { + setFiles((prevFiles) => + prevFiles.filter((_, index) => index !== fileIndex) + ); + }; + + // Function to scroll to the specific question + const scrollToQuestion = (questionId: string) => { + const questionElement = questionRefs.current[questionId]; + if (questionElement) { + questionElement.scrollIntoView({ behavior: "smooth" }); + } + }; + + // Handle pagination + const handlePageChange = (newPage: number) => { + setPage(newPage); + }; + + // Render conditions + if (isLoading) { + return ; + } + + if (isError) { + return ( + + Error: {error?.message || "Terjadi kesalahan saat memuat pertanyaan."} + + ); + } + + const totalQuestions = data?.data?.length || 0; + const totalPages = Math.ceil(totalQuestions / limit); + const averageScores = averageScoreQuery.data?.aspects; + const averageScoresSubAspect = averageScoreSubAspectQuery.data?.subAspects; + + if (!assessmentId) { + return ( + + + Error: Data Asesmen tidak ditemukan. Harap akses halaman melalui link yang valid. + + + ); + } + + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedQuestions = data?.data.slice(startIndex, endIndex) || []; + + const filteredQuestions = paginatedQuestions.filter((question) => { + return question.subAspectId === selectedSubAspectId; // Misalnya, jika `question` memiliki `subAspectId` + }); + + return ( + + + + Harap menjawab semua pertanyaan yang tersedia + + Semua jawaban Anda akan ditinjau + + + + {/* LEFT-SIDE */} + {/* Aspek dan Sub-Aspek */} + +
+ {aspectsQuery.data?.data + .filter((aspect) => + aspect.subAspects.some((subAspect) => + data?.data.some((question) => question.subAspectId === subAspect.id) + ) + ) + .map((aspect) => ( +
+
toggleAspect(aspect.id)} + > +
{aspect.name}
+
+ {openAspects[aspect.id] ? ( + + ) : ( + + )} +
+
+ + {openAspects[aspect.id] && ( +
+ {aspect.subAspects + .filter((subAspect) => + data?.data.some((question) => question.subAspectId === subAspect.id) + ) + .map((subAspect) => ( +
setSelectedSubAspectId(subAspect.id)} + > +
{subAspect.name}
+
+ ))} +
+ )} +
+ ))} +
+
+ {/* Pertanyaan */} + + {filteredQuestions.length === 0 ? ( + + Pertanyaan tidak ada untuk sub-aspek yang dipilih. + + ) : ( + filteredQuestions.map((question: any, index: number) => { + const questionId = question.questionId; + if (!questionId) return null; + + return ( + (questionRefs.current[questionId] = el)} + style={{ position: "relative" }} + > + + + + {startIndex + index + 1}. {question.questionText} + + + { + setFlaggedQuestions((prevFlags) => ({ + ...prevFlags, + [questionId]: !prevFlags[questionId], + })); + toggleFlagMutation.mutate(questionId); + }} + title="Tandai" + color={flaggedQuestions[questionId] ? "red" : "white"} + style={{ + border: "1px gray solid ", + borderRadius: "4px", + backgroundColor: flaggedQuestions[questionId] ? "red" : "white", + }} + > + + + + + {question.options?.length > 0 ? ( + +
+ {question.options.map((option: any) => ( + + ))} +
+
+ ) : ( + Tidak ada opsi untuk pertanyaan ini. + )} + +