diff --git a/apps/backend/src/routes/assessmentResult/route.ts b/apps/backend/src/routes/assessmentResult/route.ts index d253926..575e1e8 100644 --- a/apps/backend/src/routes/assessmentResult/route.ts +++ b/apps/backend/src/routes/assessmentResult/route.ts @@ -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() .use(authInfo) @@ -27,7 +43,7 @@ const assessmentRoute = new Hono() requestValidator( "query", z.object({ - withMetadata: z + withMetadata: z .string() .optional() .transform((v) => v?.toLowerCase() === "true"), @@ -47,12 +63,16 @@ const assessmentRoute = new Hono() .leftJoin(users, eq(respondents.userId, users.id)) .where( and( - q - ? or( - ilike(users.name, q), - ilike(respondents.companyName, q), + or( + q ? or( + ilike(users.name, q), + ilike(respondents.companyName, q) + ) : undefined, + ), + or( + eq(assessments.status, 'belum diverifikasi'), + eq(assessments.status, 'selesai') ) - : undefined ) ); @@ -67,7 +87,7 @@ const assessmentRoute = new Hono() WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi' ELSE 'belum diverifikasi' END` - .as("statusVerification"), + .as("statusVerification"), assessmentsResult: sql` (SELECT ROUND(AVG(${options.score}), 2) FROM ${answers} @@ -76,25 +96,36 @@ const assessmentRoute = new Hono() JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId} JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId} WHERE ${answers.assessmentId} = ${assessments.id})` - .as("assessmentsResult"), + .as("assessmentsResult"), }) .from(assessments) .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) - .leftJoin(users, eq(respondents.userId, users.id)) + .leftJoin(users, eq(respondents.userId, users.id)) .where( and( - q - ? or( - ilike(users.name, q), - ilike(respondents.companyName, q), + 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; - const totalCount = totalCountResult[0]?.count || 0; + const totalCount = totalCountResult[0]?.count || 0; return c.json({ data: result, _metadata: { @@ -116,22 +147,22 @@ const assessmentRoute = new Hono() 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, + 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` CASE WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi' ELSE 'belum diverifikasi' END` - .as("statusVerification"), + .as("statusVerification"), assessmentsResult: sql` (SELECT ROUND(AVG(${options.score}), 2) FROM ${answers} @@ -140,7 +171,55 @@ const assessmentRoute = new Hono() JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId} JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId} WHERE ${answers.assessmentId} = ${assessments.id})` - .as("assessmentsResult"), + .as("assessmentsResult"), + }) + .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( + "/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` + CASE + WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi' + ELSE 'belum diverifikasi' + END`.as("statusVerification"), + verifiedAssessmentsResult: sql` + (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)) @@ -169,7 +248,7 @@ const assessmentRoute = new Hono() message: "Assessment ID is missing", }); } - + // Total count of options related to the assessment const totalCountQuery = sql` SELECT count(*) @@ -181,7 +260,7 @@ const assessmentRoute = new Hono() WHERE ${questions.deletedAt} IS NULL AND ${answers.assessmentId} = ${assessmentId} `; - + // Query to get detailed information about options const result = await db .select({ @@ -202,7 +281,7 @@ const assessmentRoute = new Hono() .leftJoin(aspects, eq(subAspects.aspectId, aspects.id)) .leftJoin(answers, eq(options.id, answers.optionId)) .where(sql`${questions.deletedAt} IS NULL AND ${answers.assessmentId} = ${assessmentId}`); - + // Execute the total count query const totalCountResult = await db.execute(totalCountQuery); @@ -211,95 +290,285 @@ const assessmentRoute = new Hono() message: "Data does not exist", }); } - + return c.json({ data: result, totalCount: totalCountResult[0]?.count || 0 }); } ) - - // POST Endpoint for creating a new answer revision - .post( - "/answer-revisions", - checkPermission("assessmentResult.create"), - requestValidator( - "json", - z.object({ - answerId: z.string(), - newOptionId: z.string(), - revisedBy: z.string(), - newValidationInformation: z.string(), - }) - ), + + .get( + '/average-score/sub-aspects/assessments/:assessmentId', + checkPermission("assessments.readAssessmentScore"), async (c) => { - const { answerId, newOptionId, revisedBy, newValidationInformation } = c.req.valid("json"); + const { assessmentId } = c.req.param(); - // Check if the answer exists - const existingAnswer = await db - .select() - .from(answers) - .where(eq(answers.id, answerId)); - - if (!existingAnswer.length) { - throw notFound({ - message: "Answer not found", - }); - } - - // Insert new revision - const [newRevision] = await db - .insert(answerRevisions) - .values({ - answerId, - newOptionId, - revisedBy, - newValidationInformation + const averageScores = await db + .select({ + aspectId: subAspects.aspectId, + subAspectId: subAspects.id, + subAspectName: subAspects.name, + average: sql`AVG(options.score)` }) - .returning(); + .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( - { - message: "Answer revision created successfully", - data: newRevision - }, - 201 - ); + 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`(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({ + assessmentId: z.string(), + revisedBy: z.string(), // assuming this will come from the session or auth context + }) + ), + async (c) => { + const { assessmentId, revisedBy } = c.req.valid("json"); + + // Fetch answers related to the given assessmentId + const existingAnswers = await db + .select() + .from(answers) + .where(eq(answers.assessmentId, assessmentId)); + + if (!existingAnswers.length) { + throw notFound({ + message: "No answers found for the given assessment ID", + }); + } + + // 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(revisions) + .returning(); + + return c.json( + { + message: "Answer revisions created successfully", + data: newRevisions, + }, + 201 + ); + } +) + .patch( "/:id", checkPermission("assessmentResult.update"), - async (c) => { - const assessmentId = c.req.param("id"); - - if (!assessmentId) { - throw notFound({ - message: "Assessment tidak ada", - }); - } - - const assessment = await db - .select() - .from(assessments) - .where(and(eq(assessments.id, assessmentId))); - - if (!assessment[0]) throw notFound(); - - await db - .update(assessments) - .set({ - verifiedAt: new Date(), - }) - .where(eq(assessments.id, assessmentId)); - console.log("Verified Success"); + async (c) => { + const assessmentId = c.req.param("id"); - return c.json({ - message: "Assessment berhasil diverifikasi", - data: assessment, + if (!assessmentId) { + throw notFound({ + message: "Assessment tidak ada", }); } + + const assessment = await db + .select() + .from(assessments) + .where(and(eq(assessments.id, assessmentId))); + + if (!assessment[0]) throw notFound(); + + await db + .update(assessments) + .set({ + verifiedAt: new Date(), + }) + .where(eq(assessments.id, assessmentId)); + console.log("Verified Success"); + + return c.json({ + message: "Assessment berhasil diverifikasi", + data: assessment, + }); + } ) .patch( @@ -331,6 +600,53 @@ const assessmentRoute = new Hono() 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; diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index 6162149..7bd7f0c 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -935,6 +935,50 @@ const assessmentsRoute = new Hono() } ) + .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 + ); + } + ) + .patch( "/updateOption", checkPermission("assessments.submitOption"), diff --git a/apps/frontend/src/components/AppNavbar.tsx b/apps/frontend/src/components/AppNavbar.tsx index 2c4a59e..e184bae 100644 --- a/apps/frontend/src/components/AppNavbar.tsx +++ b/apps/frontend/src/components/AppNavbar.tsx @@ -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 = () => { diff --git a/apps/frontend/src/modules/assessmentResult/queries/assessmentResultQueries.ts b/apps/frontend/src/modules/assessmentResult/queries/assessmentResultQueries.ts index 6f32c83..814122c 100644 --- a/apps/frontend/src/modules/assessmentResult/queries/assessmentResultQueries.ts +++ b/apps/frontend/src/modules/assessmentResult/queries/assessmentResultQueries.ts @@ -29,4 +29,31 @@ export const getAllAspectsAverageScore = (assessmentId: string | undefined) => ), enabled: Boolean(assessmentId), }); - \ No newline at end of file + +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), + }); \ No newline at end of file diff --git a/apps/frontend/src/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries.ts b/apps/frontend/src/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries.ts index 0b4bb14..06f0062 100644 --- a/apps/frontend/src/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries.ts +++ b/apps/frontend/src/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries.ts @@ -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) => - fetchRPC( - client.assessmentResult[":id"].$patch({ - param: { - id: assessmentResultId, - }, - }) - ); - \ No newline at end of file + +export const getVerifiedAssessmentResultByIdQueryOptions = (assessmentResultId: string | undefined) => + queryOptions({ + queryKey: ["verifiedAssessmentResult", assessmentResultId], + queryFn: () => + fetchRPC( + client.assessmentResult.verified[":id"].$get({ + param: { + 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), +}); \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.lazy.tsx index 04ecde3..a210190 100644 --- a/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.lazy.tsx @@ -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(undefined); @@ -34,251 +43,613 @@ 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 formatScore = (score: string | number | undefined) => { - const parsedScore = typeof score === 'number' ? score : parseFloat(score || "NaN"); - return !isNaN(parsedScore) ? parsedScore.toFixed(2) : 'N/A'; // Mengembalikan 'N/A' jika bukan angka + const getAspectScore = (aspectId: string) => { + return allAspectsScoreData?.aspects?.find((score) => score.aspectId === aspectId)?.averageScore || undefined; }; - const totalScore = formatScore(assessmentResult?.assessmentsResult); + const getSubAspectScore = (subAspectId: string) => { + return allSubAspectsScoreData?.subAspects?.find((score) => score.subAspectId === subAspectId)?.averageScore || undefined; + }; - const blueColors = [ - "hsl(220, 100%, 50%)", - "hsl(220, 80%, 60%)", - "hsl(220, 60%, 70%)", - "hsl(220, 40%, 80%)", - "hsl(220, 20%, 90%)", + 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) : '0'; + }; + + const totalScore = parseFloat(formatScore(assessmentResult?.assessmentsResult)); + const totalVerifiedScore = parseFloat(formatScore(verifiedAssessmentResult?.verifiedAssessmentsResult)); + + 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) || {}; - return ( - -
-

Cyber Security Maturity Level Dashboard

-

Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah

-
+ const customizedAxisTick = (props: any) => { + const { x, y, payload } = props; + return ( + + + {payload.value} + + + ); + }; - {/* Score table */} - -
- {allAspectsData?.aspects.map((aspect) => ( -
-
-

{aspect.aspectName}

- {formatScore(aspect.averageScore)} -
- {allSubAspectsData?.subAspects.map((subAspect) => { - if (subAspect.aspectId === aspect.aspectId) { + 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 + } + setIsOpen(false); // Menutup dropdown setelah item dipilih + }; + + // Pie Chart Component + function PieChartComponent({ chartData, totalScore, chartConfig }: { chartData: { aspectName: string, score: number, fill: string }[], totalScore: number, chartConfig: ChartConfig }) { + return ( +
+
+ + + } + /> + { + const radius = innerRadius + (outerRadius - innerRadius) * 0.5; + const x = cx + radius * Math.cos(-midAngle * (Math.PI / 180)); + const y = cy + radius * Math.sin(-midAngle * (Math.PI / 180)); return ( -
-
-

{subAspect.subAspectName}

- {formatScore(subAspect.averageScore)} -
+ + {chartData[index]?.score || ""} + + ); + }} + labelLine={false} + > +
+ + {/* Legend */} +
+ {chartData.map((entry, index) => ( +
+ + {entry.aspectName} +
+ ))} +
+
+ ); + } + + function RadarChartComponent({ chartData, chartConfig }: { chartData: { aspectName: string, score: number }[], chartConfig: ChartConfig }) { + return ( +
+ + + { + if (active && payload && payload.length > 0) { + const { aspectName, score } = payload[0].payload; + return ( +
+

{`${aspectName} : ${score}`}

); } return null; - })} + }} + /> + + + + +
+
+
+ ); + } + + function BarChartComponent({ barChartData, barChartConfig }: { barChartData: { subAspectName: string, score: number, fill: string, aspectId: string, aspectName: string }[], barChartConfig: ChartConfig }) { + return ( +
+ + + + + + { + if (active && payload && payload.length > 0) { + const { subAspectName, score } = payload[0].payload; + return ( +
+

{`${subAspectName} : ${score}`}

+
+ ); + } + return null; + }} + /> + +
+
+
+ ); + } + + + return ( + +
+

Tingkatan Level Maturitas

+
+ {[ + { 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) => ( +
0 ? '-mt-10' : ''}`}> + + + + Level {level} + + +
+

{title}

+ {details.map((detail) => ( +

{detail}

+ ))} +
))}
+
- {/* nilai keseluruhan */} -
-

Level Muturitas:

- {totalScore} -
-
- - - {/* Pie Chart */} - - - - - } - /> - { - const radius = innerRadius + (outerRadius - innerRadius) * 0.5; - const x = cx + radius * Math.cos(-midAngle * (Math.PI / 180)); - const y = cy + radius * Math.sin(-midAngle * (Math.PI / 180)); - return ( - - {chartData[index]?.score || ""} - - ); - }} - labelLine={false} + +
+ {/* Konten Header */} +
+ {isSuperAdmin ? ( +
+ +

Detail Hasil Asesmen

+

Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah

- )) || []} - + ) : ( +
+

Cyber Security Maturity Level Dashboard

+

Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah

+
+ )} + + {/* Dropdown */} +
+ + + {selectedItem} + {isOpen ? ( + + ) : ( + + )} + + {isOpen && ( + + + {selectedItem === 'Hasil Sementara' ? 'Hasil Terverifikasi' : 'Hasil Sementara'} + + + )} + +
+
+
+ + {isSuperAdmin && + +
+
+
+
+

{assessmentResult?.respondentName}

+

{assessmentResult?.position}

+
+
+
+
+
+

Username

+

{assessmentResult?.username}

+
+
+

Email

+

{assessmentResult?.email}

+
+
+
+
+

Nama Perusahaan

+

{assessmentResult?.companyName}

+
+
+

Pengalaman Kerja

+

{assessmentResult?.workExperience}

+
+
+
+
+

No. HP

+

{assessmentResult?.phoneNumber}

+
+
+

Alamat

+

{assessmentResult?.address}

+
+
+
+
+

Tanggal Assessment

+

+ {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' + )} +

+
+
+

Status Verifikasi

+

{assessmentResult?.statusAssessment}

+
+
+
+
+ } + + {/* Conditional rendering based on selectedItem */} + {selectedItem === 'Hasil Sementara' ? ( + <> + {/* Score Table */} +

Tabel Level Maturitas

+ +
+ {aspectsData?.data?.map((aspect) => ( +
+
+

{aspect.name}

+ {formatScore(getAspectScore(aspect.id))} +
+ {aspect.subAspects.map((subAspect) => ( +
+
+

{subAspect.name}

+ {formatScore(getSubAspectScore(subAspect.id))} +
+
+ ))} +
+ ))} +
+ + {/* Total score */} +
+

Level Maturitas:

+ {totalScore} +
+
+ + ) : ( + <> + {/* Verified Result Table */} +

Tabel Level Maturitas Terverifikasi

+ +
+ {aspectsData?.data?.map((aspect) => ( +
+
+

{aspect.name}

+ {formatScore(getVerifiedAspectScore(aspect.id))} +
+ {aspect.subAspects.map((subAspect) => ( +
+
+

{subAspect.name}

+ {formatScore(getVerifiedSubAspectScore(subAspect.id))} +
+
+ ))} +
+ ))} +
+ + {/* Total verified score */} +
+

Level Maturitas:

+ {totalVerifiedScore} +
+
+ + )} + + + {/* Pie Chart */} + {selectedItem === 'Hasil Sementara' ? ( + <> + + + Diagram Lingkaran + + + + + + + ) : ( + <> + + + Diagram Lingkaran + + + + + + + )} + {/* Radar Chart */} + {selectedItem === 'Hasil Sementara' ? ( + <> + + + Diagram Radar + + + + + + + ) : ( + <> + + + Diagram Radary + + + + + + + )} - {/* Radar Chart */} - - - - - { - if (active && payload && payload.length > 0) { - const { aspectName, score } = payload[0].payload; - return ( -
-

{`${aspectName} : ${score}`}

-
- ); - } - return null; - }} - /> - - - - -
-
-
-
-
- - - {/* Bar Chart */} - - - - - - value.slice(0,3)} - tick={{ textAnchor: 'start' }} - /> - - { - if (active && payload && payload.length > 0) { - const { subAspectName, score } = payload[0].payload; // Ambil data dari payload - return ( -
-

{`${subAspectName} : ${score}`}

-
- ); - } - return null; - }} - /> - -
-
-
+ + {/* Bar Chart */} + {selectedItem === 'Hasil Sementara' ? ( + <> + + + Diagram Batang + + + + + + + ) : ( + <> + + + Diagram Batang + + + + + + + )}
diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.lazy.tsx index 6b687bc..38b16a8 100644 --- a/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.lazy.tsx @@ -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; const columnHelper = createColumnHelper(); -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 : ''; + + // Use the mutation defined in the queries file + const mutation = postAnswerRevisionMutation(); - const handleVerifyClick = (assessmentId: string) => { - verifyMutation.mutate(assessmentId); + 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 ( 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: () =>
Status Verifikasi
, + cell: (props) => { + const status = props.row.original.statusAssessments; + switch (status) { + case "belum diverifikasi": + return
+ Belum Diverifikasi +
; + case "selesai": + return
+ Selesai +
; + default: + } + }, }), columnHelper.display({ header: "Hasil Assessment", cell: (props) => props.row.original.assessmentsResult, }), columnHelper.display({ - header: "Action", + header: " ", cell: (props) => ( - +
+ {props.row.original.statusAssessments === 'belum diverifikasi' && ( + + )} +
), }), columnHelper.display({ - header: " ", + header: "Action", cell: (props) => ( - - {createActionButtons([ - { - label: "Detail", - permission: true, - action: `?detail=${props.row.original.id}`, - color: "black", - icon: , - }, - ])} - +
+ +
), }), ]} diff --git a/apps/frontend/src/routes/_verifyingLayout.tsx b/apps/frontend/src/routes/_verifyingLayout.tsx new file mode 100644 index 0000000..c3ec490 --- /dev/null +++ b/apps/frontend/src/routes/_verifyingLayout.tsx @@ -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 ? ( +
+ {/* Header */} + + + {/* Main Content Area */} +
+ {/* Sidebar */} + + + {/* Main Content */} +
+ +
+
+
+ ) : ( + + ); +} diff --git a/apps/frontend/src/routes/_verifyingLayout/verifying/index.lazy.tsx b/apps/frontend/src/routes/_verifyingLayout/verifying/index.lazy.tsx new file mode 100644 index 0000000..572d0ee --- /dev/null +++ b/apps/frontend/src/routes/_verifyingLayout/verifying/index.lazy.tsx @@ -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([]); + const [dragActive, setDragActive] = useState(false); + const [flaggedQuestions, setFlaggedQuestions] = useState<{ + [key: string]: boolean; + }>({}); + const fileInputRef = useRef(null); + const [modalOpen, setModalOpen] = useState(false); + const [modalOpenFileSize, setModalOpenFileSize] = useState(false); + const [selectedAspectId, setSelectedAspectId] = useState(null); + const [selectedSubAspectId, setSelectedSubAspectId] = useState(null); + const [assessmentId, setAssessmentId] = useState(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({ + 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) => { + 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, 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, 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 ; + } + + if (isError) { + return ( + + Error: {error?.message || "Terjadi kesalahan saat memuat pertanyaan."} + + ); + } + + if (!assessmentId) { + return ( + + + + Error: Data Asesmen tidak ditemukan. Harap akses halaman melalui link yang valid. + + + + ); + } + + 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 ( +
+ + + + {/* LEFT-SIDE */} + {/* Aspek dan Sub-Aspek */} +
+ +
+ {/* Aspek */} + {aspectsQuery.data?.data + .filter((aspect) => + aspect.subAspects.some((subAspect) => + data?.data.some((question) => question.subAspectId === subAspect.id) + ) + ) + .map((aspect) => ( +
+
toggleAspect(aspect.id)} + > +
{aspect.name}
+
+ {openAspects[aspect.id] ? ( + + ) : ( + + )} +
+
+ + {/* Sub-Aspek */} + {openAspects[aspect.id] && ( +
+ {aspect.subAspects + .filter((subAspect) => + data?.data.some((question) => question.subAspectId === subAspect.id) + ) + .map((subAspect) => ( +
setSelectedSubAspectId(subAspect.id)} + > +
{subAspect.name}
+
+ ))} +
+ )} +
+ ))} +
+
+
+ + {/* MIDDLE */} + {/* Pertanyaan */} +
+ + + Harap menjawab semua pertanyaan yang tersedia + + Semua jawaban Anda akan ditinjau + {filteredQuestions.length === 0 ? ( + + Pertanyaan tidak ada untuk sub-aspek yang dipilih. + + ) : ( + filteredQuestions.map((question: any, index: number) => { + const questionId = question.questionId; + if (!questionId) return null; + + return ( +
(questionRefs.current[questionId] = el)} + className="space-y-4" + > + + + {/* Question */} + {startIndex + index + 1}. +
+ + {question.questionText} + +
+
+ + {/* Opsi Radio Button */} + {question.options?.length > 0 ? ( +
+ handleAnswerChange(questionId, value)} + className="flex flex-col gap-2" + > + {question.options.map((option: any) => ( +
handleAnswerChange(questionId, option.optionId)} + > + + +
+ ))} +
+
+ ) : ( + Tidak ada opsi untuk pertanyaan ini. + )} + + {/* Textarea */} +
+