Merge pull request #29 from digitalsolutiongroup/feat/assessment-frontend

Slicing and Integration API for Assessment
This commit is contained in:
Abiyasa Putra Prasetya 2024-10-11 11:28:18 +07:00 committed by GitHub
commit 33309e7eba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 970 additions and 93 deletions

View File

@ -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<HonoEnv>()
.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<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})`
: sql<number>`(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<number>`(
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<string, {
id: string;
name: string;
createdAt: string | null;
updatedAt: string | null;
subAspects: { id: string; name: string; questionCount: number }[];
}>);
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<HonoEnv>()
),
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<HonoEnv>()
.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<number>`(SELECT count(*)
FROM ${options}
LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id}
@ -86,16 +228,18 @@ const assessmentsRoute = new Hono<HonoEnv>()
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<HonoEnv>()
.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<HonoEnv>()
),
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<number>`(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<HonoEnv>()
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<HonoEnv>()
// 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<HonoEnv>()
200
);
}
)
)
// Get data answers from table answers by optionId and assessmentId
.post(
@ -243,7 +433,7 @@ const assessmentsRoute = new Hono<HonoEnv>()
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<HonoEnv>()
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<HonoEnv>()
message: "Invalid Content-Type",
});
}
// Extract boundary
const boundary = contentType.split('boundary=')[1];
if (!boundary) {
@ -290,16 +480,16 @@ const assessmentsRoute = new Hono<HonoEnv>()
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<HonoEnv>()
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<HonoEnv>()
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<HonoEnv>()
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<HonoEnv>()
})
.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<HonoEnv>()
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<HonoEnv>()
}
)
// 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<HonoEnv>()
.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<HonoEnv>()
// 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<HonoEnv>()
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<HonoEnv>()
// 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<HonoEnv>()
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
.where(eq(assessments.id, assessmentId))
.groupBy(aspects.id);
return c.json({
assessmentId,
aspects: averageScores.map(score => ({

View File

@ -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 (
<>
<div>

View File

@ -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,
},
})
),
});

View File

@ -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<File[]>([]);
const [dragActive, setDragActive] = useState(false);
const [flaggedQuestions, setFlaggedQuestions] = useState<{
[key: string]: boolean;
}>({});
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedSubAspectId, setSelectedSubAspectId] = useState<string | null>(null);
const [assessmentId, setAssessmentId] = useState<string | null>(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<ToggleFlagResponse, Error, string>({
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<HTMLDivElement>) => {
event.preventDefault();
setDragActive(true);
};
const handleDragLeave = () => {
setDragActive(false);
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
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<HTMLInputElement>) => {
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 <Loader />;
}
if (isError) {
return (
<Text color="red">
Error: {error?.message || "Terjadi kesalahan saat memuat pertanyaan."}
</Text>
);
}
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 (
<Card shadow="sm" p="lg" radius="md" withBorder>
<Text color="red" className="text-center">
Error: Data Asesmen tidak ditemukan. Harap akses halaman melalui link yang valid.
</Text>
</Card>
);
}
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 (
<Card shadow="sm" p="lg" radius="md" withBorder>
<Stack gap="md">
<Text className="text-2xl font-bold">
Harap menjawab semua pertanyaan yang tersedia
</Text>
<Text className="text-gray-400">Semua jawaban Anda akan ditinjau</Text>
<Flex justify="space-between" align="flex-start" mt="lg">
{/* LEFT-SIDE */}
{/* Aspek dan Sub-Aspek */}
<Flex direction="column" gap="xs" className="mr-4 w-52">
<div className="space-y-4">
{aspectsQuery.data?.data
.filter((aspect) =>
aspect.subAspects.some((subAspect) =>
data?.data.some((question) => question.subAspectId === subAspect.id)
)
)
.map((aspect) => (
<div
key={aspect.id}
className="p-4 bg-gray-50 rounded-lg shadow-md"
>
<div
className="flex justify-between cursor-pointer"
onClick={() => toggleAspect(aspect.id)}
>
<div className="text-lg text-gray-700">{aspect.name}</div>
<div>
{openAspects[aspect.id] ? (
<TbChevronUp size={25} />
) : (
<TbChevronRight size={25} />
)}
</div>
</div>
{openAspects[aspect.id] && (
<div className="mt-2 space-y-2">
{aspect.subAspects
.filter((subAspect) =>
data?.data.some((question) => question.subAspectId === subAspect.id)
)
.map((subAspect) => (
<div
key={subAspect.id}
className={`flex justify-between text-gray-600 cursor-pointer ${selectedSubAspectId === subAspect.id ? 'font-bold' : ''}`}
onClick={() => setSelectedSubAspectId(subAspect.id)}
>
<div>{subAspect.name}</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</Flex>
{/* Pertanyaan */}
<Stack gap="sm" style={{ flex: 1 }}>
{filteredQuestions.length === 0 ? (
<Text color="black" className="text-center p-3">
Pertanyaan tidak ada untuk sub-aspek yang dipilih.
</Text>
) : (
filteredQuestions.map((question: any, index: number) => {
const questionId = question.questionId;
if (!questionId) return null;
return (
<Card
key={questionId}
shadow="sm"
p="lg"
radius="md"
withBorder
ref={(el) => (questionRefs.current[questionId] = el)}
style={{ position: "relative" }}
>
<Stack gap="sm">
<Flex justify="space-between" align="flex-start" style={{ width: "100%" }}>
<Text
className="font-bold"
style={{
flexGrow: 1,
wordBreak: "break-word",
marginRight: "40px",
}}
>
{startIndex + index + 1}. {question.questionText}
</Text>
<ActionIcon
onClick={() => {
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",
}}
>
<TbFlagFilled
size={20}
color={flaggedQuestions[questionId] ? "white" : "black"}
style={{
padding: "2px",
}}
/>
</ActionIcon>
</Flex>
{question.options?.length > 0 ? (
<Radio.Group>
<div className="flex flex-col gap-4">
{question.options.map((option: any) => (
<label
key={option.optionId}
className="bg-gray-200 border rounded-lg p-4 cursor-pointer transition-transform transform hover:scale-105 shadow-md hover:shadow-lg flex items-center"
onClick={() => document.getElementById(option.optionId)?.click()}
>
<Radio
id={option.optionId}
className="font-bold"
value={option.optionId}
label={option.optionText}
size="md"
radius="xl"
style={{ pointerEvents: "none" }}
onChange={() => {
submitAnswerMutation.mutate({
optionId: option.optionId,
assessmentId: assessmentId || "",
validationInformation: JSON.stringify({
info: "jfjforjfocn",
questionId: question.questionId,
}),
});
}}
/>
</label>
))}
</div>
</Radio.Group>
) : (
<Text color="red">Tidak ada opsi untuk pertanyaan ini.</Text>
)}
<Textarea placeholder="Berikan keterangan terkait jawaban di atas" />
{/* File Upload */}
{question.needFile === true && (
<div
className={`pt-5 pb-5 pr-5 pl-2 border-2 border-dashed ${dragActive ? "bg-gray-100" : "bg-transparent"
} shadow-lg`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<Flex align="center" justify="space-between" gap="sm">
<TbUpload
size={24}
style={{ marginLeft: "8px", marginRight: "8px" }}
/>
<div className="flex-grow text-right">
<Text className="font-bold">
Klik untuk unggah atau geser file disini
</Text>
<Text className="text-sm text-gray-400">
PNG, JPG, PDF
</Text>
</div>
</Flex>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
style={{ display: "none" }}
accept="image/png, image/jpeg, application/pdf"
/>
</div>
)}
{files.length > 0 && (
<Stack gap="sm" mt="sm">
<Text className="font-bold">File yang diunggah:</Text>
{files.map((file, fileIndex) => (
<Group key={fileIndex} align="center">
<Text>{file.name}</Text>
<CloseButton
title="Hapus file"
onClick={() => handleRemoveFile(fileIndex)}
/>
</Group>
))}
</Stack>
)}
</Stack>
</Card>
);
})
)}
</Stack>
{/* Navigasi dan Pagination */}
<Flex direction="column" gap="xs" className="ml-4">
{/* Navigasi (Number of Questions) */}
<div className="grid grid-cols-5 gap-2">
{filteredQuestions.map((question, index) => {
const questionId = question.questionId;
if (!questionId) return null;
// Menentukan nomor soal berdasarkan indeks pertanyaan yang difilter
const questionNumber = index + 1; // Nomor pertanyaan dimulai dari 1
return (
<div key={questionId} className="flex justify-center relative">
<button
className={`w-10 h-10 border rounded-lg flex items-center justify-center relative
${flaggedQuestions[questionId] ? "text-black" : "bg-transparent text-black"}`}
onClick={() => scrollToQuestion(questionId)}
>
{questionNumber} {/* Menampilkan nomor pertanyaan yang sudah difilter */}
</button>
{flaggedQuestions[questionId] && (
<div className="absolute top-0 right-0 w-0 h-0 border-b-[20px] border-b-transparent border-r-[20px] border-r-black rounded-e-md" />
)}
</div>
);
})}
</div>
<div className="mt-4 flex justify-center">
<Pagination
value={page}
total={Math.ceil(totalQuestions / limit)}
onChange={handlePageChange}
/>
</div>
{/* Skor Aspek dan Sub-Aspek */}
<div className="mt-4">
<Card shadow="sm" p="md" radius="md" withBorder>
<Stack>
{averageScores && averageScores.length > 0 ? (
averageScores.map((aspect: any) => (
<div key={aspect.AspectId} className="flex justify-between items-center">
<Text className="text-xl text-gray-400">
{aspect.AspectName}
</Text>
<Text className="text-xl font-bold">
{parseFloat(aspect.averageScore).toFixed(1)}
</Text>
</div>
))
) : (
<Text>Tidak ada data skor.</Text>
)}
{/* Garis pembatas */}
<div>
<hr className="border-t-2 border-gray-300 w-full mx-auto" />
</div>
{averageScoresSubAspect && averageScoresSubAspect.length > 0 && (
<Stack>
{averageScoresSubAspect.map((subAspects: any) => {
return (
<div key={subAspects.subAspectId} className="flex justify-between items-center">
<Text className="text-lg text-gray-400">
{subAspects.subAspectName}
</Text>
<Text className="text-lg font-bold">
{parseFloat(subAspects.averageScore).toFixed(1)}
</Text>
</div>
);
})}
</Stack>
)}
</Stack>
{/* Tombol Selesai */}
<div className="mt-6">
<button className="bg-gray-200 text-black font-bold py-2 w-full">
Selesai
</button>
</div>
</Card>
</div>
</Flex>
</Flex>
</Stack>
</Card>
);
}

View File

@ -0,0 +1,18 @@
import { getQuestionsAllQueryOptions } from "@/modules/assessmentManagement/queries/assessmentQueries.ts";
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchParamSchema = z.object({
create: z.boolean().default(false).optional(),
edit: z.string().default("").optional(),
delete: z.string().default("").optional(),
detail: z.string().default("").optional(),
});
export const Route = createFileRoute("/_dashboardLayout/assessment/")({
validateSearch: searchParamSchema,
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(getQuestionsAllQueryOptions(0, 10));
},
});