From 1adc5e4df18293524c47504a38d0c8b670cfed2c Mon Sep 17 00:00:00 2001 From: Sukma Gladys Date: Fri, 11 Oct 2024 10:56:14 +0700 Subject: [PATCH] feat: assessment result and result management frontend --- apps/backend/src/data/permissions.ts | 3 + apps/backend/src/data/sidebarMenus.ts | 12 +- .../src/routes/assessmentResult/route.ts | 93 +- apps/backend/src/routes/assessments/route.ts | 6 +- apps/frontend/package.json | 3 +- apps/frontend/src/components/AppNavbar.tsx | 5 +- .../queries/assessmentResultQueries.ts | 32 + .../modals/assessmentResultsFormModal.tsx | 240 +++++ .../assessmentResultsManagaementQueries.ts | 41 + .../assessmentRequest/index.lazy.tsx | 2 +- .../assessmentResult/index.lazy.tsx | 286 ++++++ .../assessmentResult/index.tsx | 15 + .../index.lazy.tsx | 117 +++ .../assessmentResultsManagement/index.tsx | 15 + .../src/shadcn/components/ui/chart.tsx | 365 +++++++ pnpm-lock.yaml | 935 +++++++++++++++--- 16 files changed, 1987 insertions(+), 183 deletions(-) create mode 100644 apps/frontend/src/modules/assessmentResult/queries/assessmentResultQueries.ts create mode 100644 apps/frontend/src/modules/assessmentResultsManagement/modals/assessmentResultsFormModal.tsx create mode 100644 apps/frontend/src/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries.ts create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.lazy.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.lazy.tsx create mode 100644 apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.tsx create mode 100644 apps/frontend/src/shadcn/components/ui/chart.tsx diff --git a/apps/backend/src/data/permissions.ts b/apps/backend/src/data/permissions.ts index b85fe2b..1054709 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -125,6 +125,9 @@ const permissionsData = [ { code: "assessments.readAverageAllAspects", }, + { + code: "assessmentResult.update", + } ] as const; export type SpecificPermissionCode = (typeof permissionsData)[number]["code"]; diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index 9356717..4c0f203 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -9,10 +9,9 @@ const sidebarMenus: SidebarMenu[] = [ }, { label: "Manajemen Pengguna", - icon: { tb: "TbUsers" }, + icon: { tb: "TbUser" }, allowedPermissions: ["permissions.read"], link: "/users", - color: "red", }, { label: "Manajemen Aspek", @@ -23,10 +22,9 @@ const sidebarMenus: SidebarMenu[] = [ }, { label: "Manajemen Pertanyaan", - icon: { tb: "TbChecklist" }, + icon: { tb: "TbMessage2Cog" }, allowedPermissions: ["permissions.read"], link: "/questions", - color: "green", }, { label: "Permohonan Asesmen", @@ -42,6 +40,12 @@ const sidebarMenus: SidebarMenu[] = [ link: "/assessmentRequestManagements", color: "orange", }, + { + label: "Manajemen Hasil", + icon: { tb: "TbReport" }, + allowedPermissions: ["permissions.read"], + link: "/assessmentResultsManagement", + }, ]; export default sidebarMenus; diff --git a/apps/backend/src/routes/assessmentResult/route.ts b/apps/backend/src/routes/assessmentResult/route.ts index 5b9e989..1518817 100644 --- a/apps/backend/src/routes/assessmentResult/route.ts +++ b/apps/backend/src/routes/assessmentResult/route.ts @@ -1,4 +1,4 @@ -import { and, eq, isNull, sql } from "drizzle-orm"; +import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; import db from "../../drizzle"; @@ -27,12 +27,34 @@ const assessmentRoute = new Hono() requestValidator( "query", z.object({ + 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(40), + q: z.string().default(""), }) ), async (c) => { - const { page, limit } = c.req.valid("query"); + const { page, limit, q } = c.req.valid("query"); + const totalItems = await db + .select({ + count: sql`COUNT(*)`, + }) + .from(assessments) + .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) + .leftJoin(users, eq(respondents.userId, users.id)) + .where( + and( + q + ? or( + ilike(users.name, q), + ilike(respondents.companyName, q), + ) + : undefined + ) + ); const result = await db .select({ @@ -59,21 +81,26 @@ const assessmentRoute = new Hono() .from(assessments) .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) .leftJoin(users, eq(respondents.userId, users.id)) + .where( + and( + q + ? or( + ilike(users.name, q), + ilike(respondents.companyName, q), + ) + : undefined + ) + ) .offset(page * limit) .limit(limit); - - const totalItems = await db - .select({ - count: sql`COUNT(*)`, - }) - .from(assessments); - + const totalCountResult = await totalItems; + const totalCount = totalCountResult[0]?.count || 0; return c.json({ data: result, _metadata: { currentPage: page, - totalPages: Math.ceil(totalItems[0].count / limit), - totalItems: totalItems[0].count, + totalPages: Math.ceil(totalCount / limit), + totalItems: totalCount, perPage: limit, }, }); @@ -98,7 +125,13 @@ const assessmentRoute = new Hono() phoneNumber: respondents.phoneNumber, username: users.username, assessmentDate: assessments.createdAt, - statusAssessment: assessments.status, + statusAssessment: assessments.status, + statusVerification: sql` + CASE + WHEN ${assessments.validatedAt} IS NOT NULL THEN 'sudah diverifikasi' + ELSE 'belum diverifikasi' + END` + .as("statusVerification"), assessmentsResult: sql` (SELECT ROUND(AVG(${options.score}), 2) FROM ${answers} @@ -233,6 +266,40 @@ const assessmentRoute = new Hono() 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({ + validatedAt: new Date(), + }) + .where(eq(assessments.id, assessmentId)); + console.log("Validated Success"); + + return c.json({ + message: "Assessment berhasil diverifikasi", + data: assessment, + }); + } + ); export default assessmentRoute; diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index 462fd09..0534b02 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -45,7 +45,7 @@ const assessmentsRoute = new Hono() // Get data for current Assessment Score from submitted options By Assessment Id .get( "/getCurrentAssessmentScore", - checkPermission("assessments.readAssessmentScore"), + // checkPermission("assessments.readAssessmentScore"), requestValidator( "query", z.object({ @@ -446,6 +446,7 @@ const assessmentsRoute = new Hono() const averageScores = await db .select({ + aspectId: subAspects.aspectId, subAspectId: subAspects.id, subAspectName: subAspects.name, average: sql`AVG(options.score)` @@ -463,7 +464,8 @@ const assessmentsRoute = new Hono() subAspects: averageScores.map(score => ({ subAspectId: score.subAspectId, subAspectName: score.subAspectName, - averageScore: score.average + averageScore: score.average, + aspectId: score.aspectId })) }); } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6034b9f..3e2544c 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -11,12 +11,12 @@ "dependencies": { "@emotion/react": "^11.11.4", "@hookform/resolvers": "^3.9.0", - "@paralleldrive/cuid2": "^2.2.2", "@mantine/core": "^7.10.2", "@mantine/dates": "^7.10.2", "@mantine/form": "^7.10.2", "@mantine/hooks": "^7.10.2", "@mantine/notifications": "^7.10.2", + "@paralleldrive/cuid2": "^2.2.2", "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", "@radix-ui/react-dialog": "^1.1.1", @@ -40,6 +40,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", "react-icons": "^5.2.1", + "recharts": "^2.13.0", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.23.8" diff --git a/apps/frontend/src/components/AppNavbar.tsx b/apps/frontend/src/components/AppNavbar.tsx index 5e9af2d..4d10139 100644 --- a/apps/frontend/src/components/AppNavbar.tsx +++ b/apps/frontend/src/components/AppNavbar.tsx @@ -19,6 +19,7 @@ export default function AppNavbar() { // const {user} = useAuth(); const { pathname } = useLocation(); + const pathsThatCloseSidebar = ["/assessmentResult"]; const [isSidebarOpen, setSidebarOpen] = useState(true); const toggleSidebar = () => { @@ -43,7 +44,7 @@ export default function AppNavbar() { if (window.innerWidth < 768) { // Ganti 768 dengan breakpoint mobile Anda setSidebarOpen(false); } else { - setSidebarOpen(true); + setSidebarOpen(!pathsThatCloseSidebar.includes(pathname)); } }; @@ -66,6 +67,7 @@ export default function AppNavbar() { {/* Sidebar */} + {!pathsThatCloseSidebar.includes(pathname) && (
+ )} ); diff --git a/apps/frontend/src/modules/assessmentResult/queries/assessmentResultQueries.ts b/apps/frontend/src/modules/assessmentResult/queries/assessmentResultQueries.ts new file mode 100644 index 0000000..6f32c83 --- /dev/null +++ b/apps/frontend/src/modules/assessmentResult/queries/assessmentResultQueries.ts @@ -0,0 +1,32 @@ +import client from "@/honoClient"; +import fetchRPC from "@/utils/fetchRPC"; +import { queryOptions } from "@tanstack/react-query"; + +export const getAllSubAspectsAverageScore = (assessmentId: string | undefined) => + queryOptions({ + queryKey: ["allSubAspectsAverage", assessmentId], + queryFn: () => + fetchRPC( + client.assessments["average-score"]["sub-aspects"]["assessments"][":assessmentId"].$get({ + param: { + assessmentId: assessmentId!, + }, + }) + ), + enabled: Boolean(assessmentId), + }); + +export const getAllAspectsAverageScore = (assessmentId: string | undefined) => + queryOptions({ + queryKey: ["allAspectsAverage", assessmentId], + queryFn: () => + fetchRPC( + client.assessments["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/modals/assessmentResultsFormModal.tsx b/apps/frontend/src/modules/assessmentResultsManagement/modals/assessmentResultsFormModal.tsx new file mode 100644 index 0000000..815d6f5 --- /dev/null +++ b/apps/frontend/src/modules/assessmentResultsManagement/modals/assessmentResultsFormModal.tsx @@ -0,0 +1,240 @@ +import { + Button, + Flex, + Modal, + ScrollArea, +} from "@mantine/core"; +import { useForm } from "@mantine/form"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getRouteApi } from "@tanstack/react-router"; +import { useEffect } from "react"; +import createInputComponents from "@/utils/createInputComponents"; +import { getAssessmentResultByIdQueryOptions } from "../queries/assessmentResultsManagaementQueries"; + +/** + * Change this + */ +const routeApi = getRouteApi("/_dashboardLayout/assessmentResultsManagement/"); + +export default function UserFormModal() { + /** + * DON'T CHANGE FOLLOWING: + */ + const queryClient = useQueryClient(); + + const navigate = routeApi.useNavigate(); + + const searchParams = routeApi.useSearch(); + + const detailId = (searchParams as { detail?: string }).detail; + + const isModalOpen = Boolean(detailId); + + const formType = detailId ? "detail" : "tambah"; + + /** + * CHANGE FOLLOWING: + */ + + const assessmentResultQuery = useQuery(getAssessmentResultByIdQueryOptions(detailId)); + + const modalTitle = + formType.charAt(0).toUpperCase() + formType.slice(1) + " Hasil Assessment"; + + const form = useForm({ + initialValues: { + respondentName: "", + position: "", + workExperience: "", + email: "", + companyName: "", + address: "", + phoneNumber: "", + username: "", + assessmentDate: "", + statusAssessment: "", + assessmentsResult: "", + }, + }); + + useEffect(() => { + const data = assessmentResultQuery.data; + + if (!data) { + form.reset(); + return; + } + + // Function to format the date + const formatDate = (dateString: string) => { + const date = new Date(dateString); + // Format only the date, hour, and minute + return date.toLocaleString('id-ID', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + }; + + form.setValues({ + respondentName: data.respondentName ?? "", + position: data.position ?? "", + workExperience: data.workExperience ?? "", + email: data.email ?? "", + companyName: data.companyName ?? "", + address: data.address ?? "", + phoneNumber: data.phoneNumber ?? "", + username: data.username ?? "", + assessmentDate: data.assessmentDate ? formatDate(data.assessmentDate) : "", + statusAssessment: data.statusAssessment ?? "", + assessmentsResult: String(data.assessmentsResult ?? ""), + }); + + form.setErrors({}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [assessmentResultQuery.data]); + + // const mutation = useMutation({ + // mutationKey: ["assessmentResultMutation"], + // mutationFn: async ( + // options: + // | { action: "ubah"; data: Parameters[0] } + // | { action: "tambah"; data: Parameters[0] } + // ) => { + // console.log("called"); + // return options.action === "ubah" + // ? await updateUser(options.data) + // : await createUser(options.data); + // }, + // onError: (error: unknown) => { + // console.log(error); + + // if (error instanceof FormResponseError) { + // form.setErrors(error.formErrors); + // return; + // } + + // if (error instanceof Error) { + // notifications.show({ + // message: error.message, + // color: "red", + // }); + // } + // }, + // }); + + const handleSubmit = async (values: typeof form.values) => { + if (formType === "detail") { + ({ + action: formType, + data: { + respondentName: values.respondentName, + position: values.position, + workExperience: values.workExperience, + email: values.email, + companyName: values.companyName, + address: values.address, + phoneNumber: values.phoneNumber, + username: values.username, + assessmentDate: values.assessmentDate, + statusAssessment: values.statusAssessment, + assessmentsResult: values.assessmentsResult, + isEnabled: "true", + }, + }); + } + queryClient.invalidateQueries({ queryKey: ["users"] }); + navigate({ search: {} }); + }; + + /** + * YOU MIGHT NOT NEED FOLLOWING: + */ + + return ( + navigate({ search: {} })} + title={modalTitle} //Uppercase first letter + scrollAreaComponent={ScrollArea.Autosize} + size="md" + > +
handleSubmit(values))}> + {createInputComponents({ + readonlyAll: formType === "detail", + inputs: [ + { + type: "text", + label: "Nama Respondent", + ...form.getInputProps("respondentName"), + }, + { + type: "text", + label: "Jabatan", + ...form.getInputProps("position"), + }, + { + type: "text", + label: "Pengalaman Kerja", + ...form.getInputProps("workExperience"), + }, + { + type: "text", + label: "Email", + ...form.getInputProps("email"), + }, + { + type: "text", + label: "Instansi/Perusahaan", + ...form.getInputProps("companyName"), + }, + { + type: "text", + label: "Alamat", + ...form.getInputProps("address"), + }, + { + type: "text", + label: "Nomor Telepon", + ...form.getInputProps("phoneNumber"), + }, + { + type: "text", + label: "Username", + ...form.getInputProps("username"), + }, + { + type: "text", + label: "Tanggal Assessment", + ...form.getInputProps("assessmentDate"), + }, + { + type: "text", + label: "Status Assessment", + ...form.getInputProps("statusAssessment"), + }, + { + type: "text", + label: "Hasil Assessment", + ...form.getInputProps("assessmentsResult"), + }, + ], + })} + + {/* Buttons */} + + + +
+
+ ); +} diff --git a/apps/frontend/src/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries.ts b/apps/frontend/src/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries.ts new file mode 100644 index 0000000..0b4bb14 --- /dev/null +++ b/apps/frontend/src/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries.ts @@ -0,0 +1,41 @@ +import client from "@/honoClient"; +import fetchRPC from "@/utils/fetchRPC"; +import { queryOptions } from "@tanstack/react-query"; + +export const assessmentResultsQueryOptions = (page: number, limit: number, q?: string) => + queryOptions({ + queryKey: ["assessmentResults", { page, limit, q }], + queryFn: () => + fetchRPC( + client.assessmentResult.$get({ + query: { + limit: String(limit), + page: String(page), + q: q || "", + }, + }) + ), + }); + +export const getAssessmentResultByIdQueryOptions = (assessmentResultId: string | undefined) => + queryOptions({ + queryKey: ["assessmentResults", assessmentResultId], + queryFn: () => + fetchRPC( + client.assessmentResult[":id"].$get({ + param: { + id: assessmentResultId!, + }, + }) + ), + enabled: Boolean(assessmentResultId), + }); + export const verifyAssessmentResultQuery = (assessmentResultId: string) => + fetchRPC( + client.assessmentResult[":id"].$patch({ + param: { + id: assessmentResultId, + }, + }) + ); + \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx index f2aa30f..8e7636a 100644 --- a/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx @@ -57,7 +57,7 @@ export default function UsersPage() { console.error("Assessment ID is missing"); return; } - const resultUrl = `/assessmentResult/${assessmentId}`; + const resultUrl = `/assessmentResult?id=${assessmentId}`; window.location.href = resultUrl; }; diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.lazy.tsx new file mode 100644 index 0000000..4137d8e --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.lazy.tsx @@ -0,0 +1,286 @@ +import { useEffect, useState } from "react"; +import { createLazyFileRoute } from "@tanstack/react-router"; +import { getAllAspectsAverageScore, getAllSubAspectsAverageScore } from "@/modules/assessmentResult/queries/assessmentResultQueries"; +import { useQuery } from "@tanstack/react-query"; +import { getAssessmentResultByIdQueryOptions } 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, +} from "@/shadcn/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/shadcn/components/ui/chart" + +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 [assessmentId, setAssessmentId] = useState(undefined); + + useEffect(() => { + const id = getQueryParam("id"); + setAssessmentId(id ?? undefined); + }, []); + + const { data: assessmentResult } = useQuery(getAssessmentResultByIdQueryOptions(assessmentId)); + const { data: allAspectsData } = useQuery(getAllAspectsAverageScore(assessmentId)); + const { data: allSubAspectsData } = useQuery(getAllSubAspectsAverageScore(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 totalScore = formatScore(assessmentResult?.assessmentsResult); + + const blueColors = [ + "hsl(220, 100%, 50%)", + "hsl(220, 80%, 60%)", + "hsl(220, 60%, 70%)", + "hsl(220, 40%, 80%)", + "hsl(220, 20%, 90%)", + ]; + + const chartData = allAspectsData?.aspects.map((aspect, index) => ({ + aspectName: aspect.AspectName, + score: Number(formatScore(aspect.averageScore)), + fill: blueColors[index % blueColors.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 sortedBarChartData = barChartData.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], + }; + return config; + }, {} as ChartConfig) || {}; + + const barChartConfig = allSubAspectsData?.subAspects.reduce((config, subAspect, index) => { + config[subAspect.subAspectName.toLowerCase()] = { + label: subAspect.subAspectName, + color: blueColors[index % blueColors.length], + }; + return config; + }, {} as ChartConfig) || {}; + + return ( + +
+

Cyber Security Maturity Level Dashboard

+

Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah

+
+ + {/* Score table */} + +
+ {allAspectsData?.aspects.map((aspect) => ( +
+
+

{aspect.AspectName}

+ {formatScore(aspect.averageScore)} +
+ {allSubAspectsData?.subAspects.map((subAspect) => { + if (subAspect.aspectId === aspect.AspectId) { + return ( +
+
+

{subAspect.subAspectName}

+ {formatScore(subAspect.averageScore)} +
+
+ ); + } + return null; + })} +
+ ))} +
+ + {/* 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} + > + + + + + + {chartData.map((entry, index) => ( +
+ + {entry.aspectName} +
+ )) || []} +
+
+ + {/* 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; + }} + /> + +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.tsx new file mode 100644 index 0000000..70f46b5 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentResult/index.tsx @@ -0,0 +1,15 @@ +import { getAllAspectsAverageScore } from '@/modules/assessmentResult/queries/assessmentResultQueries'; +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod'; + +const searchParamSchema = z.object({ + detail: z.string().default("").optional(), +}); + +export const Route = createFileRoute("/_dashboardLayout/assessmentResult/")({ + validateSearch: searchParamSchema, + + loader: ({ context: { queryClient } }) => { + queryClient.ensureQueryData(getAllAspectsAverageScore("0")); + }, +}); \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.lazy.tsx new file mode 100644 index 0000000..6b687bc --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.lazy.tsx @@ -0,0 +1,117 @@ +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 { Button } from "@/shadcn/components/ui/button"; +import assessmentResultsFormModal from "@/modules/assessmentResultsManagement/modals/assessmentResultsFormModal"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import FormResponseError from "@/errors/FormResponseError"; +import { notifications } from "@mantine/notifications"; + +export const Route = createLazyFileRoute('/_dashboardLayout/assessmentResultsManagement/')({ + component: assessmentResultsManagementPage, +}); + +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", + }); + }, + }); +}; + +export default function assessmentResultsManagementPage() { + const verifyMutation = useVerifyAssessmentResult(); + + const handleVerifyClick = (assessmentId: string) => { + verifyMutation.mutate(assessmentId); + }; + + return ( + props.row.index + 1, + }), + columnHelper.display({ + header: "Nama Responden", + cell: (props) => props.row.original.respondentName, + }), + columnHelper.display({ + header: "Nama Perusahaan", + cell: (props) => props.row.original.companyName, + }), + columnHelper.display({ + header: "Status Assessment", + cell: (props) => props.row.original.statusAssessments, + }), + columnHelper.display({ + header: "Status Verifikasi", + cell: (props) => props.row.original.statusVerification, + }), + columnHelper.display({ + header: "Hasil Assessment", + cell: (props) => props.row.original.assessmentsResult, + }), + columnHelper.display({ + header: "Action", + cell: (props) => ( + + ), + }), + columnHelper.display({ + header: " ", + cell: (props) => ( + + {createActionButtons([ + { + label: "Detail", + permission: true, + action: `?detail=${props.row.original.id}`, + color: "black", + icon: , + }, + ])} + + ), + }), + ]} + /> + ); +} diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.tsx new file mode 100644 index 0000000..7d0fed7 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentResultsManagement/index.tsx @@ -0,0 +1,15 @@ +import { assessmentResultsQueryOptions } from '@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries'; +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod'; + +const searchParamSchema = z.object({ + detail: z.string().default("").optional(), +}); + +export const Route = createFileRoute("/_dashboardLayout/assessmentResultsManagement/")({ + validateSearch: searchParamSchema, + + loader: ({ context: { queryClient } }) => { + queryClient.ensureQueryData(assessmentResultsQueryOptions(0, 10)); + }, +}); \ No newline at end of file diff --git a/apps/frontend/src/shadcn/components/ui/chart.tsx b/apps/frontend/src/shadcn/components/ui/chart.tsx new file mode 100644 index 0000000..8620baa --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/chart.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import * as RechartsPrimitive from "recharts" + +import { cn } from "@/lib/utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode + icon?: React.ComponentType + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + return context +} + +const ChartContainer = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + config: ChartConfig + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >["children"] + } +>(({ id, className, children, config, ...props }, ref) => { + const uniqueId = React.useId() + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}` + + return ( + +
+ + + {children} + +
+
+ ) +}) +ChartContainer.displayName = "Chart" + +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { + const colorConfig = Object.entries(config).filter( + ([_, config]) => config.theme || config.color + ) + + if (!colorConfig.length) { + return null + } + + return ( +