Pull Request branch dev-clone to main #1

Merged
gitea merged 429 commits from dev-clone into main 2024-12-23 09:31:34 +00:00
16 changed files with 1983 additions and 183 deletions
Showing only changes of commit b8e949beda - Show all commits

View File

@ -125,6 +125,9 @@ const permissionsData = [
{
code: "assessments.readAverageAllAspects",
},
{
code: "assessmentResult.update",
}
] as const;
export type SpecificPermissionCode = (typeof permissionsData)[number]["code"];

View File

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

View File

@ -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<HonoEnv>()
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<number>`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<HonoEnv>()
.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<number>`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,
},
});
@ -99,6 +126,12 @@ const assessmentRoute = new Hono<HonoEnv>()
username: users.username,
assessmentDate: assessments.createdAt,
statusAssessment: assessments.status,
statusVerification: sql<string>`
CASE
WHEN ${assessments.validatedAt} IS NOT NULL THEN 'sudah diverifikasi'
ELSE 'belum diverifikasi'
END`
.as("statusVerification"),
assessmentsResult: sql<number>`
(SELECT ROUND(AVG(${options.score}), 2)
FROM ${answers}
@ -233,6 +266,40 @@ const assessmentRoute = new Hono<HonoEnv>()
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;

View File

@ -172,7 +172,7 @@ const assessmentsRoute = new Hono<HonoEnv>()
// Get data for current Assessment Score from submitted options By Assessment Id
.get(
"/getCurrentAssessmentScore",
checkPermission("assessments.readAssessmentScore"),
// checkPermission("assessments.readAssessmentScore"),
requestValidator(
"query",
z.object({

View File

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

View File

@ -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));
}
};
@ -72,7 +73,7 @@ export default function AppNavbar() {
<AppHeader toggle={toggleSidebar} openNavbar={isSidebarOpen} />
{/* Sidebar */}
{pathname !== "/assessment" && (
{!pathsThatCloseSidebar.includes(pathname) && (
<div
className={`fixed lg:relative w-64 bg-white top-16 left-0 h-full z-40 px-3 py-4 transition-transform border-x
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}

View File

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

View File

@ -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<typeof updateUser>[0] }
// | { action: "tambah"; data: Parameters<typeof createUser>[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 (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={modalTitle} //Uppercase first letter
scrollAreaComponent={ScrollArea.Autosize}
size="md"
>
<form onSubmit={form.onSubmit((values) => 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 */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
>
Tutup
</Button>
</Flex>
</form>
</Modal>
);
}

View File

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

View File

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

View File

