Pull Request branch dev-clone to main #1

Merged
gitea merged 429 commits from dev-clone into main 2024-12-23 09:31:34 +00:00
10 changed files with 2329 additions and 389 deletions
Showing only changes of commit 92744c7f82 - Show all commits

View File

@ -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,54 +298,244 @@ const assessmentRoute = new Hono<HonoEnv>()
}
)
// POST Endpoint for creating a new answer revision
.post(
.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
);
}
)
)
.patch(
"/:id",
@ -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;

View File

@ -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;

View File

@ -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 = () => {

View File

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

View File

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

View File

@ -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]"
@ -230,8 +299,8 @@ export default function AssessmentResultPage() {
return null;
}}
/>
<PolarAngleAxis dataKey="aspectName" tick={{ fontSize: 10}} stroke="black" />
<PolarRadiusAxis angle={90} domain={[0, 8]} tick={{ fontSize: 10 }} stroke="black" />
<PolarAngleAxis dataKey="aspectName" 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}>
<CartesianGrid vertical={false} horizontal={true}/>
<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>
);

View File

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

View 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" />
);
}

View File

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

View File

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