Merge pull request #29 from digitalsolutiongroup/feat/assessment-frontend
Slicing and Integration API for Assessment
This commit is contained in:
commit
33309e7eba
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq, ilike, or, sql } from "drizzle-orm";
|
import { and, eq, ilike, isNull, inArray, or, sql } from "drizzle-orm";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import db from "../../drizzle";
|
import db from "../../drizzle";
|
||||||
|
|
@ -42,6 +42,133 @@ async function updateFilenameInDatabase(answerId: string, filename: string): Pro
|
||||||
const assessmentsRoute = new Hono<HonoEnv>()
|
const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
.use(authInfo)
|
.use(authInfo)
|
||||||
|
|
||||||
|
// Get all aspects
|
||||||
|
.get(
|
||||||
|
"/aspect",
|
||||||
|
// checkPermission("managementAspect.readAll"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
includeTrashed: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
withMetadata: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
page: z.coerce.number().int().min(0).default(0),
|
||||||
|
limit: z.coerce.number().int().min(1).max(1000).default(10),
|
||||||
|
q: z.string().default(""),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
|
const totalCountQuery = includeTrashed
|
||||||
|
? sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})`
|
||||||
|
: sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`;
|
||||||
|
|
||||||
|
const aspectIdsQuery = await db
|
||||||
|
.select({
|
||||||
|
id: aspects.id,
|
||||||
|
})
|
||||||
|
.from(aspects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
includeTrashed ? undefined : isNull(aspects.deletedAt),
|
||||||
|
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.offset(page * limit)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const aspectIds = aspectIdsQuery.map(a => a.id);
|
||||||
|
|
||||||
|
if (aspectIds.length === 0) {
|
||||||
|
return c.json({
|
||||||
|
data: [],
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main query to get aspects, sub-aspects, and number of questions
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: aspects.id,
|
||||||
|
name: aspects.name,
|
||||||
|
createdAt: aspects.createdAt,
|
||||||
|
updatedAt: aspects.updatedAt,
|
||||||
|
...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}),
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
// Increase the number of questions related to sub aspects
|
||||||
|
questionCount: sql<number>`(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM ${questions}
|
||||||
|
WHERE ${questions.subAspectId} = ${subAspects.id}
|
||||||
|
)`.as('questionCount'),
|
||||||
|
fullCount: totalCountQuery,
|
||||||
|
})
|
||||||
|
.from(aspects)
|
||||||
|
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.where(inArray(aspects.id, aspectIds));
|
||||||
|
|
||||||
|
// Grouping sub aspects by aspect ID
|
||||||
|
const groupedResult = result.reduce((acc, curr) => {
|
||||||
|
const aspectId = curr.id;
|
||||||
|
|
||||||
|
if (!acc[aspectId]) {
|
||||||
|
acc[aspectId] = {
|
||||||
|
id: curr.id,
|
||||||
|
name: curr.name,
|
||||||
|
createdAt: curr.createdAt ? new Date(curr.createdAt).toISOString() : null,
|
||||||
|
updatedAt: curr.updatedAt ? new Date(curr.updatedAt).toISOString() : null,
|
||||||
|
subAspects: curr.subAspectName
|
||||||
|
? [{ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount }]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (curr.subAspectName) {
|
||||||
|
const exists = acc[aspectId].subAspects.some(sub => sub.id === curr.subAspectId);
|
||||||
|
if (!exists) {
|
||||||
|
acc[aspectId].subAspects.push({
|
||||||
|
id: curr.subAspectId!,
|
||||||
|
name: curr.subAspectName,
|
||||||
|
questionCount: curr.questionCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
subAspects: { id: string; name: string; questionCount: number }[];
|
||||||
|
}>);
|
||||||
|
|
||||||
|
const groupedArray = Object.values(groupedResult);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: groupedArray,
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit),
|
||||||
|
totalItems: Number(result[0]?.fullCount) ?? 0,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// Get data for current Assessment Score from submitted options By Assessment Id
|
// Get data for current Assessment Score from submitted options By Assessment Id
|
||||||
.get(
|
.get(
|
||||||
"/getCurrentAssessmentScore",
|
"/getCurrentAssessmentScore",
|
||||||
|
|
@ -77,6 +204,21 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
"/getAllQuestions",
|
"/getAllQuestions",
|
||||||
checkPermission("assessments.readAllQuestions"),
|
checkPermission("assessments.readAllQuestions"),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
// Definisikan tipe untuk hasil query dan izinkan nilai null
|
||||||
|
type QuestionWithOptions = {
|
||||||
|
aspectsId: string | null;
|
||||||
|
aspectsName: string | null;
|
||||||
|
subAspectId: string | null;
|
||||||
|
subAspectName: string | null;
|
||||||
|
questionId: string | null;
|
||||||
|
questionText: string | null;
|
||||||
|
optionId: string;
|
||||||
|
optionText: string;
|
||||||
|
needFile: boolean | null;
|
||||||
|
optionScore: number;
|
||||||
|
fullCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const totalCountQuery =
|
const totalCountQuery =
|
||||||
sql<number>`(SELECT count(*)
|
sql<number>`(SELECT count(*)
|
||||||
FROM ${options}
|
FROM ${options}
|
||||||
|
|
@ -86,16 +228,18 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
WHERE ${questions.deletedAt} IS NULL
|
WHERE ${questions.deletedAt} IS NULL
|
||||||
)`;
|
)`;
|
||||||
|
|
||||||
const result = await db
|
// Sesuaikan tipe hasil query
|
||||||
|
const result: QuestionWithOptions[] = await db
|
||||||
.select({
|
.select({
|
||||||
optionId: options.id,
|
|
||||||
aspectsId: aspects.id,
|
aspectsId: aspects.id,
|
||||||
aspectsName: aspects.name,
|
aspectsName: aspects.name,
|
||||||
subAspectId: subAspects.id,
|
subAspectId: subAspects.id,
|
||||||
subAspectName: subAspects.name,
|
subAspectName: subAspects.name,
|
||||||
questionId: questions.id,
|
questionId: questions.id,
|
||||||
questionText: questions.question,
|
questionText: questions.question,
|
||||||
|
optionId: options.id,
|
||||||
optionText: options.text,
|
optionText: options.text,
|
||||||
|
needFile: questions.needFile,
|
||||||
optionScore: options.score,
|
optionScore: options.score,
|
||||||
fullCount: totalCountQuery,
|
fullCount: totalCountQuery,
|
||||||
})
|
})
|
||||||
|
|
@ -103,15 +247,63 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
.leftJoin(questions, eq(options.questionId, questions.id))
|
.leftJoin(questions, eq(options.questionId, questions.id))
|
||||||
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||||
.where(sql`${questions.deletedAt} IS NULL`)
|
.where(sql`${questions.deletedAt} IS NULL`);
|
||||||
|
|
||||||
|
// Definisikan tipe untuk hasil pengelompokan
|
||||||
|
type GroupedQuestion = {
|
||||||
|
questionId: string | null;
|
||||||
|
questionText: string | null;
|
||||||
|
needFile: boolean | null;
|
||||||
|
aspectsId: string | null;
|
||||||
|
aspectsName: string | null;
|
||||||
|
subAspectId: string | null;
|
||||||
|
subAspectName: string | null;
|
||||||
|
options: {
|
||||||
|
optionId: string;
|
||||||
|
optionText: string;
|
||||||
|
optionScore: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mengelompokkan berdasarkan questionId
|
||||||
|
const groupedResult: GroupedQuestion[] = result.reduce((acc, current) => {
|
||||||
|
const { questionId, questionText, needFile, aspectsId, aspectsName, subAspectId, subAspectName, optionId, optionText, optionScore } = current;
|
||||||
|
|
||||||
|
// Cek apakah questionId sudah ada dalam accumulator
|
||||||
|
const existingQuestion = acc.find(q => q.questionId === questionId);
|
||||||
|
|
||||||
|
if (existingQuestion) {
|
||||||
|
// Tambahkan opsi baru ke array options dari pertanyaan yang ada
|
||||||
|
existingQuestion.options.push({
|
||||||
|
optionId,
|
||||||
|
optionText,
|
||||||
|
optionScore
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Jika pertanyaan belum ada, tambahkan objek baru
|
||||||
|
acc.push({
|
||||||
|
questionId,
|
||||||
|
questionText,
|
||||||
|
needFile,
|
||||||
|
aspectsId,
|
||||||
|
aspectsName,
|
||||||
|
subAspectId,
|
||||||
|
subAspectName,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
optionId,
|
||||||
|
optionText,
|
||||||
|
optionScore
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, [] as GroupedQuestion[]); // Pastikan tipe untuk accumulator didefinisikan
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
data: result.map((d) => (
|
data: groupedResult,
|
||||||
{
|
|
||||||
...d,
|
|
||||||
fullCount: undefined
|
|
||||||
}
|
|
||||||
)),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -159,10 +351,10 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
eq(answers.assessmentId, assessmentId), // Filter by assessmentId
|
eq(answers.assessmentId, assessmentId), // Filter by assessmentId
|
||||||
q
|
q
|
||||||
? or(
|
? or(
|
||||||
ilike(answers.filename, q),
|
ilike(answers.filename, q),
|
||||||
ilike(answers.validationInformation, q),
|
ilike(answers.validationInformation, q),
|
||||||
eq(answers.id, q)
|
eq(answers.id, q)
|
||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -185,26 +377,26 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
|
|
||||||
// Toggles the isFlagged field between true and false
|
// Toggles the isFlagged field between true and false
|
||||||
.patch(
|
.patch(
|
||||||
"/:id/toggleFlag",
|
"/:questionId/toggleFlag",
|
||||||
checkPermission("assessments.toggleFlag"),
|
checkPermission("assessments.toggleFlag"),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const answerId = c.req.param("id");
|
const questionId = c.req.param("questionId");
|
||||||
|
|
||||||
// Retrieve the current state of isFlagged
|
// Join answers and options to retrieve answer based on questionId
|
||||||
const currentAnswer = await db
|
const currentAnswer = await db
|
||||||
.select({
|
.select({
|
||||||
isFlagged: answers.isFlagged,
|
isFlagged: answers.isFlagged,
|
||||||
|
answerId: answers.id,
|
||||||
})
|
})
|
||||||
.from(answers)
|
.from(answers)
|
||||||
.where(eq(answers.id, answerId))
|
.innerJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.where(eq(options.questionId, questionId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!currentAnswer.length) {
|
if (!currentAnswer.length) {
|
||||||
throw notFound(
|
throw notFound({
|
||||||
{
|
message: "Answer not found",
|
||||||
message: "Answer not found",
|
});
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle the isFlagged value
|
// Toggle the isFlagged value
|
||||||
|
|
@ -216,15 +408,13 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
.set({
|
.set({
|
||||||
isFlagged: newIsFlaggedValue,
|
isFlagged: newIsFlaggedValue,
|
||||||
})
|
})
|
||||||
.where(eq(answers.id, answerId))
|
.where(eq(answers.id, currentAnswer[0].answerId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!updatedAnswer.length) {
|
if (!updatedAnswer.length) {
|
||||||
throw notFound(
|
throw notFound({
|
||||||
{
|
message: "Failed to update answer",
|
||||||
message: "Failed to update answer",
|
});
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
|
|
@ -409,7 +599,7 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
// Get data for One Sub Aspect average score By Sub Aspect Id and Assessment Id
|
// Get data for One Sub Aspect average score By Sub Aspect Id and Assessment Id
|
||||||
.get(
|
.get(
|
||||||
'/average-score/sub-aspects/:subAspectId/assessments/:assessmentId',
|
'/average-score/sub-aspects/:subAspectId/assessments/:assessmentId',
|
||||||
checkPermission("assessments.readAverageSubAspect"),
|
// checkPermission("assessments.readAssessmentScore"),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { subAspectId, assessmentId } = c.req.param();
|
const { subAspectId, assessmentId } = c.req.param();
|
||||||
|
|
||||||
|
|
@ -437,10 +627,10 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get data for All Sub Aspects average score By Assessment Id
|
// Get data for All Sub Aspects average score By Assessment Id
|
||||||
.get(
|
.get(
|
||||||
'/average-score/sub-aspects/assessments/:assessmentId',
|
'/average-score/sub-aspects/assessments/:assessmentId',
|
||||||
checkPermission("assessments.readAverageAllSubAspects"),
|
// checkPermission("assessments.readAssessmentScore"),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { assessmentId } = c.req.param();
|
const { assessmentId } = c.req.param();
|
||||||
|
|
||||||
|
|
@ -474,7 +664,7 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
// Get data for One Aspect average score By Aspect Id and Assessment Id
|
// Get data for One Aspect average score By Aspect Id and Assessment Id
|
||||||
.get(
|
.get(
|
||||||
"/average-score/aspects/:aspectId/assessments/:assessmentId",
|
"/average-score/aspects/:aspectId/assessments/:assessmentId",
|
||||||
checkPermission("assessments.readAverageAspect"),
|
// checkPermission("assessments.readAverageAspect"),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { aspectId, assessmentId } = c.req.param();
|
const { aspectId, assessmentId } = c.req.param();
|
||||||
|
|
||||||
|
|
@ -506,7 +696,7 @@ const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
// Get data for All Aspects average score By Assessment Id
|
// Get data for All Aspects average score By Assessment Id
|
||||||
.get(
|
.get(
|
||||||
'/average-score/aspects/assessments/:assessmentId',
|
'/average-score/aspects/assessments/:assessmentId',
|
||||||
checkPermission("assessments.readAverageAllAspects"),
|
// checkPermission("assessments.readAssessmentScore"),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { assessmentId } = c.req.param();
|
const { assessmentId } = c.req.param();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export default function AppNavbar() {
|
||||||
// const {user} = useAuth();
|
// const {user} = useAuth();
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const pathsThatCloseSidebar = ["/assessmentResult"];
|
const pathsThatCloseSidebar = ["/assessmentResult", "/assessment"];
|
||||||
|
|
||||||
const [isSidebarOpen, setSidebarOpen] = useState(true);
|
const [isSidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
|
|
@ -60,6 +60,12 @@ export default function AppNavbar() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname === "/assessment"){
|
||||||
|
setSidebarOpen(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { InferRequestType } from "hono";
|
||||||
|
|
||||||
|
// Query untuk mendapatkan skor assessment saat ini
|
||||||
|
export const getCurrentAssessmentScoreQueryOptions = (assessmentId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["assessment", { assessmentId }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments.getCurrentAssessmentScore.$get({
|
||||||
|
query: {
|
||||||
|
assessmentId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchAspects = async () => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments.aspect.$get({
|
||||||
|
query: {}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query untuk mendapatkan semua pertanyaan berdasarkan halaman dan limit
|
||||||
|
export const getQuestionsAllQueryOptions = (page: number, limit: number, q?: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["assessment", { page, limit, q }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments.getAllQuestions.$get({
|
||||||
|
query: {
|
||||||
|
limit: String(limit),
|
||||||
|
page: String(page),
|
||||||
|
q: q || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query untuk mendapatkan jawaban berdasarkan assessment ID
|
||||||
|
export const getAnswersQueryOptions = (assessmentId: string, page: number, limit: number, q?: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["assessment", { assessmentId, page, limit, q }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments.getAnswers.$get({
|
||||||
|
query: {
|
||||||
|
assessmentId,
|
||||||
|
limit: String(limit),
|
||||||
|
page: String(page),
|
||||||
|
q: q || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query untuk toggle flag jawaban berdasarkan questionId
|
||||||
|
export const toggleFlagAnswer = async (questionId: string) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments[":questionId"].toggleFlag.$patch({
|
||||||
|
param: { questionId }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opsional: Jika Anda ingin menggunakan react-query untuk toggleFlag
|
||||||
|
export const toggleFlagAnswerMutationOptions = (questionId: string) => ({
|
||||||
|
mutationFn: () => toggleFlagAnswer(questionId),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Di file queries (sesuaikan tipe yang diperlukan untuk submitAnswer)
|
||||||
|
export const submitAnswer = async (
|
||||||
|
form: {
|
||||||
|
optionId: string;
|
||||||
|
assessmentId: string;
|
||||||
|
validationInformation: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments.submitAnswer.$post({
|
||||||
|
json: form,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Opsional: Jika Anda ingin menggunakan react-query untuk submitAnswer
|
||||||
|
export const submitAnswerMutationOptions = () => ({
|
||||||
|
mutationFn: submitAnswer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query untuk mendapatkan rata-rata skor berdasarkan aspectId dan assessmentId
|
||||||
|
export const getAverageScoreQueryOptions = (assessmentId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["averageScore", { assessmentId }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments["average-score"].aspects.assessments[":assessmentId"].$get({
|
||||||
|
param: {
|
||||||
|
assessmentId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAverageScoreSubAspectQueryOptions = (assessmentId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["averageScoreSubAspects", { assessmentId }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments["average-score"]["sub-aspects"].assessments[":assessmentId"].$get({
|
||||||
|
param: {
|
||||||
|
assessmentId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,543 @@
|
||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Flex,
|
||||||
|
Pagination,
|
||||||
|
Stack,
|
||||||
|
Radio,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
Loader,
|
||||||
|
ActionIcon,
|
||||||
|
CloseButton,
|
||||||
|
Group,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getAnswersQueryOptions,
|
||||||
|
getAverageScoreSubAspectQueryOptions,
|
||||||
|
getAverageScoreQueryOptions,
|
||||||
|
fetchAspects,
|
||||||
|
submitAnswerMutationOptions,
|
||||||
|
getQuestionsAllQueryOptions,
|
||||||
|
toggleFlagAnswer,
|
||||||
|
} from "@/modules/assessmentManagement/queries/assessmentQueries";
|
||||||
|
import { TbFlagFilled, TbUpload, TbChevronRight, TbChevronUp } from "react-icons/tb";
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
const getQueryParam = (param: string) => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get(param);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/_dashboardLayout/assessment/")({
|
||||||
|
component: AssessmentPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ToggleFlagResponse {
|
||||||
|
message: string;
|
||||||
|
answer: {
|
||||||
|
id: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
optionId: string | null;
|
||||||
|
assessmentId: string | null;
|
||||||
|
isFlagged: boolean | null;
|
||||||
|
filename: string | null;
|
||||||
|
validationInformation: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssessmentPage() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const limit = 10;
|
||||||
|
const questionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const [flaggedQuestions, setFlaggedQuestions] = useState<{
|
||||||
|
[key: string]: boolean;
|
||||||
|
}>({});
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [selectedSubAspectId, setSelectedSubAspectId] = useState<string | null>(null);
|
||||||
|
const [assessmentId, setAssessmentId] = useState<string | null>(null);
|
||||||
|
const [answers, setAnswers] = useState<{ [key: string]: string }>({});
|
||||||
|
|
||||||
|
// Fetch aspects and sub-aspects
|
||||||
|
const aspectsQuery = useQuery({
|
||||||
|
queryKey: ["aspects"],
|
||||||
|
queryFn: fetchAspects,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetching questions data using useQuery
|
||||||
|
const { data, isLoading, isError, error } = useQuery(
|
||||||
|
getQuestionsAllQueryOptions(page, limit)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = getQueryParam("id");
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
// Handle if no ID found
|
||||||
|
setAssessmentId(null);
|
||||||
|
} else {
|
||||||
|
setAssessmentId(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if aspectsQuery.data and aspectsQuery.data.data are defined
|
||||||
|
if (aspectsQuery.data?.data && aspectsQuery.data.data.length > 0) {
|
||||||
|
// If no sub-aspect is selected, find a suitable default
|
||||||
|
if (selectedSubAspectId === null) {
|
||||||
|
const firstMatchingSubAspect = aspectsQuery.data.data
|
||||||
|
.flatMap(aspect => aspect.subAspects) // Get all sub-aspects
|
||||||
|
.find(subAspect => data?.data.some(question => question.subAspectId === subAspect.id)); // Filter based on available questions
|
||||||
|
|
||||||
|
if (firstMatchingSubAspect) {
|
||||||
|
setSelectedSubAspectId(firstMatchingSubAspect.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [aspectsQuery.data, selectedSubAspectId, data?.data]);
|
||||||
|
|
||||||
|
// Tambahkan state untuk aspek yang terbuka
|
||||||
|
const [openAspects, setOpenAspects] = useState<{ [key: string]: boolean }>({});
|
||||||
|
|
||||||
|
const toggleAspect = (aspectId: string) => {
|
||||||
|
setOpenAspects((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[aspectId]: !prev[aspectId], // Toggle state untuk aspek yang diklik
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch average scores berdasarkan assessmentId yang diambil dari URL
|
||||||
|
const averageScoreQuery = useQuery(
|
||||||
|
getAverageScoreQueryOptions(assessmentId || "")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch average scores for sub-aspects
|
||||||
|
const averageScoreSubAspectQuery = useQuery(
|
||||||
|
getAverageScoreSubAspectQueryOptions(assessmentId || "")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mutation function to toggle flag
|
||||||
|
const toggleFlagMutation = useMutation<ToggleFlagResponse, Error, string>({
|
||||||
|
mutationFn: toggleFlagAnswer,
|
||||||
|
onSuccess: (response) => {
|
||||||
|
if (response && response.answer) {
|
||||||
|
const { answer } = response;
|
||||||
|
setFlaggedQuestions((prevFlags) => ({
|
||||||
|
...prevFlags,
|
||||||
|
[answer.id]: answer.isFlagged !== null ? answer.isFlagged : false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Error toggling flag:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inside the AssessmentPage function:
|
||||||
|
const submitAnswerMutation = useMutation(submitAnswerMutationOptions());
|
||||||
|
|
||||||
|
const handleAnswerChange = (optionId: string) => {
|
||||||
|
submitAnswerMutation.mutate({
|
||||||
|
optionId: optionId,
|
||||||
|
assessmentId: assessmentId || "",
|
||||||
|
validationInformation: "someValidationInfo", // Sesuaikan validasi yang relevan
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and Drop handlers
|
||||||
|
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = () => {
|
||||||
|
setDragActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
const droppedFiles = Array.from(event.dataTransfer.files);
|
||||||
|
setFiles((prevFiles) => [...prevFiles, ...droppedFiles]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (event.target.files) {
|
||||||
|
const fileArray = Array.from(event.target.files); // Ubah ke array hanya jika files tidak null
|
||||||
|
setFiles((prevFiles) => [...prevFiles, ...fileArray]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (fileIndex: number) => {
|
||||||
|
setFiles((prevFiles) =>
|
||||||
|
prevFiles.filter((_, index) => index !== fileIndex)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to scroll to the specific question
|
||||||
|
const scrollToQuestion = (questionId: string) => {
|
||||||
|
const questionElement = questionRefs.current[questionId];
|
||||||
|
if (questionElement) {
|
||||||
|
questionElement.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle pagination
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
setPage(newPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render conditions
|
||||||
|
if (isLoading) {
|
||||||
|
return <Loader />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Text color="red">
|
||||||
|
Error: {error?.message || "Terjadi kesalahan saat memuat pertanyaan."}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalQuestions = data?.data?.length || 0;
|
||||||
|
const totalPages = Math.ceil(totalQuestions / limit);
|
||||||
|
const averageScores = averageScoreQuery.data?.aspects;
|
||||||
|
const averageScoresSubAspect = averageScoreSubAspectQuery.data?.subAspects;
|
||||||
|
|
||||||
|
if (!assessmentId) {
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" p="lg" radius="md" withBorder>
|
||||||
|
<Text color="red" className="text-center">
|
||||||
|
Error: Data Asesmen tidak ditemukan. Harap akses halaman melalui link yang valid.
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = (page - 1) * limit;
|
||||||
|
const endIndex = startIndex + limit;
|
||||||
|
const paginatedQuestions = data?.data.slice(startIndex, endIndex) || [];
|
||||||
|
|
||||||
|
const filteredQuestions = paginatedQuestions.filter((question) => {
|
||||||
|
return question.subAspectId === selectedSubAspectId; // Misalnya, jika `question` memiliki `subAspectId`
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" p="lg" radius="md" withBorder>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text className="text-2xl font-bold">
|
||||||
|
Harap menjawab semua pertanyaan yang tersedia
|
||||||
|
</Text>
|
||||||
|
<Text className="text-gray-400">Semua jawaban Anda akan ditinjau</Text>
|
||||||
|
|
||||||
|
<Flex justify="space-between" align="flex-start" mt="lg">
|
||||||
|
|
||||||
|
{/* LEFT-SIDE */}
|
||||||
|
{/* Aspek dan Sub-Aspek */}
|
||||||
|
<Flex direction="column" gap="xs" className="mr-4 w-52">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{aspectsQuery.data?.data
|
||||||
|
.filter((aspect) =>
|
||||||
|
aspect.subAspects.some((subAspect) =>
|
||||||
|
data?.data.some((question) => question.subAspectId === subAspect.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map((aspect) => (
|
||||||
|
<div
|
||||||
|
key={aspect.id}
|
||||||
|
className="p-4 bg-gray-50 rounded-lg shadow-md"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex justify-between cursor-pointer"
|
||||||
|
onClick={() => toggleAspect(aspect.id)}
|
||||||
|
>
|
||||||
|
<div className="text-lg text-gray-700">{aspect.name}</div>
|
||||||
|
<div>
|
||||||
|
{openAspects[aspect.id] ? (
|
||||||
|
<TbChevronUp size={25} />
|
||||||
|
) : (
|
||||||
|
<TbChevronRight size={25} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openAspects[aspect.id] && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{aspect.subAspects
|
||||||
|
.filter((subAspect) =>
|
||||||
|
data?.data.some((question) => question.subAspectId === subAspect.id)
|
||||||
|
)
|
||||||
|
.map((subAspect) => (
|
||||||
|
<div
|
||||||
|
key={subAspect.id}
|
||||||
|
className={`flex justify-between text-gray-600 cursor-pointer ${selectedSubAspectId === subAspect.id ? 'font-bold' : ''}`}
|
||||||
|
onClick={() => setSelectedSubAspectId(subAspect.id)}
|
||||||
|
>
|
||||||
|
<div>{subAspect.name}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
{/* Pertanyaan */}
|
||||||
|
<Stack gap="sm" style={{ flex: 1 }}>
|
||||||
|
{filteredQuestions.length === 0 ? (
|
||||||
|
<Text color="black" className="text-center p-3">
|
||||||
|
Pertanyaan tidak ada untuk sub-aspek yang dipilih.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
filteredQuestions.map((question: any, index: number) => {
|
||||||
|
const questionId = question.questionId;
|
||||||
|
if (!questionId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={questionId}
|
||||||
|
shadow="sm"
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
ref={(el) => (questionRefs.current[questionId] = el)}
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Flex justify="space-between" align="flex-start" style={{ width: "100%" }}>
|
||||||
|
<Text
|
||||||
|
className="font-bold"
|
||||||
|
style={{
|
||||||
|
flexGrow: 1,
|
||||||
|
wordBreak: "break-word",
|
||||||
|
marginRight: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{startIndex + index + 1}. {question.questionText}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
onClick={() => {
|
||||||
|
setFlaggedQuestions((prevFlags) => ({
|
||||||
|
...prevFlags,
|
||||||
|
[questionId]: !prevFlags[questionId],
|
||||||
|
}));
|
||||||
|
toggleFlagMutation.mutate(questionId);
|
||||||
|
}}
|
||||||
|
title="Tandai"
|
||||||
|
color={flaggedQuestions[questionId] ? "red" : "white"}
|
||||||
|
style={{
|
||||||
|
border: "1px gray solid ",
|
||||||
|
borderRadius: "4px",
|
||||||
|
backgroundColor: flaggedQuestions[questionId] ? "red" : "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TbFlagFilled
|
||||||
|
size={20}
|
||||||
|
color={flaggedQuestions[questionId] ? "white" : "black"}
|
||||||
|
style={{
|
||||||
|
padding: "2px",
|
||||||
|
}}
|
||||||
|
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{question.options?.length > 0 ? (
|
||||||
|
<Radio.Group>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{question.options.map((option: any) => (
|
||||||
|
<label
|
||||||
|
key={option.optionId}
|
||||||
|
className="bg-gray-200 border rounded-lg p-4 cursor-pointer transition-transform transform hover:scale-105 shadow-md hover:shadow-lg flex items-center"
|
||||||
|
onClick={() => document.getElementById(option.optionId)?.click()}
|
||||||
|
>
|
||||||
|
<Radio
|
||||||
|
id={option.optionId}
|
||||||
|
className="font-bold"
|
||||||
|
value={option.optionId}
|
||||||
|
label={option.optionText}
|
||||||
|
size="md"
|
||||||
|
radius="xl"
|
||||||
|
style={{ pointerEvents: "none" }}
|
||||||
|
onChange={() => {
|
||||||
|
submitAnswerMutation.mutate({
|
||||||
|
optionId: option.optionId,
|
||||||
|
assessmentId: assessmentId || "",
|
||||||
|
validationInformation: JSON.stringify({
|
||||||
|
info: "jfjforjfocn",
|
||||||
|
questionId: question.questionId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Radio.Group>
|
||||||
|
) : (
|
||||||
|
<Text color="red">Tidak ada opsi untuk pertanyaan ini.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Textarea placeholder="Berikan keterangan terkait jawaban di atas" />
|
||||||
|
|
||||||
|
{/* File Upload */}
|
||||||
|
{question.needFile === true && (
|
||||||
|
<div
|
||||||
|
className={`pt-5 pb-5 pr-5 pl-2 border-2 border-dashed ${dragActive ? "bg-gray-100" : "bg-transparent"
|
||||||
|
} shadow-lg`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<Flex align="center" justify="space-between" gap="sm">
|
||||||
|
<TbUpload
|
||||||
|
size={24}
|
||||||
|
style={{ marginLeft: "8px", marginRight: "8px" }}
|
||||||
|
/>
|
||||||
|
<div className="flex-grow text-right">
|
||||||
|
<Text className="font-bold">
|
||||||
|
Klik untuk unggah atau geser file disini
|
||||||
|
</Text>
|
||||||
|
<Text className="text-sm text-gray-400">
|
||||||
|
PNG, JPG, PDF
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
accept="image/png, image/jpeg, application/pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<Stack gap="sm" mt="sm">
|
||||||
|
<Text className="font-bold">File yang diunggah:</Text>
|
||||||
|
{files.map((file, fileIndex) => (
|
||||||
|
<Group key={fileIndex} align="center">
|
||||||
|
<Text>{file.name}</Text>
|
||||||
|
<CloseButton
|
||||||
|
title="Hapus file"
|
||||||
|
onClick={() => handleRemoveFile(fileIndex)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Navigasi dan Pagination */}
|
||||||
|
<Flex direction="column" gap="xs" className="ml-4">
|
||||||
|
|
||||||
|
{/* Navigasi (Number of Questions) */}
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{filteredQuestions.map((question, index) => {
|
||||||
|
const questionId = question.questionId;
|
||||||
|
if (!questionId) return null;
|
||||||
|
|
||||||
|
// Menentukan nomor soal berdasarkan indeks pertanyaan yang difilter
|
||||||
|
const questionNumber = index + 1; // Nomor pertanyaan dimulai dari 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={questionId} className="flex justify-center relative">
|
||||||
|
<button
|
||||||
|
className={`w-10 h-10 border rounded-lg flex items-center justify-center relative
|
||||||
|
${flaggedQuestions[questionId] ? "text-black" : "bg-transparent text-black"}`}
|
||||||
|
onClick={() => scrollToQuestion(questionId)}
|
||||||
|
>
|
||||||
|
{questionNumber} {/* Menampilkan nomor pertanyaan yang sudah difilter */}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{flaggedQuestions[questionId] && (
|
||||||
|
<div className="absolute top-0 right-0 w-0 h-0 border-b-[20px] border-b-transparent border-r-[20px] border-r-black rounded-e-md" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Pagination
|
||||||
|
value={page}
|
||||||
|
total={Math.ceil(totalQuestions / limit)}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skor Aspek dan Sub-Aspek */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Card shadow="sm" p="md" radius="md" withBorder>
|
||||||
|
<Stack>
|
||||||
|
{averageScores && averageScores.length > 0 ? (
|
||||||
|
averageScores.map((aspect: any) => (
|
||||||
|
<div key={aspect.AspectId} className="flex justify-between items-center">
|
||||||
|
<Text className="text-xl text-gray-400">
|
||||||
|
{aspect.AspectName}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xl font-bold">
|
||||||
|
{parseFloat(aspect.averageScore).toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text>Tidak ada data skor.</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Garis pembatas */}
|
||||||
|
<div>
|
||||||
|
<hr className="border-t-2 border-gray-300 w-full mx-auto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{averageScoresSubAspect && averageScoresSubAspect.length > 0 && (
|
||||||
|
<Stack>
|
||||||
|
{averageScoresSubAspect.map((subAspects: any) => {
|
||||||
|
return (
|
||||||
|
<div key={subAspects.subAspectId} className="flex justify-between items-center">
|
||||||
|
<Text className="text-lg text-gray-400">
|
||||||
|
{subAspects.subAspectName}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-lg font-bold">
|
||||||
|
{parseFloat(subAspects.averageScore).toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Tombol Selesai */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<button className="bg-gray-200 text-black font-bold py-2 w-full">
|
||||||
|
Selesai
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { getQuestionsAllQueryOptions } from "@/modules/assessmentManagement/queries/assessmentQueries.ts";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const searchParamSchema = z.object({
|
||||||
|
create: z.boolean().default(false).optional(),
|
||||||
|
edit: z.string().default("").optional(),
|
||||||
|
delete: z.string().default("").optional(),
|
||||||
|
detail: z.string().default("").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_dashboardLayout/assessment/")({
|
||||||
|
validateSearch: searchParamSchema,
|
||||||
|
|
||||||
|
loader: ({ context: { queryClient } }) => {
|
||||||
|
queryClient.ensureQueryData(getQuestionsAllQueryOptions(0, 10));
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user