@ -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<string | undefined>(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 (
<Card className="w-full h-screen border-none">
<div className="flex flex-col w-full h-fit mb-6 justify-center items-center">
<p className="text-2xl font-bold">Cyber Security Maturity Level Dashboard</p>
<p className="text-xs text-muted-foreground">Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah</p>
</div>
{/* Score table */}
<Card className="flex flex-col w-full h-fit my-2 mb-8 border overflow-hidden">
<div className="flex flex-row">
{allAspectsData?.aspects.map((aspect) => (
<div key={aspect.AspectId} className="flex-col bg-white w-full h-full">
<div className="flex flex-col font-bold items-center justify-center border p-2 h-full w-full">
<p className="text-sm">{aspect.AspectName}</p>
<span className="text-2xl">{formatScore(aspect.averageScore)}</span>
</div>
{allSubAspectsData?.subAspects.map((subAspect) => {
if (subAspect.aspectId === aspect.AspectId) {
return (
<div key={subAspect.subAspectId} className="flex flex-col gap-2 border-x p-2 h-full w-full">
<div className="flex flex-row gap-2 justify-between h-full w-full">
<p className="text-xs text-muted-foreground">{subAspect.subAspectName}</p>
<span className="text-xs font-bold">{formatScore(subAspect.averageScore)}</span>
</div>
</div>
);
}
return null;
})}
</div>
))}
</div>
{/* nilai keseluruhan */}
<div className="flex flex-row w-full h-10 gap-2 bg-blue-600 text-white items-center justify-center font-bold">
<p>Level Muturitas:</p>
<span>{totalScore}</span>
</div>
</Card>
<Card className="flex flex-row gap-8 border-none shadow-none">
{/* Pie Chart */}
<Card className="flex flex-row w-full">
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<PieChart>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={chartData}
dataKey="score"
nameKey="aspectName"
innerRadius={60}
strokeWidth={5}
label={({ cx, cy, midAngle, innerRadius, outerRadius, index }) => {
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 (
<text
x={x}
y={y}
fill="white"
textAnchor="middle"
dominantBaseline="middle"
fontSize={14}
>
{chartData[index]?.score || ""}
</text>
);
}}
labelLine={false}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-3xl font-bold"
>
{totalScore.toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-muted-foreground"
>
</tspan>
</text>
)
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm justify-center items-start">
{chartData.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="w-4 h-4"
style={{ backgroundColor: entry.fill }}
/>
<span className="font-medium">{entry.aspectName}</span>
</div>
)) || []}
</CardFooter>
</Card>
{/* Radar Chart */}
<Card className="flex flex-col w-full">
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto max-h-[250px]"
>
<RadarChart data={chartData}>
<ChartTooltip
cursor={false}
content={({ active, payload }) => {
if (active && payload && payload.length > 0) {
const { aspectName, score } = payload[0].payload;
return (
<div className="tooltip bg-white p-1 rounded-md">
<p>{`${aspectName} : ${score}`}</p>
</div>
);
}
return null;
}}
/>
<PolarAngleAxis dataKey="aspectName" tick={{ fontSize: 10}} stroke="black" />
<PolarRadiusAxis angle={90} domain={[0, 8]} tick={{ fontSize: 10 }} stroke="black" />
<PolarGrid radialLines={true} />
<Radar
dataKey="score"
fillOpacity={0}
stroke="#007BFF"
strokeWidth={2}
/>
</RadarChart>
</ChartContainer>
</CardContent>
</Card>
</Card>
<Card className="flex w-full h-fit border mt-8">
{/* Bar Chart */}
<Card className="w-full">
<CardContent>
<ChartContainer config={barChartConfig}>
<BarChart accessibilityLayer data={sortedBarChartData}>
<CartesianGrid vertical={false} horizontal={true}/>
<XAxis
dataKey="subAspectName"
tickLine={false}
tickMargin={0}
axisLine={false}
tickFormatter={(value) => value.slice(0,3)}
tick={{ textAnchor: 'start' }}
/>
<YAxis />
<ChartTooltip
cursor={false}
content={({ active, payload }) => {
if (active && payload && payload.length > 0) {
const { subAspectName, score } = payload[0].payload; // Ambil data dari payload
return (
<div className="tooltip bg-white p-1 rounded-md">
<p>{`${subAspectName} : ${score}`}</p>
</div>
);
}
return null;
}}
/>
<Bar dataKey="score" radius={4}/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
</Card>
</Card>
);
}

View File

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

View File

@ -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<typeof assessmentResultsQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
const useVerifyAssessmentResult = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (assessmentId: string) => {
return verifyAssessmentResultQuery(assessmentId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["assessmentResults"] });
notifications.show({
title: "Berhasil",
message: "Assessment berhasil diverifikasi",
color: "green",
});
},
onError: (error: FormResponseError) => {
notifications.show({
title: "Error",
message: error.message || "Terjadi kesalahan saat verifikasi",
color: "red",
});
},
});
};
export default function assessmentResultsManagementPage() {
const verifyMutation = useVerifyAssessmentResult();
const handleVerifyClick = (assessmentId: string) => {
verifyMutation.mutate(assessmentId);
};
return (
<PageTemplate
title="Manajemen Hasil Assessment"
queryOptions={assessmentResultsQueryOptions}
modals={[assessmentResultsFormModal()]}
createButton={false}
columnDefs={[
columnHelper.display({
header: "#",
cell: (props) => 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) => (
<Button
onClick={() => handleVerifyClick(props.row.original.id)}
variant={"ghost"}
className="w-fit items-center bg-gray-200 hover:bg-gray-300"
>
<span className="text-black">Verifikasi</span>
</Button>
),
}),
columnHelper.display({
header: " ",
cell: (props) => (
<Flex gap="xs" className="bg-white">
{createActionButtons([
{
label: "Detail",
permission: true,
action: `?detail=${props.row.original.id}`,
color: "black",
icon: <TbEye />,
},
])}
</Flex>
),
}),
]}
/>
);
}

View File

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

View File

@ -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<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
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 (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
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 (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

File diff suppressed because it is too large Load Diff