Merge pull request #29 from digitalsolutiongroup/feat/assessment-frontend
Slicing and Integration API for Assessment
This commit is contained in:
commit
33309e7eba
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user