Pull Request branch dev-clone to main #1
|
|
@ -2,7 +2,7 @@ import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
|||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import db from "../../drizzle";
|
||||
import { assessments } from "../../drizzle/schema/assessments";
|
||||
import { assessments, statusEnum } from "../../drizzle/schema/assessments";
|
||||
import { respondents } from "../../drizzle/schema/respondents";
|
||||
import { users } from "../../drizzle/schema/users";
|
||||
import { aspects } from "../../drizzle/schema/aspects";
|
||||
|
|
@ -17,6 +17,22 @@ import checkPermission from "../../middlewares/checkPermission";
|
|||
import requestValidator from "../../utils/requestValidator";
|
||||
import { notFound } from "../../errors/DashboardError";
|
||||
|
||||
// optionFormSchema: untuk /submitOption
|
||||
export const optionFormSchema = z.object({
|
||||
optionId: z.string().min(1),
|
||||
assessmentId: z.string().min(1),
|
||||
questionId: z.string().min(1),
|
||||
isFlagged: z.boolean().optional().default(false),
|
||||
filename: z.string().optional(),
|
||||
});
|
||||
|
||||
// validationFormSchema: untuk /submitValidation
|
||||
export const validationFormSchema = z.object({
|
||||
assessmentId: z.string().min(1),
|
||||
questionId: z.string().min(1),
|
||||
newValidationInformation: z.string().min(1, "Validation information is required"),
|
||||
});
|
||||
|
||||
const assessmentRoute = new Hono<HonoEnv>()
|
||||
.use(authInfo)
|
||||
|
||||
|
|
@ -47,12 +63,16 @@ const assessmentRoute = new Hono<HonoEnv>()
|
|||
.leftJoin(users, eq(respondents.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
q
|
||||
? or(
|
||||
or(
|
||||
q ? or(
|
||||
ilike(users.name, q),
|
||||
ilike(respondents.companyName, q),
|
||||
ilike(respondents.companyName, q)
|
||||
) : undefined,
|
||||
),
|
||||
or(
|
||||
eq(assessments.status, 'belum diverifikasi'),
|
||||
eq(assessments.status, 'selesai')
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -83,14 +103,25 @@ const assessmentRoute = new Hono<HonoEnv>()
|
|||
.leftJoin(users, eq(respondents.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
q
|
||||
? or(
|
||||
or(
|
||||
q ? or(
|
||||
ilike(users.name, q),
|
||||
ilike(respondents.companyName, q),
|
||||
) : undefined,
|
||||
),
|
||||
or(
|
||||
eq(assessments.status, 'belum diverifikasi'),
|
||||
eq(assessments.status, 'selesai')
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
sql`CASE
|
||||
WHEN ${assessments.status} = 'belum diverifikasi' THEN 1
|
||||
WHEN ${assessments.status} = 'selesai' THEN 2
|
||||
ELSE 3
|
||||
END`
|
||||
)
|
||||
.offset(page * limit)
|
||||
.limit(limit);
|
||||
const totalCountResult = await totalItems;
|
||||
|
|
@ -157,6 +188,54 @@ const assessmentRoute = new Hono<HonoEnv>()
|
|||
}
|
||||
)
|
||||
|
||||
.get(
|
||||
"/verified/:id",
|
||||
checkPermission("assessmentResult.read"),
|
||||
async (c) => {
|
||||
const assessmentId = c.req.param("id");
|
||||
|
||||
const result = await db
|
||||
.select({
|
||||
respondentName: users.name,
|
||||
position: respondents.position,
|
||||
workExperience: respondents.workExperience,
|
||||
email: users.email,
|
||||
companyName: respondents.companyName,
|
||||
address: respondents.address,
|
||||
phoneNumber: respondents.phoneNumber,
|
||||
username: users.username,
|
||||
assessmentDate: assessments.createdAt,
|
||||
statusAssessment: assessments.status,
|
||||
statusVerification: sql<string>`
|
||||
CASE
|
||||
WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi'
|
||||
ELSE 'belum diverifikasi'
|
||||
END`.as("statusVerification"),
|
||||
verifiedAssessmentsResult: sql<number>`
|
||||
(SELECT ROUND(AVG(${options.score}), 2)
|
||||
FROM ${answerRevisions}
|
||||
JOIN ${answers} ON ${answers.id} = ${answerRevisions.answerId}
|
||||
JOIN ${options} ON ${options.id} = ${answerRevisions.newOptionId}
|
||||
JOIN ${questions} ON ${questions.id} = ${options.questionId}
|
||||
JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId}
|
||||
JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId}
|
||||
WHERE ${answers.assessmentId} = ${assessments.id})`.as("verifiedAssessmentsResult"),
|
||||
})
|
||||
.from(assessments)
|
||||
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||
.leftJoin(users, eq(respondents.userId, users.id))
|
||||
.where(eq(assessments.id, assessmentId));
|
||||
|
||||
if (!result.length) {
|
||||
throw notFound({
|
||||
message: "Assessment not found",
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(result[0]);
|
||||
}
|
||||
)
|
||||
|
||||
// Get all Questions and Options that relate to Sub Aspects and Aspects based on Assessment ID
|
||||
.get(
|
||||
"getAllQuestion/:id",
|
||||
|
|
@ -219,49 +298,239 @@ const assessmentRoute = new Hono<HonoEnv>()
|
|||
}
|
||||
)
|
||||
|
||||
// POST Endpoint for creating a new answer revision
|
||||
.get(
|
||||
'/average-score/sub-aspects/assessments/:assessmentId',
|
||||
checkPermission("assessments.readAssessmentScore"),
|
||||
async (c) => {
|
||||
const { assessmentId } = c.req.param();
|
||||
|
||||
const averageScores = await db
|
||||
.select({
|
||||
aspectId: subAspects.aspectId,
|
||||
subAspectId: subAspects.id,
|
||||
subAspectName: subAspects.name,
|
||||
average: sql`AVG(options.score)`
|
||||
})
|
||||
.from(answerRevisions)
|
||||
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
|
||||
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
|
||||
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||
.where(eq(assessments.id, assessmentId))
|
||||
.groupBy(subAspects.id);
|
||||
|
||||
return c.json({
|
||||
assessmentId,
|
||||
subAspects: averageScores.map(score => ({
|
||||
subAspectId: score.subAspectId,
|
||||
subAspectName: score.subAspectName,
|
||||
averageScore: score.average,
|
||||
aspectId: score.aspectId
|
||||
}))
|
||||
});
|
||||
}
|
||||
)
|
||||
|
||||
.get(
|
||||
'/average-score/aspects/assessments/:assessmentId',
|
||||
checkPermission("assessments.readAssessmentScore"),
|
||||
async (c) => {
|
||||
const { assessmentId } = c.req.param();
|
||||
|
||||
// Query untuk mendapatkan average score per aspect
|
||||
const aspectScores = await db
|
||||
.select({
|
||||
aspectId: aspects.id,
|
||||
aspectName: aspects.name,
|
||||
averageScore: sql`AVG(options.score)`,
|
||||
})
|
||||
.from(answerRevisions)
|
||||
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
|
||||
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
|
||||
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||
.innerJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||
.where(eq(assessments.id, assessmentId))
|
||||
.groupBy(aspects.id);
|
||||
|
||||
// Query untuk mendapatkan average score per sub-aspect
|
||||
const subAspectScores = await db
|
||||
.select({
|
||||
aspectId: subAspects.aspectId,
|
||||
subAspectId: subAspects.id,
|
||||
subAspectName: subAspects.name,
|
||||
averageScore: sql`AVG(options.score)`,
|
||||
})
|
||||
.from(answerRevisions)
|
||||
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
|
||||
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
|
||||
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||
.where(eq(assessments.id, assessmentId))
|
||||
.groupBy(subAspects.id);
|
||||
|
||||
// Menggabungkan sub-aspects ke dalam masing-masing aspect
|
||||
const aspectsWithSubAspects = aspectScores.map((aspect) => ({
|
||||
aspectId: aspect.aspectId,
|
||||
aspectName: aspect.aspectName,
|
||||
averageScore: aspect.averageScore,
|
||||
subAspects: subAspectScores
|
||||
.filter((sub) => sub.aspectId === aspect.aspectId)
|
||||
.map((sub) => ({
|
||||
subAspectId: sub.subAspectId,
|
||||
subAspectName: sub.subAspectName,
|
||||
averageScore: sub.averageScore,
|
||||
})),
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
assessmentId,
|
||||
aspects: aspectsWithSubAspects,
|
||||
});
|
||||
}
|
||||
)
|
||||
|
||||
// Get all Answers Data by Assessment Id
|
||||
.get(
|
||||
"/getAnswers/:id",
|
||||
checkPermission("assessments.readAnswers"),
|
||||
requestValidator(
|
||||
"query",
|
||||
z.object({
|
||||
// assessmentId: z.string().min(1),
|
||||
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(1000),
|
||||
q: z.string().default(""),
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const assessmentId = c.req.param("id").toString();
|
||||
const { page, limit, q, withMetadata } = c.req.valid("query");
|
||||
|
||||
// Query to count total answers for the specific assessmentId
|
||||
const totalCountQuery = sql<number>`(SELECT count(*)
|
||||
FROM ${answerRevisions}
|
||||
JOIN ${answers} ON ${answers.id} = ${answerRevisions.answerId}
|
||||
JOIN ${assessments} ON ${answers.assessmentId} = ${assessments.id}
|
||||
WHERE ${assessments.id} = ${assessmentId})`;
|
||||
|
||||
// Query to retrieve answers for the specific assessmentId
|
||||
const result = await db
|
||||
.select({
|
||||
id: answerRevisions.id,
|
||||
answerId: answerRevisions.answerId,
|
||||
newOptionId: answerRevisions.newOptionId,
|
||||
newValidationInformation: answerRevisions.newValidationInformation,
|
||||
fullCount: totalCountQuery,
|
||||
})
|
||||
.from(answerRevisions)
|
||||
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
|
||||
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||
.where(
|
||||
and(
|
||||
eq(assessments.id, assessmentId),
|
||||
q
|
||||
? or(
|
||||
ilike(answers.filename, q),
|
||||
ilike(answerRevisions.newValidationInformation, q),
|
||||
eq(answerRevisions.answerId, q)
|
||||
)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.offset(page * limit)
|
||||
.limit(limit);
|
||||
|
||||
return c.json({
|
||||
data: result.map((d) => ({ ...d, fullCount: undefined })),
|
||||
_metadata: {
|
||||
currentPage: page,
|
||||
totalPages: withMetadata
|
||||
? Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit)
|
||||
: null,
|
||||
totalItems: withMetadata ? Number(result[0]?.fullCount) ?? 0 : null,
|
||||
perPage: limit,
|
||||
},
|
||||
});
|
||||
}
|
||||
)
|
||||
|
||||
// POST Endpoint for creating multiple answer revisions based on assessmentId
|
||||
.post(
|
||||
"/answer-revisions",
|
||||
checkPermission("assessmentResult.create"),
|
||||
requestValidator(
|
||||
"json",
|
||||
z.object({
|
||||
answerId: z.string(),
|
||||
newOptionId: z.string(),
|
||||
revisedBy: z.string(),
|
||||
newValidationInformation: z.string(),
|
||||
assessmentId: z.string(),
|
||||
revisedBy: z.string(), // assuming this will come from the session or auth context
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { answerId, newOptionId, revisedBy, newValidationInformation } = c.req.valid("json");
|
||||
const { assessmentId, revisedBy } = c.req.valid("json");
|
||||
|
||||
// Check if the answer exists
|
||||
const existingAnswer = await db
|
||||
// Fetch answers related to the given assessmentId
|
||||
const existingAnswers = await db
|
||||
.select()
|
||||
.from(answers)
|
||||
.where(eq(answers.id, answerId));
|
||||
.where(eq(answers.assessmentId, assessmentId));
|
||||
|
||||
if (!existingAnswer.length) {
|
||||
if (!existingAnswers.length) {
|
||||
throw notFound({
|
||||
message: "Answer not found",
|
||||
message: "No answers found for the given assessment ID",
|
||||
});
|
||||
}
|
||||
|
||||
// Insert new revision
|
||||
const [newRevision] = await db
|
||||
// Fetch already existing revisions for the given answer IDs
|
||||
const existingRevisions = await db
|
||||
.select({ answerId: answerRevisions.answerId })
|
||||
.from(answerRevisions)
|
||||
.where(
|
||||
or(
|
||||
...existingAnswers.map((answer) =>
|
||||
eq(answerRevisions.answerId, answer.id)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Create a Set of existing revision IDs for quick lookup
|
||||
const existingRevisionIds = new Set(
|
||||
existingRevisions.map(revision => revision.answerId)
|
||||
);
|
||||
|
||||
// Prepare revisions to be inserted, excluding those that already exist
|
||||
const revisions = existingAnswers
|
||||
.filter(answer => !existingRevisionIds.has(answer.id)) // Filter out existing revisions
|
||||
.map(answer => ({
|
||||
answerId: answer.id,
|
||||
newOptionId: answer.optionId, // Assuming you want to keep the existing optionId
|
||||
newValidationInformation: answer.validationInformation, // Keep the existing validation information
|
||||
revisedBy: revisedBy
|
||||
}));
|
||||
|
||||
if (revisions.length === 0) {
|
||||
return c.json({
|
||||
message: "No new revisions to create, as all answers are already revised.",
|
||||
});
|
||||
}
|
||||
|
||||
// Insert all new revisions in a single operation
|
||||
const newRevisions = await db
|
||||
.insert(answerRevisions)
|
||||
.values({
|
||||
answerId,
|
||||
newOptionId,
|
||||
revisedBy,
|
||||
newValidationInformation
|
||||
})
|
||||
.values(revisions)
|
||||
.returning();
|
||||
|
||||
return c.json(
|
||||
{
|
||||
message: "Answer revision created successfully",
|
||||
data: newRevision
|
||||
message: "Answer revisions created successfully",
|
||||
data: newRevisions,
|
||||
},
|
||||
201
|
||||
);
|
||||
|
|
@ -331,6 +600,53 @@ const assessmentRoute = new Hono<HonoEnv>()
|
|||
message: "Status assessment berhasil diperbarui.",
|
||||
});
|
||||
}
|
||||
)
|
||||
|
||||
.post(
|
||||
"/updateValidation",
|
||||
checkPermission("assessments.submitValidation"),
|
||||
requestValidator("json", validationFormSchema),
|
||||
async (c) => {
|
||||
const validationData = c.req.valid("json");
|
||||
|
||||
// Cek apakah jawaban ada berdasarkan assessmentId dan questionId
|
||||
const [targetAnswer] = await db
|
||||
.select({ id: answers.id })
|
||||
.from(answers)
|
||||
.leftJoin(options, eq(answers.optionId, options.id))
|
||||
.where(
|
||||
sql`answers."assessmentId" = ${validationData.assessmentId}
|
||||
AND options."questionId" = ${validationData.questionId}`
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!targetAnswer) {
|
||||
return c.json(
|
||||
{ message: "Answer not found for given assessmentId and questionId" },
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
// Dapatkan tanggal dan waktu saat ini
|
||||
const currentDate = new Date();
|
||||
|
||||
// Update dengan melakukan JOIN yang sama
|
||||
const [updatedRevision] = await db
|
||||
.update(answerRevisions)
|
||||
.set({
|
||||
newValidationInformation: validationData.newValidationInformation,
|
||||
})
|
||||
.where(sql`"answerId" = ${targetAnswer.id}`)
|
||||
.returning();
|
||||
|
||||
return c.json(
|
||||
{
|
||||
message: "Revision updated successfully",
|
||||
revision: updatedRevision, // Revisi yang baru saja diperbarui
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
export default assessmentRoute;
|
||||
|
|
|
|||
|
|
@ -979,4 +979,48 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
|||
}
|
||||
)
|
||||
|
||||
.patch(
|
||||
"/updateOption",
|
||||
checkPermission("assessments.submitOption"),
|
||||
requestValidator("json", newOptionFormSchema),
|
||||
async (c) => {
|
||||
const optionData = c.req.valid("json");
|
||||
|
||||
// Temukan answerId yang sesuai berdasarkan assessmentId dan questionId
|
||||
const [targetAnswer] = await db
|
||||
.select({ id: answers.id })
|
||||
.from(answers)
|
||||
.leftJoin(options, eq(answers.optionId, options.id))
|
||||
.where(
|
||||
sql`answers."assessmentId" = ${optionData.assessmentId}
|
||||
AND options."questionId" = ${optionData.questionId}`
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!targetAnswer) {
|
||||
return c.json(
|
||||
{ message: "Answer not found for given assessmentId and questionId" },
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
// Lakukan update pada answer_revisions menggunakan answerId yang ditemukan
|
||||
const [updatedRevision] = await db
|
||||
.update(answerRevisions)
|
||||
.set({
|
||||
newOptionId: optionData.newOptionId,
|
||||
})
|
||||
.where(sql`"answerId" = ${targetAnswer.id}`)
|
||||
.returning();
|
||||
|
||||
return c.json(
|
||||
{
|
||||
message: "Revision updated successfully",
|
||||
revision: updatedRevision, // Revisi yang baru saja diperbarui
|
||||
},
|
||||
200
|
||||
);
|
||||
}
|
||||
)
|
||||
|
||||
export default assessmentsRoute;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function AppNavbar() {
|
|||
// const userRole = JSON.parse(localStorage.getItem('userRole') || '{}');
|
||||
|
||||
const { pathname } = useLocation();
|
||||
const pathsThatCloseSidebar = ["/assessmentRequest", "/assessmentResult", "/assessment"];
|
||||
const pathsThatCloseSidebar = ["/assessmentRequest", "/assessmentResult", "/assessment", "/verifying"];
|
||||
|
||||
const [isSidebarOpen, setSidebarOpen] = useState(true);
|
||||
const toggleSidebar = () => {
|
||||
|
|
|
|||
|
|
@ -30,3 +30,30 @@ export const getAllAspectsAverageScore = (assessmentId: string | undefined) =>
|
|||
enabled: Boolean(assessmentId),
|
||||
});
|
||||
|
||||
export const getAllVerifiedSubAspectsAverageScore = (assessmentId: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["allVerifiedSubAspectsAverage", assessmentId],
|
||||
queryFn: () =>
|
||||
fetchRPC(
|
||||
client.assessmentResult["average-score"]["sub-aspects"]["assessments"][":assessmentId"].$get({
|
||||
param: {
|
||||
assessmentId: assessmentId!,
|
||||
},
|
||||
})
|
||||
),
|
||||
enabled: Boolean(assessmentId),
|
||||
});
|
||||
|
||||
export const getAllVerifiedAspectsAverageScore = (assessmentId: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["allVerifiedAspectsAverage", assessmentId],
|
||||
queryFn: () =>
|
||||
fetchRPC(
|
||||
client.assessmentResult["average-score"]["aspects"]["assessments"][":assessmentId"].$get({
|
||||
param: {
|
||||
assessmentId: assessmentId!,
|
||||
},
|
||||
})
|
||||
),
|
||||
enabled: Boolean(assessmentId),
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import client from "@/honoClient";
|
||||
import fetchRPC from "@/utils/fetchRPC";
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { queryOptions, useMutation } from "@tanstack/react-query";
|
||||
|
||||
export const assessmentResultsQueryOptions = (page: number, limit: number, q?: string) =>
|
||||
queryOptions({
|
||||
|
|
@ -30,12 +30,149 @@ export const getAssessmentResultByIdQueryOptions = (assessmentResultId: string |
|
|||
),
|
||||
enabled: Boolean(assessmentResultId),
|
||||
});
|
||||
export const verifyAssessmentResultQuery = (assessmentResultId: string) =>
|
||||
|
||||
export const getVerifiedAssessmentResultByIdQueryOptions = (assessmentResultId: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["verifiedAssessmentResult", assessmentResultId],
|
||||
queryFn: () =>
|
||||
fetchRPC(
|
||||
client.assessmentResult[":id"].$patch({
|
||||
client.assessmentResult.verified[":id"].$get({
|
||||
param: {
|
||||
id: assessmentResultId,
|
||||
id: assessmentResultId!,
|
||||
},
|
||||
})
|
||||
),
|
||||
enabled: Boolean(assessmentResultId),
|
||||
});
|
||||
|
||||
export const postAnswerRevisionQueryOptions = (
|
||||
assessmentId: string,
|
||||
revisedBy: string,
|
||||
) =>
|
||||
queryOptions({
|
||||
queryKey: ["answerRevisions", assessmentId],
|
||||
queryFn: () =>
|
||||
fetchRPC(
|
||||
client.assessmentResult["answer-revisions"].$post({
|
||||
json: {
|
||||
assessmentId,
|
||||
revisedBy,
|
||||
},
|
||||
})
|
||||
),
|
||||
enabled: Boolean(assessmentId && revisedBy),
|
||||
});
|
||||
|
||||
export const postAnswerRevisionMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ assessmentId, revisedBy }: { assessmentId: string; revisedBy: string }) => {
|
||||
return fetchRPC(
|
||||
client.assessmentResult["answer-revisions"].$post({
|
||||
json: {
|
||||
assessmentId,
|
||||
revisedBy,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
console.log("Revision posted successfully.");
|
||||
// Optionally, you could trigger a refetch of relevant data here
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Error posting revision:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Query untuk mendapatkan jawaban berdasarkan assessment ID
|
||||
export const getAnswersRevisionQueryOptions = (
|
||||
assessmentId: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
q: string = "",
|
||||
withMetadata: string = "true"
|
||||
) => {
|
||||
return queryOptions({
|
||||
queryKey: ["answerRevision", { assessmentId, page, limit, q }],
|
||||
queryFn: () =>
|
||||
fetchRPC(
|
||||
client.assessmentResult.getAnswers[":id"].$get({
|
||||
param: { id: assessmentId },
|
||||
query: {
|
||||
limit: String(limit),
|
||||
page: String(page),
|
||||
q,
|
||||
withMetadata,
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateValidationQueryOptions = (assessmentId: string, questionId: string, newValidationInformation: string) => {
|
||||
return queryOptions({
|
||||
queryKey: ["updateValidation", { assessmentId, questionId }],
|
||||
queryFn: () =>
|
||||
fetchRPC(
|
||||
client.assessmentResult.updateValidation.$post({
|
||||
json: {
|
||||
assessmentId,
|
||||
questionId,
|
||||
newValidationInformation,
|
||||
},
|
||||
})
|
||||
),
|
||||
enabled: Boolean(assessmentId && questionId && newValidationInformation),
|
||||
});
|
||||
};
|
||||
|
||||
export const updateValidationQuery = async (
|
||||
form: {
|
||||
assessmentId: string;
|
||||
questionId: string;
|
||||
newValidationInformation: string;
|
||||
}
|
||||
) => {
|
||||
return await fetchRPC(
|
||||
client.assessmentResult.updateValidation.$post({
|
||||
json: {
|
||||
...form,
|
||||
assessmentId: String(form.assessmentId),
|
||||
questionId: String(form.questionId),
|
||||
newValidationInformation: form.newValidationInformation,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const updateOptionQuery = async (
|
||||
form: {
|
||||
assessmentId: string;
|
||||
questionId: string;
|
||||
optionId: string;
|
||||
}
|
||||
) => {
|
||||
return await fetchRPC(
|
||||
client.assessments.updateOption.$patch({
|
||||
json: {
|
||||
...form,
|
||||
assessmentId: String(form.assessmentId),
|
||||
questionId: String(form.questionId),
|
||||
newOptionId: form.optionId,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const submitAssessmentRevision = async (assessmentId: string): Promise<{ message: string }> => {
|
||||
return await fetchRPC(
|
||||
client.assessmentResult.submitAssessmentRevision[":id"].$patch({
|
||||
param: { id: assessmentId },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const submitAssessmentRevisionMutationOptions = (assessmentId: string) => ({
|
||||
mutationFn: () => submitAssessmentRevision(assessmentId),
|
||||
});
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { SetStateAction, useEffect, useState } from "react";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { getAllAspectsAverageScore, getAllSubAspectsAverageScore } from "@/modules/assessmentResult/queries/assessmentResultQueries";
|
||||
import { getAllAspectsAverageScore, getAllSubAspectsAverageScore, getAllVerifiedAspectsAverageScore, getAllVerifiedSubAspectsAverageScore } from "@/modules/assessmentResult/queries/assessmentResultQueries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getAssessmentResultByIdQueryOptions } from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
|
||||
import { getAssessmentResultByIdQueryOptions, getVerifiedAssessmentResultByIdQueryOptions } from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
|
||||
import { PieChart, Pie, Label, BarChart, Bar, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import { PolarAngleAxis, PolarRadiusAxis, PolarGrid, Radar, RadarChart } from "recharts"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shadcn/components/ui/card"
|
||||
import {
|
||||
ChartConfig,
|
||||
|
|
@ -16,16 +19,22 @@ import {
|
|||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/shadcn/components/ui/chart"
|
||||
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
|
||||
import { TbChevronDown, TbChevronLeft, TbChevronUp } from "react-icons/tb";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shadcn/components/ui/dropdown-menu";
|
||||
|
||||
const getQueryParam = (param: string) => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get(param);
|
||||
};
|
||||
|
||||
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentResult/")({
|
||||
component: AssessmentResultPage,
|
||||
});
|
||||
|
||||
export default function AssessmentResultPage() {
|
||||
const getQueryParam = (param: string) => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get(param);
|
||||
};
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.role === "super-admin";
|
||||
|
||||
const [assessmentId, setAssessmentId] = useState<string | undefined>(undefined);
|
||||
|
||||
|
|
@ -34,104 +43,166 @@ export default function AssessmentResultPage() {
|
|||
setAssessmentId(id ?? undefined);
|
||||
}, []);
|
||||
|
||||
const { data: aspectsData } = useQuery(aspectQueryOptions(0, 10));
|
||||
const { data: assessmentResult } = useQuery(getAssessmentResultByIdQueryOptions(assessmentId));
|
||||
const { data: allAspectsData } = useQuery(getAllAspectsAverageScore(assessmentId));
|
||||
const { data: allSubAspectsData } = useQuery(getAllSubAspectsAverageScore(assessmentId));
|
||||
const { data: verifiedAssessmentResult } = useQuery(getVerifiedAssessmentResultByIdQueryOptions(assessmentId));
|
||||
const { data: allAspectsScoreData } = useQuery(getAllAspectsAverageScore(assessmentId));
|
||||
const { data: allSubAspectsScoreData } = useQuery(getAllSubAspectsAverageScore(assessmentId));
|
||||
const { data: allVerifiedAspectsScoreData } = useQuery(getAllVerifiedAspectsAverageScore(assessmentId));
|
||||
const { data: allVerifiedSubAspectsScoreData } = useQuery(getAllVerifiedSubAspectsAverageScore(assessmentId));
|
||||
|
||||
const getAspectScore = (aspectId: string) => {
|
||||
return allAspectsScoreData?.aspects?.find((score) => score.aspectId === aspectId)?.averageScore || undefined;
|
||||
};
|
||||
|
||||
const getSubAspectScore = (subAspectId: string) => {
|
||||
return allSubAspectsScoreData?.subAspects?.find((score) => score.subAspectId === subAspectId)?.averageScore || undefined;
|
||||
};
|
||||
|
||||
const getVerifiedAspectScore = (aspectId: string) => {
|
||||
return allVerifiedAspectsScoreData?.aspects?.find((score) => score.aspectId === aspectId)?.averageScore || undefined;
|
||||
};
|
||||
|
||||
const getVerifiedSubAspectScore = (subAspectId: string) => {
|
||||
return allVerifiedSubAspectsScoreData?.subAspects?.find((score) => score.subAspectId === subAspectId)?.averageScore || undefined;
|
||||
};
|
||||
|
||||
const formatScore = (score: string | number | undefined) => {
|
||||
if (score === null || score === undefined) return '0';
|
||||
const parsedScore = typeof score === 'number' ? score : parseFloat(score || "NaN");
|
||||
return !isNaN(parsedScore) ? parsedScore.toFixed(2) : 'N/A'; // Mengembalikan 'N/A' jika bukan angka
|
||||
return !isNaN(parsedScore) ? parsedScore.toFixed(2) : '0';
|
||||
};
|
||||
|
||||
const totalScore = formatScore(assessmentResult?.assessmentsResult);
|
||||
const totalScore = parseFloat(formatScore(assessmentResult?.assessmentsResult));
|
||||
const totalVerifiedScore = parseFloat(formatScore(verifiedAssessmentResult?.verifiedAssessmentsResult));
|
||||
|
||||
const blueColors = [
|
||||
"hsl(220, 100%, 50%)",
|
||||
"hsl(220, 80%, 60%)",
|
||||
"hsl(220, 60%, 70%)",
|
||||
"hsl(220, 40%, 80%)",
|
||||
"hsl(220, 20%, 90%)",
|
||||
const getScoreStyleClass = (score: number | undefined, isBg: boolean = false) => {
|
||||
if (score === undefined || score === null) return { color: 'grey' };
|
||||
|
||||
let colorVar = '--levelOne-color';
|
||||
let textColor = 'white';
|
||||
|
||||
if (score >= 1.50 && score < 2.50) {
|
||||
colorVar = '--levelTwo-color';
|
||||
} else if (score >= 2.50 && score < 3.50) {
|
||||
colorVar = '--levelThree-color';
|
||||
textColor = 'black';
|
||||
} else if (score >= 3.50 && score < 4.49) {
|
||||
colorVar = '--levelFour-color';
|
||||
} else if (score >= 4.50 && score <= 5) {
|
||||
colorVar = '--levelFive-color';
|
||||
}
|
||||
|
||||
return isBg
|
||||
? { backgroundColor: `var(${colorVar})`, color: textColor }
|
||||
: { color: `var(${colorVar})` };
|
||||
};
|
||||
|
||||
const aspectsColors = [
|
||||
"#DBED9B",
|
||||
"#FF3F9F",
|
||||
"#877BDF",
|
||||
"#CFAF49",
|
||||
"#5FD4E7",
|
||||
];
|
||||
|
||||
const chartData = allAspectsData?.aspects.map((aspect, index) => ({
|
||||
aspectName: aspect.aspectName,
|
||||
score: Number(formatScore(aspect.averageScore)),
|
||||
fill: blueColors[index % blueColors.length],
|
||||
const chartData = aspectsData?.data?.map((aspect, index) => ({
|
||||
aspectName: aspect.name,
|
||||
score: Number(formatScore(getAspectScore(aspect.id))),
|
||||
fill: aspectsColors[index % aspectsColors.length],
|
||||
})) || [];
|
||||
|
||||
const barChartData = allSubAspectsData?.subAspects.map((subAspect) => ({
|
||||
subAspectName: subAspect.subAspectName,
|
||||
score: Number(formatScore(subAspect.averageScore)),
|
||||
fill: "#005BFF",
|
||||
aspectId: subAspect.aspectId,
|
||||
aspectName: allAspectsData?.aspects.find(aspect => aspect.aspectId === subAspect.aspectId)?.aspectName
|
||||
const verifiedChartData = aspectsData?.data?.map((aspect, index) => ({
|
||||
aspectName: aspect.name,
|
||||
score: Number(formatScore(getVerifiedAspectScore(aspect.id))),
|
||||
fill: aspectsColors[index % aspectsColors.length],
|
||||
})) || [];
|
||||
|
||||
const barChartData = aspectsData?.data?.flatMap((aspect) =>
|
||||
aspect.subAspects.map((subAspect) => ({
|
||||
subAspectName: subAspect.name,
|
||||
score: Number(formatScore(getSubAspectScore(subAspect.id))),
|
||||
fill: "#005BFF",
|
||||
aspectId: aspect.id,
|
||||
aspectName: aspect.name
|
||||
}))
|
||||
) || [];
|
||||
|
||||
const verifiedBarChartData = aspectsData?.data?.flatMap((aspect) =>
|
||||
aspect.subAspects.map((subAspect) => ({
|
||||
subAspectName: subAspect.name,
|
||||
score: Number(formatScore(getVerifiedSubAspectScore(subAspect.id))),
|
||||
fill: "#005BFF",
|
||||
aspectId: aspect.id,
|
||||
aspectName: aspect.name
|
||||
}))
|
||||
) || [];
|
||||
|
||||
const sortedBarChartData = barChartData.sort((a, b) => (a.aspectId ?? '').localeCompare(b.aspectId ?? ''));
|
||||
const sortedVerifiedBarChartData = verifiedBarChartData.sort((a, b) => (a.aspectId ?? '').localeCompare(b.aspectId ?? ''));
|
||||
|
||||
const chartConfig = allAspectsData?.aspects.reduce((config, aspect, index) => {
|
||||
config[aspect.aspectName.toLowerCase()] = {
|
||||
label: aspect.aspectName,
|
||||
color: blueColors[index % blueColors.length],
|
||||
const chartConfig = aspectsData?.data?.reduce((config, aspect, index) => {
|
||||
config[aspect.name.toLowerCase()] = {
|
||||
label: aspect.name,
|
||||
color: aspectsColors[index % aspectsColors.length],
|
||||
};
|
||||
return config;
|
||||
}, {} as ChartConfig) || {};
|
||||
|
||||
const barChartConfig = allSubAspectsData?.subAspects.reduce((config, subAspect, index) => {
|
||||
config[subAspect.subAspectName.toLowerCase()] = {
|
||||
label: subAspect.subAspectName,
|
||||
color: blueColors[index % blueColors.length],
|
||||
const barChartConfig = aspectsData?.data?.reduce((config, aspect, index) => {
|
||||
aspect.subAspects.forEach((subAspect) => {
|
||||
config[subAspect.name.toLowerCase()] = {
|
||||
label: subAspect.name,
|
||||
color: aspectsColors[index % aspectsColors.length],
|
||||
};
|
||||
});
|
||||
return config;
|
||||
}, {} as ChartConfig) || {};
|
||||
|
||||
const customizedAxisTick = (props: any) => {
|
||||
const { x, y, payload } = props;
|
||||
return (
|
||||
<Card className="w-full h-screen border-none">
|
||||
<div className="flex flex-col w-full h-fit mb-6 justify-center items-center">
|
||||
<p className="text-2xl font-bold">Cyber Security Maturity Level Dashboard</p>
|
||||
<p className="text-xs text-muted-foreground">Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah</p>
|
||||
</div>
|
||||
|
||||
{/* Score table */}
|
||||
<Card className="flex flex-col w-full h-fit my-2 mb-8 border overflow-hidden">
|
||||
<div className="flex flex-row">
|
||||
{allAspectsData?.aspects.map((aspect) => (
|
||||
<div key={aspect.aspectId} className="flex-col bg-white w-full h-full">
|
||||
<div className="flex flex-col font-bold items-center justify-center border p-2 h-full w-full">
|
||||
<p className="text-sm">{aspect.aspectName}</p>
|
||||
<span className="text-2xl">{formatScore(aspect.averageScore)}</span>
|
||||
</div>
|
||||
{allSubAspectsData?.subAspects.map((subAspect) => {
|
||||
if (subAspect.aspectId === aspect.aspectId) {
|
||||
return (
|
||||
<div key={subAspect.subAspectId} className="flex flex-col gap-2 border-x p-2 h-full w-full">
|
||||
<div className="flex flex-row gap-2 justify-between h-full w-full">
|
||||
<p className="text-xs text-muted-foreground">{subAspect.subAspectName}</p>
|
||||
<span className="text-xs font-bold">{formatScore(subAspect.averageScore)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<g transform={`translate(${x},${y})`}>
|
||||
<text
|
||||
x={0}
|
||||
y={0}
|
||||
dy={3}
|
||||
textAnchor="end"
|
||||
fill="#666"
|
||||
transform="rotate(-90)"
|
||||
fontSize={10}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState('Hasil Sementara');
|
||||
|
||||
const handleDropdownToggle = () => {
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleItemClick = () => {
|
||||
// Mengubah antara "Hasil Sementara" dan "Hasil Terverifikasi"
|
||||
if (selectedItem === 'Hasil Sementara') {
|
||||
setSelectedItem('Hasil Terverifikasi'); // Mengubah teks dropdown
|
||||
} else {
|
||||
setSelectedItem('Hasil Sementara'); // Mengubah kembali ke teks awal
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
setIsOpen(false); // Menutup dropdown setelah item dipilih
|
||||
};
|
||||
|
||||
{/* nilai keseluruhan */}
|
||||
<div className="flex flex-row w-full h-10 gap-2 bg-blue-600 text-white items-center justify-center font-bold">
|
||||
<p>Level Muturitas:</p>
|
||||
<span>{totalScore}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="flex flex-row gap-8 border-none shadow-none">
|
||||
{/* Pie Chart */}
|
||||
<Card className="flex flex-row w-full">
|
||||
<CardContent className="flex-1 pb-0">
|
||||
// Pie Chart Component
|
||||
function PieChartComponent({ chartData, totalScore, chartConfig }: { chartData: { aspectName: string, score: number, fill: string }[], totalScore: number, chartConfig: ChartConfig }) {
|
||||
return (
|
||||
<div className="flex flex-row w-full border-none">
|
||||
<div className="flex-1 pb-0 w-72">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto aspect-square max-h-[250px]"
|
||||
className="mx-auto aspect-square max-h-64"
|
||||
>
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
|
|
@ -142,7 +213,7 @@ export default function AssessmentResultPage() {
|
|||
data={chartData}
|
||||
dataKey="score"
|
||||
nameKey="aspectName"
|
||||
innerRadius={60}
|
||||
innerRadius={50}
|
||||
strokeWidth={5}
|
||||
label={({ cx, cy, midAngle, innerRadius, outerRadius, index }) => {
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
|
|
@ -152,7 +223,7 @@ export default function AssessmentResultPage() {
|
|||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
fill="black"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={14}
|
||||
|
|
@ -180,22 +251,18 @@ export default function AssessmentResultPage() {
|
|||
>
|
||||
{totalScore.toLocaleString()}
|
||||
</tspan>
|
||||
<tspan
|
||||
x={viewBox.cx}
|
||||
y={(viewBox.cy || 0) + 24}
|
||||
className="fill-muted-foreground"
|
||||
>
|
||||
</tspan>
|
||||
</text>
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col gap-2 text-sm justify-center items-start">
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex-col gap-2 text-sm justify-center items-start">
|
||||
{chartData.map((entry, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<span
|
||||
|
|
@ -204,13 +271,15 @@ export default function AssessmentResultPage() {
|
|||
/>
|
||||
<span className="font-medium">{entry.aspectName}</span>
|
||||
</div>
|
||||
)) || []}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Radar Chart */}
|
||||
<Card className="flex flex-col w-full">
|
||||
<CardContent className="flex-1 pb-0">
|
||||
function RadarChartComponent({ chartData, chartConfig }: { chartData: { aspectName: string, score: number }[], chartConfig: ChartConfig }) {
|
||||
return (
|
||||
<div className="flex-1 pb-0">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mx-auto max-h-[250px]"
|
||||
|
|
@ -231,7 +300,7 @@ export default function AssessmentResultPage() {
|
|||
}}
|
||||
/>
|
||||
<PolarAngleAxis dataKey="aspectName" tick={{ fontSize: 10 }} stroke="black" />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 8]} tick={{ fontSize: 10 }} stroke="black" />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 5]} tick={{ fontSize: 10 }} tickCount={6} stroke="black" />
|
||||
<PolarGrid radialLines={true} />
|
||||
<Radar
|
||||
dataKey="score"
|
||||
|
|
@ -241,33 +310,32 @@ export default function AssessmentResultPage() {
|
|||
/>
|
||||
</RadarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<Card className="flex w-full h-fit border mt-8">
|
||||
{/* Bar Chart */}
|
||||
<Card className="w-full">
|
||||
<CardContent>
|
||||
function BarChartComponent({ barChartData, barChartConfig }: { barChartData: { subAspectName: string, score: number, fill: string, aspectId: string, aspectName: string }[], barChartConfig: ChartConfig }) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ChartContainer config={barChartConfig}>
|
||||
<BarChart accessibilityLayer data={sortedBarChartData}>
|
||||
<BarChart accessibilityLayer data={barChartData} margin={{ bottom: 120 }}>
|
||||
<CartesianGrid vertical={false} horizontal={true} />
|
||||
<XAxis
|
||||
dataKey="subAspectName"
|
||||
tickLine={false}
|
||||
tickMargin={0}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.slice(0,3)}
|
||||
tick={{ textAnchor: 'start' }}
|
||||
interval={0}
|
||||
tick={customizedAxisTick}
|
||||
/>
|
||||
<YAxis />
|
||||
<YAxis domain={[0, 5]} tickCount={6} />
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={({ active, payload }) => {
|
||||
if (active && payload && payload.length > 0) {
|
||||
const { subAspectName, score } = payload[0].payload; // Ambil data dari payload
|
||||
const { subAspectName, score } = payload[0].payload;
|
||||
return (
|
||||
<div className="tooltip bg-white p-1 rounded-md">
|
||||
<div className="tooltip bg-white p-1 rounded-md shadow-lg">
|
||||
<p>{`${subAspectName} : ${score}`}</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -275,11 +343,314 @@ export default function AssessmentResultPage() {
|
|||
return null;
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="score" radius={4}/>
|
||||
<Bar dataKey="score" radius={2} fill="#007BFF" />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Card className="flex flex-row w-full h-full border-none shadow-none">
|
||||
<div className="flex flex-col w-fit min-h-fit border-none shadow-none -ml-1 -pr-2">
|
||||
<p className="font-bold mt-2">Tingkatan Level Maturitas</p>
|
||||
<div className="flex flex-col mr-5 -ml-5 h-full">
|
||||
{[
|
||||
{ level: 5, colorVar: '--levelFive-color', title: 'Implementasi Optimal', details: ['Otomatisasi', 'Terintegrasi', 'Membudaya'], textColor: 'white' },
|
||||
{ level: 4, colorVar: '--levelFour-color', title: 'Implementasi Terkelola', details: ['Terorganisir', 'Review Berkala', 'Berkelanjutan'], textColor: 'white' },
|
||||
{ level: 3, colorVar: '--levelThree-color', title: 'Implementasi Terdefinisi', details: ['Terorganisir', 'Konsisten', 'Review Berkala'], textColor: 'black' },
|
||||
{ level: 2, colorVar: '--levelTwo-color', title: 'Implementasi Berulang', details: ['Terorganisir', 'Tidak Konsisten', 'Berulang'], textColor: 'white' },
|
||||
{ level: 1, colorVar: '--levelOne-color', title: 'Implementasi Awal', details: ['Tidak Terukur', 'Tidak Konsisten', 'Risiko Tinggi'], textColor: 'white' }
|
||||
].map(({ level, colorVar, title, details, textColor }, index) => (
|
||||
<div key={level} className={`flex flex-row h-full border-none ${index > 0 ? '-mt-10' : ''}`}>
|
||||
<svg className="w-30 h-32 pb-5" style={{ color: `var(${colorVar})` }} fill="currentColor" viewBox="0 0 24 24">
|
||||
<polygon points="12,4 19,10 19,24 12,19 5,24 5,10" />
|
||||
<text x="12" y="16" textAnchor="middle" fill={textColor} fontSize="3" fontWeight="bold">
|
||||
Level {level}
|
||||
</text>
|
||||
</svg>
|
||||
<div className="flex flex-col items-start justify-center -ml-4">
|
||||
<p className="text-xs font-bold whitespace-nowrap">{title}</p>
|
||||
{details.map((detail) => (
|
||||
<p key={detail} className="text-xs">{detail}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="flex flex-col w-full h-fit border-none shadow-none -mt-6 -mr-5 p-4 bg-stone-50 overflow-hidden">
|
||||
<div className="flex flex-col w-full h-fit mb-6 justify-center items-start">
|
||||
{/* Konten Header */}
|
||||
<div className="flex justify-between items-center w-full">
|
||||
{isSuperAdmin ? (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
className="flex items-center text-xs text-blue-600 gap-2 mb-2"
|
||||
onClick={() => window.close()}
|
||||
>
|
||||
<TbChevronLeft size={20} className="mr-1" />
|
||||
Kembali
|
||||
</button>
|
||||
<p className="text-2xl font-bold">Detail Hasil Asesmen</p>
|
||||
<p className="text-xs text-muted-foreground">Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<p className="text-2xl font-bold">Cyber Security Maturity Level Dashboard</p>
|
||||
<p className="text-xs text-muted-foreground">Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown */}
|
||||
<div className="flex">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="bg-black text-white flex w-44 p-2 pl-4 rounded-sm text-sm items-start justify-between"
|
||||
onClick={handleDropdownToggle}
|
||||
>
|
||||
{selectedItem}
|
||||
{isOpen ? (
|
||||
<TbChevronUp size={20} className="justify-center items-center" />
|
||||
) : (
|
||||
<TbChevronDown size={20} className="justify-center items-center" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
{isOpen && (
|
||||
<DropdownMenuContent className="bg-white text-black flex w-44 rounded-sm text-sm items-start">
|
||||
<DropdownMenuItem className="w-full" onClick={handleItemClick}>
|
||||
{selectedItem === 'Hasil Sementara' ? 'Hasil Terverifikasi' : 'Hasil Sementara'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSuperAdmin &&
|
||||
<Card className="flex flex-col w-full h-full mb-6 justify-center items-start">
|
||||
<div className="flex flex-row border-b w-full p-4 gap-4 items-center">
|
||||
<div className="flex w-16 h-16 rounded-full bg-slate-300">
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-lg font-bold">{assessmentResult?.respondentName}</p>
|
||||
<p className="text-sm">{assessmentResult?.position}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex lg:flex-row flex-col text-xs h-full w-full justify-between p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Username</p>
|
||||
<p>{assessmentResult?.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Email</p>
|
||||
<p>{assessmentResult?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Nama Perusahaan</p>
|
||||
<p>{assessmentResult?.companyName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Pengalaman Kerja</p>
|
||||
<p>{assessmentResult?.workExperience}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground">No. HP</p>
|
||||
<p>{assessmentResult?.phoneNumber}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Alamat</p>
|
||||
<p>{assessmentResult?.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Tanggal Assessment</p>
|
||||
<p>
|
||||
{assessmentResult?.assessmentDate ? (
|
||||
new Intl.DateTimeFormat("id-ID", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
.format(new Date(assessmentResult.assessmentDate))
|
||||
.replace(/\./g, ':')
|
||||
.replace('pukul ', '')
|
||||
) : (
|
||||
'N/A'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Status Verifikasi</p>
|
||||
<p>{assessmentResult?.statusAssessment}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
}
|
||||
|
||||
{/* Conditional rendering based on selectedItem */}
|
||||
{selectedItem === 'Hasil Sementara' ? (
|
||||
<>
|
||||
{/* Score Table */}
|
||||
<p className="text-lg font-bold">Tabel Level Maturitas</p>
|
||||
<Card className="flex flex-col w-full h-fit my-2 mb-8 overflow-hidden border-y">
|
||||
<div className="flex flex-row">
|
||||
{aspectsData?.data?.map((aspect) => (
|
||||
<div key={aspect.id} className="flex-col bg-white w-full h-full border-x border-t">
|
||||
<div className="flex flex-col font-bold items-center justify-center p-2 h-full w-full border-b" style={getScoreStyleClass(getAspectScore(aspect.id))}>
|
||||
<p className="text-sm text-black">{aspect.name}</p>
|
||||
<span className="text-2xl">{formatScore(getAspectScore(aspect.id))}</span>
|
||||
</div>
|
||||
{aspect.subAspects.map((subAspect) => (
|
||||
<div key={subAspect.id} className="flex flex-col gap-2 p-2 h-full w-full">
|
||||
<div className="flex flex-row gap-2 justify-between h-full w-full">
|
||||
<p className="text-xs text-muted-foreground">{subAspect.name}</p>
|
||||
<span className="text-xs font-bold">{formatScore(getSubAspectScore(subAspect.id))}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Total score */}
|
||||
<div className="flex flex-row w-full h-14 gap-2 items-center justify-center font-bold text-2xl" style={getScoreStyleClass(Number(totalScore), true)}>
|
||||
<p>Level Maturitas:</p>
|
||||
<span>{totalScore}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Verified Result Table */}
|
||||
<p className="text-lg font-bold">Tabel Level Maturitas Terverifikasi</p>
|
||||
<Card className="flex flex-col w-full h-fit my-2 mb-8 overflow-hidden border-y">
|
||||
<div className="flex flex-row">
|
||||
{aspectsData?.data?.map((aspect) => (
|
||||
<div key={aspect.id} className="flex-col bg-white w-full h-full border-x border-t">
|
||||
<div className="flex flex-col font-bold items-center justify-center p-2 h-full w-full border-b" style={getScoreStyleClass(getAspectScore(aspect.id))}>
|
||||
<p className="text-sm text-black">{aspect.name}</p>
|
||||
<span className="text-2xl">{formatScore(getVerifiedAspectScore(aspect.id))}</span>
|
||||
</div>
|
||||
{aspect.subAspects.map((subAspect) => (
|
||||
<div key={subAspect.id} className="flex flex-col gap-2 p-2 h-full w-full">
|
||||
<div className="flex flex-row gap-2 justify-between h-full w-full">
|
||||
<p className="text-xs text-muted-foreground">{subAspect.name}</p>
|
||||
<span className="text-xs font-bold">{formatScore(getVerifiedSubAspectScore(subAspect.id))}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Total verified score */}
|
||||
<div className="flex flex-row w-full h-14 gap-2 items-center justify-center font-bold text-2xl" style={getScoreStyleClass(Number(totalScore), true)}>
|
||||
<p>Level Maturitas:</p>
|
||||
<span>{totalVerifiedScore}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card className="flex flex-col lg:flex-row gap-8 border-none shadow-none">
|
||||
{/* Pie Chart */}
|
||||
{selectedItem === 'Hasil Sementara' ? (
|
||||
<>
|
||||
<Card className="flex flex-col w-full">
|
||||
<CardHeader className="items-start pb-0">
|
||||
<CardTitle className="text-lg">Diagram Lingkaran</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PieChartComponent
|
||||
chartData={chartData}
|
||||
totalScore={totalScore}
|
||||
chartConfig={chartConfig}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card className="flex flex-col w-full">
|
||||
<CardHeader className="items-start pb-0">
|
||||
<CardTitle className="text-lg">Diagram Lingkaran</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PieChartComponent
|
||||
chartData={verifiedChartData}
|
||||
totalScore={totalVerifiedScore}
|
||||
chartConfig={chartConfig}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{/* Radar Chart */}
|
||||
{selectedItem === 'Hasil Sementara' ? (
|
||||
<>
|
||||
<Card className="flex flex-col w-full mb-4">
|
||||
<CardHeader className="items-start pb-0">
|
||||
<CardTitle className="text-lg">Diagram Radar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadarChartComponent chartData={chartData} chartConfig={chartConfig} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card className="flex flex-col w-full mb-4">
|
||||
<CardHeader className="items-start pb-0">
|
||||
<CardTitle className="text-lg">Diagram Radary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadarChartComponent chartData={verifiedChartData} chartConfig={chartConfig} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="flex w-full h-fit border mt-8">
|
||||
{/* Bar Chart */}
|
||||
{selectedItem === 'Hasil Sementara' ? (
|
||||
<>
|
||||
<Card className="w-full">
|
||||
<CardHeader className="items-start">
|
||||
<CardTitle className="text-lg">Diagram Batang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChartComponent barChartData={sortedBarChartData} barChartConfig={barChartConfig} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card className="w-full">
|
||||
<CardHeader className="items-start">
|
||||
<CardTitle className="text-lg">Diagram Batang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BarChartComponent barChartData={sortedVerifiedBarChartData} barChartConfig={barChartConfig} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</Card>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,15 +2,14 @@ import PageTemplate from "@/components/PageTemplate";
|
|||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { Flex } from "@mantine/core";
|
||||
import createActionButtons from "@/utils/createActionButton";
|
||||
import { TbEye } from "react-icons/tb";
|
||||
import { assessmentResultsQueryOptions, verifyAssessmentResultQuery } from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
|
||||
import { assessmentResultsQueryOptions, postAnswerRevisionMutation, postAnswerRevisionQueryOptions } from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
|
||||
import { Button } from "@/shadcn/components/ui/button";
|
||||
import assessmentResultsFormModal from "@/modules/assessmentResultsManagement/modals/assessmentResultsFormModal";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import FormResponseError from "@/errors/FormResponseError";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { Badge } from "@/shadcn/components/ui/badge";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
|
||||
export const Route = createLazyFileRoute('/_dashboardLayout/assessmentResultsManagement/')({
|
||||
component: assessmentResultsManagementPage,
|
||||
|
|
@ -20,43 +19,40 @@ type DataType = ExtractQueryDataType<typeof assessmentResultsQueryOptions>;
|
|||
|
||||
const columnHelper = createColumnHelper<DataType>();
|
||||
|
||||
const useVerifyAssessmentResult = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (assessmentId: string) => {
|
||||
return verifyAssessmentResultQuery(assessmentId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["assessmentResults"] });
|
||||
notifications.show({
|
||||
title: "Berhasil",
|
||||
message: "Assessment berhasil diverifikasi",
|
||||
color: "green",
|
||||
});
|
||||
},
|
||||
onError: (error: FormResponseError) => {
|
||||
notifications.show({
|
||||
title: "Error",
|
||||
message: error.message || "Terjadi kesalahan saat verifikasi",
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
const handleViewResult = (assessmentId: string) => {
|
||||
// to make sure assessmentId is valid and not null
|
||||
if (!assessmentId) {
|
||||
console.error("Assessment ID is missing");
|
||||
return;
|
||||
}
|
||||
const resultUrl = `/assessmentResult?id=${assessmentId}`;
|
||||
window.open(resultUrl, "_blank");
|
||||
};
|
||||
|
||||
export default function assessmentResultsManagementPage() {
|
||||
const verifyMutation = useVerifyAssessmentResult();
|
||||
const { user } = useAuth();
|
||||
const revisedBy = user ? user.name : '';
|
||||
|
||||
const handleVerifyClick = (assessmentId: string) => {
|
||||
verifyMutation.mutate(assessmentId);
|
||||
// Use the mutation defined in the queries file
|
||||
const mutation = postAnswerRevisionMutation();
|
||||
|
||||
const verifyAssessment = (assessmentId: string) => {
|
||||
if (!assessmentId) {
|
||||
console.error("Assessment ID is missing");
|
||||
return;
|
||||
}
|
||||
// Call the mutation to post the answer revision
|
||||
mutation.mutate({ assessmentId, revisedBy });
|
||||
|
||||
const resultUrl = `/verifying?id=${assessmentId}`;
|
||||
window.open(resultUrl, "_blank");
|
||||
};
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
title="Manajemen Hasil Assessment"
|
||||
queryOptions={assessmentResultsQueryOptions}
|
||||
modals={[assessmentResultsFormModal()]}
|
||||
// modals={[assessmentResultsFormModal()]}
|
||||
createButton={false}
|
||||
columnDefs={[
|
||||
columnHelper.display({
|
||||
|
|
@ -72,43 +68,55 @@ export default function assessmentResultsManagementPage() {
|
|||
cell: (props) => props.row.original.companyName,
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: "Status Assessment",
|
||||
cell: (props) => props.row.original.statusAssessments,
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: "Status Verifikasi",
|
||||
cell: (props) => props.row.original.statusVerification,
|
||||
id: "statusAssessments",
|
||||
header: () => <div className="text-center">Status Verifikasi</div>,
|
||||
cell: (props) => {
|
||||
const status = props.row.original.statusAssessments;
|
||||
switch (status) {
|
||||
case "belum diverifikasi":
|
||||
return <div className="flex items-center justify-center">
|
||||
<Badge variant={"unverified"}>Belum Diverifikasi</Badge>
|
||||
</div>;
|
||||
case "selesai":
|
||||
return <div className="flex items-center justify-center">
|
||||
<Badge variant={"completed"}>Selesai</Badge>
|
||||
</div>;
|
||||
default:
|
||||
}
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: "Hasil Assessment",
|
||||
cell: (props) => props.row.original.assessmentsResult,
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: "Action",
|
||||
header: " ",
|
||||
cell: (props) => (
|
||||
<div className="flex flex-row w-fit items-center rounded gap-2">
|
||||
{props.row.original.statusAssessments === 'belum diverifikasi' && (
|
||||
<Button
|
||||
onClick={() => handleVerifyClick(props.row.original.id)}
|
||||
variant={"ghost"}
|
||||
className="w-fit items-center bg-gray-200 hover:bg-gray-300"
|
||||
variant="ghost"
|
||||
className="w-fit items-center bg-blue-600 hover:bg-blue-900"
|
||||
onClick={() => verifyAssessment(props.row.original.id ?? '')}
|
||||
>
|
||||
<span className="text-black">Verifikasi</span>
|
||||
<span className="text-white">Verifikasi</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: " ",
|
||||
header: "Action",
|
||||
cell: (props) => (
|
||||
<Flex gap="xs" className="bg-white">
|
||||
{createActionButtons([
|
||||
{
|
||||
label: "Detail",
|
||||
permission: true,
|
||||
action: `?detail=${props.row.original.id}`,
|
||||
color: "black",
|
||||
icon: <TbEye />,
|
||||
},
|
||||
])}
|
||||
</Flex>
|
||||
<div className="flex flex-row w-fit items-center rounded gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-fit items-center hover:bg-gray-300 border"
|
||||
onClick={() => handleViewResult(props.row.original.id ?? '')}
|
||||
>
|
||||
<TbEye className="text-black" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
]}
|
||||
|
|
|
|||
66
apps/frontend/src/routes/_verifyingLayout.tsx
Normal file
66
apps/frontend/src/routes/_verifyingLayout.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router";
|
||||
import AppHeader from "../components/AppHeader";
|
||||
import AppNavbar from "../components/AppNavbar";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import fetchRPC from "@/utils/fetchRPC";
|
||||
import client from "@/honoClient";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/_verifyingLayout")({
|
||||
component: VerifyingLayout,
|
||||
|
||||
// beforeLoad: ({ location }) => {
|
||||
// if (true) {
|
||||
// throw redirect({
|
||||
// to: "/login",
|
||||
// });
|
||||
// }
|
||||
// },
|
||||
});
|
||||
|
||||
function VerifyingLayout() {
|
||||
const { isAuthenticated, saveAuthData } = useAuth();
|
||||
|
||||
useQuery({
|
||||
queryKey: ["my-profile"],
|
||||
queryFn: async () => {
|
||||
const response = await fetchRPC(client.auth["my-profile"].$get());
|
||||
|
||||
saveAuthData({
|
||||
id: response.id,
|
||||
name: response.name,
|
||||
permissions: response.permissions,
|
||||
role: response.roles[0],
|
||||
});
|
||||
|
||||
return response;
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const [openNavbar, setNavbarOpen] = useState(true);
|
||||
const toggle = () => {
|
||||
setNavbarOpen(!openNavbar);
|
||||
};
|
||||
|
||||
return isAuthenticated ? (
|
||||
<div className="flex flex-col w-full h-screen overflow-hidden">
|
||||
{/* Header */}
|
||||
<AppHeader toggle={toggle} openNavbar={openNavbar} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex h-full w-screen overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<AppNavbar />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="relative w-full mt-16 bg-white overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Navigate to="/login" />
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,953 @@
|
|||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
Flex,
|
||||
Stack,
|
||||
Text,
|
||||
Loader,
|
||||
ActionIcon,
|
||||
CloseButton,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
} from "@/shadcn/components/ui/card";
|
||||
import { Button } from "@/shadcn/components/ui/button";
|
||||
import { Textarea } from "@/shadcn/components/ui/textarea";
|
||||
import { Label } from "@/shadcn/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/shadcn/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
|
||||
import {
|
||||
Pagination,
|
||||
} from "@/shadcn/components/ui/pagination-assessment";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
submitAssessmentMutationOptions,
|
||||
uploadFileMutationOptions,
|
||||
fetchAspects,
|
||||
getQuestionsAllQueryOptions,
|
||||
toggleFlagAnswer,
|
||||
} from "@/modules/assessmentManagement/queries/assessmentQueries";
|
||||
import {
|
||||
getAnswersRevisionQueryOptions,
|
||||
submitAssessmentRevisionMutationOptions,
|
||||
updateOptionQuery,
|
||||
updateValidationQuery,
|
||||
} from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
|
||||
import {
|
||||
getAllVerifiedAspectsAverageScore,
|
||||
} from "@/modules/assessmentResult/queries/assessmentResultQueries";
|
||||
import { TbFlagFilled, TbUpload, TbChevronRight, TbChevronDown } from "react-icons/tb";
|
||||
import FinishAssessmentModal from "@/modules/assessmentManagement/modals/ConfirmModal";
|
||||
import ValidationModal from "@/modules/assessmentManagement/modals/ValidationModal";
|
||||
import FileSizeValidationModal from "@/modules/assessmentManagement/modals/FileSizeValidationModal";
|
||||
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("/_verifyingLayout/verifying/")({
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// Definisikan tipe untuk parameter mutation
|
||||
interface UpdateOptionParams {
|
||||
assessmentId: string;
|
||||
questionId: string;
|
||||
optionId: 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 [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalOpenFileSize, setModalOpenFileSize] = useState(false);
|
||||
const [selectedAspectId, setSelectedAspectId] = useState<string | null>(null);
|
||||
const [selectedSubAspectId, setSelectedSubAspectId] = useState<string | null>(null);
|
||||
const [assessmentId, setAssessmentId] = useState<string | null>(null);
|
||||
const [answers, setAnswers] = useState<{ [key: string]: string }>({});
|
||||
const [validationInformation, setValidationInformation] = useState<{ [key: string]: string }>({});
|
||||
const [uploadedFiles, setUploadedFiles] = useState<{ [key: string]: File | null }>({});
|
||||
const [unansweredQuestions, setUnansweredQuestions] = useState(0);
|
||||
const [validationModalOpen, setValidationModalOpen] = useState(false);
|
||||
const [exceededFileName, setExceededFileName] = useState("");
|
||||
const [currentPagePerSubAspect, setCurrentPagePerSubAspect] = useState<{ [subAspectId: string]: number }>({});
|
||||
const currentPage = currentPagePerSubAspect[selectedSubAspectId || ""] || 1;
|
||||
const questionsPerPage = 10;
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
// Fungsi untuk memeriksa pertanyaan yang belum dijawab
|
||||
const checkUnansweredQuestions = () => {
|
||||
// Misalkan data berisi pertanyaan dan jawaban
|
||||
const unanswered = data?.data.filter(question => {
|
||||
// Pastikan questionId tidak null dan tidak ada jawaban untuk questionId tersebut
|
||||
return question.questionId !== null && !answers[question.questionId];
|
||||
}) || []; // Ganti question.id dengan question.questionId dan tambahkan pengecekan null
|
||||
setUnansweredQuestions(unanswered.length); // Aman, karena unanswered selalu array
|
||||
|
||||
// Jika ada pertanyaan yang belum dijawab, buka modal peringatan
|
||||
if (unanswered.length > 0) {
|
||||
setValidationModalOpen(true);
|
||||
} else {
|
||||
setModalOpen(true); // Jika tidak ada, buka modal konfirmasi selesai asesmen
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinishClick = () => {
|
||||
// Memanggil fungsi untuk memeriksa pertanyaan yang belum dijawab
|
||||
checkUnansweredQuestions();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const id = getQueryParam("id");
|
||||
|
||||
if (!id) {
|
||||
setAssessmentId(null);
|
||||
} else {
|
||||
setAssessmentId(id);
|
||||
}
|
||||
|
||||
// Check if aspectsQuery.data is 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)
|
||||
);
|
||||
|
||||
if (firstMatchingSubAspect) {
|
||||
setSelectedSubAspectId(firstMatchingSubAspect.id);
|
||||
|
||||
// Find the parent aspect and set its id as the selectedAspectId
|
||||
const parentAspect = aspectsQuery.data.data.find((aspect) =>
|
||||
aspect.subAspects.some((sub) => sub.id === firstMatchingSubAspect.id)
|
||||
);
|
||||
|
||||
if (parentAspect) {
|
||||
setSelectedAspectId(parentAspect.id); // Use `id` from the parent aspect
|
||||
setOpenAspects({ [parentAspect.id]: true }); // Open only relevant aspects
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Update the aspectId based on the selected sub-aspect
|
||||
const matchingAspect = aspectsQuery.data.data.find((aspect) =>
|
||||
aspect.subAspects.some((subAspect) => subAspect.id === selectedSubAspectId)
|
||||
);
|
||||
|
||||
if (matchingAspect) {
|
||||
setSelectedAspectId(matchingAspect.id); // Use `id` from the matching aspect
|
||||
setOpenAspects({ [matchingAspect.id]: true }); // Close all other dropdowns and open only the newly selected aspect
|
||||
} else {
|
||||
console.warn("No matching aspect found for selected sub-aspect.");
|
||||
setSelectedAspectId(null);
|
||||
setOpenAspects({}); // Close all dropdowns if none of them match
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [aspectsQuery.data, selectedSubAspectId, data?.data]);
|
||||
|
||||
// Fetching answers for the assessment
|
||||
const { data: answersData } = useQuery(
|
||||
getAnswersRevisionQueryOptions(assessmentId || "", page, limit)
|
||||
);
|
||||
console.log("answersData:", answersData);
|
||||
|
||||
// Effect untuk mengatur answers dari data yang diambil
|
||||
useEffect(() => {
|
||||
if (!assessmentId) {
|
||||
console.error("Assessment ID tidak ditemukan");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ambil jawaban dari localStorage berdasarkan ID assessment
|
||||
const savedAnswers = JSON.parse(localStorage.getItem(`assessmentAnswers_${assessmentId}`) || "{}");
|
||||
|
||||
// Gabungkan jawaban dari localStorage dan answersData
|
||||
if (answersData) {
|
||||
// Pastikan answersData adalah objek yang valid sebelum menggabungkan
|
||||
setAnswers({
|
||||
...savedAnswers, // Jawaban dari localStorage
|
||||
...answersData // Jawaban dari query
|
||||
});
|
||||
} else {
|
||||
setAnswers(savedAnswers); // Gunakan data dari localStorage jika answersData kosong
|
||||
}
|
||||
}, [answersData, assessmentId]);
|
||||
|
||||
const handleConfirmFinish = async (assessmentId: string) => {
|
||||
try {
|
||||
// Cek pertanyaan yang belum dijawab
|
||||
let unansweredCount = 0;
|
||||
|
||||
// Cek radio button
|
||||
data?.data.forEach((question) => {
|
||||
// Pastikan questionId tidak null sebelum memeriksa answers
|
||||
if (question.questionId && !answers[question.questionId]) {
|
||||
unansweredCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// Cek textarea
|
||||
Object.keys(validationInformation).forEach((key) => {
|
||||
// Pastikan key tidak null dan tidak ada validasi informasi untuk key tersebut
|
||||
if (key && !validationInformation[key]) {
|
||||
unansweredCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (unansweredCount > 0) {
|
||||
// Tampilkan modal validasi jika ada pertanyaan yang belum dijawab
|
||||
setUnansweredQuestions(unansweredCount);
|
||||
setValidationModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Memanggil mutation untuk mengubah status asesmen menjadi 'selesai' di backend
|
||||
const mutation = submitAssessmentRevisionMutationOptions(assessmentId);
|
||||
const response = await mutation.mutationFn();
|
||||
|
||||
// Setelah status diubah, navigasikan ke halaman hasil asesmen
|
||||
const newUrl = `/assessmentResult?id=${assessmentId}`;
|
||||
window.history.pushState({}, "", newUrl);
|
||||
console.log("Navigated to:", newUrl);
|
||||
console.log(response.message);
|
||||
} catch (error) {
|
||||
console.error("Error finishing assessment:", error);
|
||||
} finally {
|
||||
setModalOpen(false); // Menutup modal setelah selesai
|
||||
}
|
||||
};
|
||||
|
||||
// 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 by aspect
|
||||
const averageScoreQuery = useQuery(getAllVerifiedAspectsAverageScore(assessmentId || ""));
|
||||
const aspects = averageScoreQuery.data?.aspects || [];
|
||||
|
||||
// Filter aspects by selected aspectId
|
||||
const filteredAspects = selectedAspectId
|
||||
? aspects.filter((aspect) => aspect.aspectId === selectedAspectId) // Use 'id' instead of 'aspectId'
|
||||
: aspects;
|
||||
|
||||
// Get the currently selected aspect to show all related sub-aspects
|
||||
const currentAspect = aspects.find(aspect => aspect.aspectId === selectedAspectId);
|
||||
const filteredSubAspects = currentAspect ? currentAspect.subAspects : [];
|
||||
|
||||
// Inisialisasi flaggedQuestions dari localStorage saat komponen dimuat
|
||||
useEffect(() => {
|
||||
const savedFlags = localStorage.getItem("flaggedQuestions");
|
||||
if (savedFlags) {
|
||||
setFlaggedQuestions(JSON.parse(savedFlags));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Simpan perubahan flag ke localStorage setiap kali flaggedQuestions berubah
|
||||
useEffect(() => {
|
||||
if (Object.keys(flaggedQuestions).length > 0) {
|
||||
localStorage.setItem("flaggedQuestions", JSON.stringify(flaggedQuestions));
|
||||
}
|
||||
}, [flaggedQuestions]);
|
||||
|
||||
// Mutation function to toggle flag
|
||||
const toggleFlagMutation = useMutation<ToggleFlagResponse, Error, string>({
|
||||
mutationFn: toggleFlagAnswer,
|
||||
onSuccess: (response) => {
|
||||
if (response && response.answer) {
|
||||
const { answer } = response;
|
||||
setFlaggedQuestions((prevFlags) => {
|
||||
const newFlags = {
|
||||
...prevFlags,
|
||||
[answer.id]: answer.isFlagged !== null ? answer.isFlagged : false,
|
||||
};
|
||||
// Simpan perubahan ke localStorage
|
||||
localStorage.setItem("flaggedQuestions", JSON.stringify(newFlags));
|
||||
return newFlags;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
console.error("Error toggling flag:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Usage of the mutation in your component
|
||||
const { mutate: submitOption } = useMutation({
|
||||
mutationFn: (form: {
|
||||
assessmentId: string;
|
||||
questionId: string;
|
||||
optionId: string;
|
||||
}) => updateOptionQuery(form),
|
||||
onSuccess: () => {
|
||||
averageScoreQuery.refetch();
|
||||
// Tindakan yang diambil setelah berhasil
|
||||
console.log("Option updated successfully!");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error updating option:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAnswerChange = (questionId: string, optionId: string) => {
|
||||
const assessmentId = getQueryParam("id");
|
||||
|
||||
// Memastikan assessmentId adalah string yang valid
|
||||
if (!assessmentId) {
|
||||
console.error("Assessment ID tidak ditemukan");
|
||||
return; // Keluar jika assessmentId tidak ada
|
||||
}
|
||||
|
||||
// Update jawaban untuk pertanyaan tertentu
|
||||
const updatedAnswers = { ...answers, [questionId]: optionId };
|
||||
// Simpan jawaban ke localStorage dengan ID assessment
|
||||
localStorage.setItem(`assessmentAnswers_${assessmentId}`, JSON.stringify(updatedAnswers));
|
||||
|
||||
// Update state
|
||||
setAnswers(updatedAnswers);
|
||||
|
||||
// Call the mutation to submit the option
|
||||
submitOption({
|
||||
assessmentId, // Menggunakan assessmentId yang diperoleh dari parameter
|
||||
questionId,
|
||||
optionId, // Mengirim ID opsi yang dipilih
|
||||
});
|
||||
};
|
||||
|
||||
// Mutation untuk mengirim data ke backend
|
||||
const { mutate: submitValidation } = useMutation({
|
||||
mutationFn: (form: {
|
||||
assessmentId: string;
|
||||
questionId: string;
|
||||
newValidationInformation: string;
|
||||
}) => updateValidationQuery(form),
|
||||
onSuccess: () => {
|
||||
// Tindakan yang diambil setelah berhasil
|
||||
console.log("Validation updated successfully!");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error updating validation:", error);
|
||||
},
|
||||
});
|
||||
|
||||
// Mengambil data dari localStorage saat komponen dimuat
|
||||
useEffect(() => {
|
||||
const storedValidationInfo = localStorage.getItem(`validationInfo_${assessmentId}`);
|
||||
|
||||
if (storedValidationInfo) {
|
||||
try {
|
||||
const parsedValidationInfo = JSON.parse(storedValidationInfo);
|
||||
setValidationInformation(parsedValidationInfo);
|
||||
|
||||
// Iterasi melalui parsedValidationInfo untuk mengirimkan setiap validasi ke server
|
||||
Object.keys(parsedValidationInfo).forEach((questionId) => {
|
||||
const validationValue = parsedValidationInfo[questionId];
|
||||
|
||||
// Pastikan assessmentId tidak null sebelum memanggil submitValidation
|
||||
if (assessmentId) {
|
||||
submitValidation({
|
||||
assessmentId,
|
||||
questionId,
|
||||
newValidationInformation: validationValue,
|
||||
});
|
||||
} else {
|
||||
console.error("Assessment ID tidak ditemukan");
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error parsing validation information:", error);
|
||||
}
|
||||
}
|
||||
}, [assessmentId, submitValidation]);
|
||||
|
||||
// Handle perubahan di Textarea
|
||||
const handleTextareaChange = (questionId: string, value: string) => {
|
||||
// Memperbarui state validationInformation
|
||||
setValidationInformation((prev) => ({
|
||||
...prev,
|
||||
[questionId]: value,
|
||||
}));
|
||||
|
||||
// Memperbarui localStorage dengan informasi validasi baru dalam format JSON
|
||||
const updatedValidationInformation = {
|
||||
...validationInformation,
|
||||
[questionId]: value,
|
||||
};
|
||||
localStorage.setItem(`validationInfo_${assessmentId}`, JSON.stringify(updatedValidationInformation));
|
||||
|
||||
// Pastikan assessmentId tidak null sebelum mengirimkan data ke server
|
||||
if (assessmentId) {
|
||||
// Kirim data validasi ke server
|
||||
submitValidation({
|
||||
assessmentId,
|
||||
questionId,
|
||||
newValidationInformation: value,
|
||||
});
|
||||
} else {
|
||||
console.error("Assessment ID tidak ditemukan");
|
||||
}
|
||||
};
|
||||
|
||||
// Mutation for file upload
|
||||
const uploadFileMutation = useMutation(uploadFileMutationOptions());
|
||||
|
||||
// Drag and Drop handlers
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
// Load uploaded files from local storage when the component mounts
|
||||
useEffect(() => {
|
||||
const keys = Object.keys(localStorage);
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith(`uploadedFile_${assessmentId}_`)) { // Menggunakan assessmentId
|
||||
const fileData = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
const questionId = key.replace(`uploadedFile_${assessmentId}_`, ''); // Ambil questionId dari kunci
|
||||
setUploadedFiles(prev => ({
|
||||
...prev,
|
||||
[questionId]: new File([fileData], fileData.name, { type: fileData.type }), // Buat objek File baru
|
||||
}));
|
||||
}
|
||||
});
|
||||
}, [assessmentId]);
|
||||
|
||||
// Max file size in bytes (64 MB)
|
||||
const MAX_FILE_SIZE = 64 * 1024 * 1024;
|
||||
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>, question: { questionId: string }) => {
|
||||
event.preventDefault();
|
||||
setDragActive(false);
|
||||
const droppedFiles = Array.from(event.dataTransfer.files);
|
||||
|
||||
if (droppedFiles.length > 0) {
|
||||
const file = droppedFiles[0];
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setExceededFileName(file.name); // Simpan nama file yang melebihi ukuran
|
||||
setModalOpenFileSize(true); // Tampilkan modal
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file); // Hanya menyertakan file pertama
|
||||
|
||||
// Pastikan assessmentId tidak null sebelum menambahkannya ke FormData
|
||||
if (assessmentId) {
|
||||
formData.append('assessmentId', assessmentId);
|
||||
} else {
|
||||
console.error("assessmentId is null");
|
||||
return; // Atau tangani sesuai kebutuhan
|
||||
}
|
||||
|
||||
// Tambahkan questionId ke FormData
|
||||
if (question.questionId) {
|
||||
formData.append('questionId', question.questionId);
|
||||
} else {
|
||||
console.error("questionId is null");
|
||||
return; // Atau tangani sesuai kebutuhan
|
||||
}
|
||||
|
||||
uploadFileMutation.mutate(formData); // Unggah file
|
||||
|
||||
// Simpan file dalam state dan local storage menggunakan questionId dan assessmentId sebagai kunci
|
||||
setUploadedFiles(prev => ({
|
||||
...prev,
|
||||
[question.questionId]: file, // Simpan file berdasarkan questionId
|
||||
}));
|
||||
|
||||
localStorage.setItem(`uploadedFile_${assessmentId}_${question.questionId}`, JSON.stringify({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>, question: { questionId: string }) => {
|
||||
if (event.target.files) {
|
||||
const fileArray = Array.from(event.target.files);
|
||||
if (fileArray.length > 0) {
|
||||
const file = fileArray[0];
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setExceededFileName(file.name); // Simpan nama file yang melebihi ukuran
|
||||
setModalOpenFileSize(true); // Tampilkan modal
|
||||
return; // Hentikan eksekusi fungsi jika ukuran file melebihi batas
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file); // Hanya menyertakan file pertama
|
||||
|
||||
// Tambahkan assessmentId ke FormData
|
||||
if (assessmentId) {
|
||||
formData.append('assessmentId', assessmentId);
|
||||
} else {
|
||||
console.error("assessmentId is null");
|
||||
return; // Atau tangani sesuai kebutuhan
|
||||
}
|
||||
|
||||
// Tambahkan questionId ke FormData
|
||||
if (question.questionId) {
|
||||
formData.append('questionId', question.questionId);
|
||||
} else {
|
||||
console.error("questionId is null");
|
||||
return; // Atau tangani sesuai kebutuhan
|
||||
}
|
||||
|
||||
uploadFileMutation.mutate(formData); // Unggah file
|
||||
|
||||
// Simpan file dalam state dan local storage menggunakan questionId dan assessmentId sebagai kunci
|
||||
setUploadedFiles(prev => ({
|
||||
...prev,
|
||||
[question.questionId]: file, // Simpan file berdasarkan questionId
|
||||
}));
|
||||
|
||||
localStorage.setItem(`uploadedFile_${assessmentId}_${question.questionId}`, JSON.stringify({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = (question: { questionId: string }) => {
|
||||
setUploadedFiles(prev => ({
|
||||
...prev,
|
||||
[question.questionId]: null, // Hapus file yang diunggah untuk pertanyaan ini
|
||||
}));
|
||||
localStorage.removeItem(`uploadedFile_${assessmentId}_${question.questionId}`); // Hapus info file dari local storage
|
||||
};
|
||||
|
||||
// Function to scroll to the specific question
|
||||
const scrollToQuestion = (questionId: string) => {
|
||||
const questionElement = questionRefs.current[questionId];
|
||||
if (questionElement) {
|
||||
questionElement.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
// Render conditions
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Text color="red">
|
||||
Error: {error?.message || "Terjadi kesalahan saat memuat pertanyaan."}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (!assessmentId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Text color="red" className="text-center">
|
||||
Error: Data Asesmen tidak ditemukan. Harap akses halaman melalui link yang valid.
|
||||
</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * questionsPerPage;
|
||||
|
||||
// Fungsi untuk mengubah halaman pada sub-aspek
|
||||
const handlePageChange = (subAspectId: string, newPage: number) => {
|
||||
setCurrentPagePerSubAspect((prev) => ({
|
||||
...prev,
|
||||
[subAspectId]: newPage,
|
||||
}));
|
||||
};
|
||||
|
||||
// Filter pertanyaan berdasarkan halaman saat ini
|
||||
const filteredQuestions = data?.data?.filter((question) => {
|
||||
// Filter berdasarkan sub-aspek yang dipilih
|
||||
return question.subAspectId === selectedSubAspectId;
|
||||
})?.slice(
|
||||
(currentPage - 1) * questionsPerPage,
|
||||
currentPage * questionsPerPage
|
||||
) || [];
|
||||
|
||||
// Perbarui jumlah halaman untuk sub-aspek saat ini
|
||||
const totalQuestionsInSubAspect = data?.data?.filter(
|
||||
(question) => question.subAspectId === selectedSubAspectId
|
||||
)?.length || 0;
|
||||
|
||||
const totalPages = Math.ceil(totalQuestionsInSubAspect / questionsPerPage);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Stack gap="md">
|
||||
<Flex justify="space-between" align="flex-start" mt="lg">
|
||||
|
||||
{/* LEFT-SIDE */}
|
||||
{/* Aspek dan Sub-Aspek */}
|
||||
<div className="fixed h-screen w-64 overflow-auto">
|
||||
<Flex direction="column" gap="xs" className="w-64">
|
||||
<div className="space-y-2">
|
||||
{/* Aspek */}
|
||||
{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-2 "
|
||||
>
|
||||
<div
|
||||
className="flex justify-between cursor-pointer"
|
||||
onClick={() => toggleAspect(aspect.id)}
|
||||
>
|
||||
<div className="text-sm font-bold px-3">{aspect.name}</div>
|
||||
<div>
|
||||
{openAspects[aspect.id] ? (
|
||||
<TbChevronDown size={25} />
|
||||
) : (
|
||||
<TbChevronRight size={25} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-Aspek */}
|
||||
{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 cursor-pointer p-2 px-6 rounded-sm transition-colors duration-150 ${selectedSubAspectId === subAspect.id ? 'text-black font-medium bg-gray-200' : 'text-gray-500'}`}
|
||||
onClick={() => setSelectedSubAspectId(subAspect.id)}
|
||||
>
|
||||
<div className="text-xs">{subAspect.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{/* MIDDLE */}
|
||||
{/* Pertanyaan */}
|
||||
<div className="ml-64 mr-60 flex-1 overflow-y-auto h-full">
|
||||
<Stack gap="sm" style={{ flex: 1 }}>
|
||||
<Text className="text-2xl font-bold ml-6">
|
||||
Harap menjawab semua pertanyaan yang tersedia
|
||||
</Text>
|
||||
<Text className="text-gray-400 ml-6 mb-7">Semua jawaban Anda akan ditinjau</Text>
|
||||
{filteredQuestions.length === 0 ? (
|
||||
<Text 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 (
|
||||
<div
|
||||
key={questionId}
|
||||
ref={(el) => (questionRefs.current[questionId] = el)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Flex justify="space-between" align="flex-start" style={{ width: "100%" }}>
|
||||
{/* Question */}
|
||||
<Text className="font-bold mx-3 p-1 text-sm">{startIndex + index + 1}.</Text>
|
||||
<div className="flex-grow">
|
||||
<Text className="font-bold break-words text-sm p-1">
|
||||
{question.questionText}
|
||||
</Text>
|
||||
</div>
|
||||
</Flex>
|
||||
|
||||
{/* Opsi Radio Button */}
|
||||
{question.options?.length > 0 ? (
|
||||
<div className="mx-11">
|
||||
<RadioGroup
|
||||
value={answers[questionId] || ""}
|
||||
onValueChange={(value) => handleAnswerChange(questionId, value)}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
{question.options.map((option: any) => (
|
||||
<div
|
||||
key={option.optionId}
|
||||
className={`cursor-pointer transition-transform transform hover:scale-105 shadow-md hover:shadow-lg flex items-center border-4 rounded-lg p-3 text-sm ${
|
||||
answers[questionId] === option.optionId
|
||||
? "bg-[--primary-color] text-white border-[--primary-color]"
|
||||
: "bg-gray-200 text-black border-gray-200"
|
||||
}`}
|
||||
onClick={() => handleAnswerChange(questionId, option.optionId)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.optionId}
|
||||
id={option.optionId}
|
||||
checked={answers[questionId] === option.optionId}
|
||||
className="bg-white checked:bg-white checked:border-[--primary-color] pointer-events-none rounded-full"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={option.optionId}
|
||||
className="ml-2 font-bold cursor-pointer flex-1"
|
||||
>
|
||||
{option.optionText}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : (
|
||||
<Text color="red">Tidak ada opsi untuk pertanyaan ini.</Text>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="mx-11">
|
||||
<Textarea
|
||||
placeholder="Berikan keterangan terkait jawaban di atas"
|
||||
value={validationInformation[question.questionId] || ""}
|
||||
onChange={(event) => handleTextareaChange(question.questionId, event.currentTarget.value)}
|
||||
disabled={!answers[question.questionId]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Size Validation Modal */}
|
||||
<FileSizeValidationModal
|
||||
opened={modalOpenFileSize}
|
||||
onClose={() => setModalOpenFileSize(false)}
|
||||
fileName={exceededFileName}
|
||||
/>
|
||||
|
||||
{/* Garis pembatas setiap soal */}
|
||||
<div>
|
||||
<hr className="border-t-2 border-gray-300 mx-11 mt-6 mb-6" />
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
{/* RIGHT-SIDE */}
|
||||
{/* Navigasi dan Pagination */}
|
||||
<div className="fixed h-screen right-0 w-60 overflow-auto mr-4">
|
||||
<Flex direction="column" gap="xs" className="mx-4">
|
||||
<Text className="font-medium text-lg text-gray-800 mb-2">
|
||||
Nomor Soal
|
||||
</Text>
|
||||
|
||||
{/* Navigasi (Number of Questions) */}
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Array.from({ length: totalQuestionsInSubAspect }).map((_, i) => {
|
||||
const questionNumber = startIndex + i + 1;
|
||||
const questionId = filteredQuestions[i]?.questionId;
|
||||
|
||||
return questionId ? (
|
||||
<div key={questionId} className="flex justify-center relative">
|
||||
<button
|
||||
className={`w-9 h-9 border rounded-sm flex items-center justify-center relative text-md
|
||||
${answers[questionId] && flaggedQuestions[questionId] ? "bg-white text-black" : ""}
|
||||
${answers[questionId] && !flaggedQuestions[questionId] ? "bg-[--primary-color] text-white" : ""}
|
||||
${!answers[questionId] && !flaggedQuestions[questionId] ? "bg-transparent text-black" : ""}
|
||||
${flaggedQuestions[questionId] ? "border-gray-50" : ""}`}
|
||||
onClick={() => scrollToQuestion(questionId)}
|
||||
>
|
||||
{questionNumber}
|
||||
</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-red-600 rounded-tr-sm" />
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={(newPage) => {
|
||||
if (selectedSubAspectId) {
|
||||
handlePageChange(selectedSubAspectId, newPage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className="text-xs m-0">Halaman {currentPage} dari {totalPages}</Text>
|
||||
</Pagination>
|
||||
</div>
|
||||
|
||||
{/* Skor Aspek dan Sub-Aspek */}
|
||||
<div className="mt-4">
|
||||
<Card>
|
||||
<Text className="text-lg font-extrabold text-center mt-4 mb-2">
|
||||
Nilai Sementara
|
||||
</Text>
|
||||
<CardContent className="max-h-full overflow-hidden">
|
||||
<ScrollArea className="h-[200px] w-full rounded-md p-2">
|
||||
<CardDescription>
|
||||
{filteredAspects.length > 0 ? (
|
||||
filteredAspects.map((aspect) => {
|
||||
const aspectScore = parseFloat(aspect.averageScore).toFixed(2);
|
||||
const aspectScoreValue = parseFloat(aspectScore);
|
||||
|
||||
return (
|
||||
<div key={aspect.aspectId} className="flex justify-between items-center">
|
||||
<Text className="text-lg text-gray-700">{aspect.aspectName}</Text>
|
||||
<Text
|
||||
className={`text-xl font-bold ${
|
||||
aspectScoreValue >= 4.5
|
||||
? "text-green-700"
|
||||
: aspectScoreValue >= 3.5
|
||||
? "text-green-400"
|
||||
: aspectScoreValue >= 2.5
|
||||
? "text-yellow-400"
|
||||
: aspectScoreValue >= 1.5
|
||||
? "text-orange-500"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{aspectScore}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text className="text-base text-gray-700">Data aspek ini kosong</Text>
|
||||
)}
|
||||
</CardDescription>
|
||||
|
||||
{/* Garis pembatas */}
|
||||
<div className="border-t-2 border-gray-300 my-4" />
|
||||
|
||||
{/* Skor Sub-Aspek */}
|
||||
{filteredSubAspects.length > 0 ? (
|
||||
filteredSubAspects.map((subAspect) => {
|
||||
const subAspectScore = parseFloat(subAspect.averageScore).toFixed(2);
|
||||
const subAspectScoreValue = parseFloat(subAspectScore);
|
||||
|
||||
return (
|
||||
<div key={subAspect.subAspectId} className="flex justify-between items-center my-2">
|
||||
<Text className="text-sm text-gray-700">{subAspect.subAspectName}</Text>
|
||||
<Text
|
||||
className={`text-sm font-bold ${
|
||||
subAspectScoreValue >= 4.5
|
||||
? "text-green-700"
|
||||
: subAspectScoreValue >= 3.5
|
||||
? "text-green-400"
|
||||
: subAspectScoreValue >= 2.5
|
||||
? "text-yellow-400"
|
||||
: subAspectScoreValue >= 1.5
|
||||
? "text-orange-500"
|
||||
: "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{subAspectScore}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<Text className="text-sm text-gray-700">Data sub-aspek ini kosong</Text>
|
||||
)}
|
||||
</ScrollArea>
|
||||
{/* Tombol Selesai */}
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleFinishClick}
|
||||
className="bg-[--primary-color] text-white font-bold rounded-md w-full text-sm"
|
||||
>
|
||||
Selesai
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Modal untuk konfirmasi selesai asesmen */}
|
||||
<FinishAssessmentModal
|
||||
opened={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onConfirm={handleConfirmFinish}
|
||||
assessmentId={assessmentId}
|
||||
/>
|
||||
|
||||
{/* Modal untuk peringatan jika ada pertanyaan yang belum dijawab */}
|
||||
<ValidationModal
|
||||
opened={validationModalOpen}
|
||||
onClose={() => setValidationModalOpen(false)}
|
||||
unansweredQuestions={unansweredQuestions}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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("/_verifyingLayout/verifying/")({
|
||||
validateSearch: searchParamSchema,
|
||||
|
||||
loader: ({ context: { queryClient } }) => {
|
||||
queryClient.ensureQueryData(getQuestionsAllQueryOptions(0, 10));
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user