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
30 changed files with 2098 additions and 211 deletions
Showing only changes of commit 5edf918a9b - Show all commits

View File

@ -21,6 +21,27 @@ const sidebarMenus: SidebarMenu[] = [
link: "/questions",
color: "green",
},
{
label: "Permohonan Asesmen",
icon: { tb: "TbMessageQuestion" },
allowedPermissions: ["permissions.read"],
link: "/assessmentRequest",
color: "green",
},
{
label: "Manajemen Aspek",
icon: { tb: "TbClipboardText" },
allowedPermissions: ["permissions.read"],
link: "/aspect",
color: "blue",
},
{
label: "Manajemen Permohonan Asesmen",
icon: { tb: "TbReport" },
allowedPermissions: ["permissions.read"],
link: "/assessmentRequestManagements",
color: "orange",
},
];
export default sidebarMenus;

View File

@ -71,6 +71,7 @@ const authInfo = createMiddleware<HonoEnv>(async (c, next) => {
// Setting the currentUser with fetched data
c.set("currentUser", {
id: user[0].users.id, // Adding user ID here
name: user[0].users.name, // Assuming the first result is the user
permissions: Array.from(permissions),
roles: Array.from(roles),

View File

@ -1,15 +1,13 @@
import { eq } from "drizzle-orm";
import { eq, sql, ilike, and, desc} from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
import db from "../../drizzle";
import { respondents } from "../../drizzle/schema/respondents";
import { assessments } from "../../drizzle/schema/assessments";
import { users } from "../../drizzle/schema/users";
import { rolesToUsers } from "../../drizzle/schema/rolesToUsers";
import { rolesSchema } from "../../drizzle/schema/roles";
import HonoEnv from "../../types/HonoEnv";
import authInfo from "../../middlewares/authInfo";
import { notFound } from "../../errors/DashboardError";
import { forbidden, notFound } from "../../errors/DashboardError";
import checkPermission from "../../middlewares/checkPermission";
import requestValidator from "../../utils/requestValidator";
import { HTTPException } from "hono/http-exception";
@ -20,85 +18,131 @@ const assessmentRequestRoute = new Hono<HonoEnv>()
// Get assessment request by user ID
.get(
"/:id",
"/",
checkPermission("assessmentRequest.read"),
requestValidator(
"query",
z.object({
includeTrashed: z.string().default("false"),
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(10),
q: z.string().optional(),
})
),
async (c) => {
const userId = c.req.param("id");
const currentUser = c.get("currentUser");
const userId = currentUser?.id; // Get user ID of the currently logged in currentUser
if (!userId) {
throw forbidden({
message: "User not authenticated"
});
}
const { page, limit, q } = c.req.valid("query");
// Query to count total data
const totalCountQuery = db
.select({
count: sql<number>`count(distinct ${assessments.id})`,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(
and(
eq(users.id, userId),
q && q.trim() !== ""
? ilike(sql`${assessments.status}::text`, `%${q}%`) // Cast status to text for ilike
: undefined
)
)
const totalCountResult = await totalCountQuery;
const totalItems = totalCountResult[0]?.count || 0;
// Query to get assessment data with pagination
const queryResult = await db
.select({
userId: users.id,
createdAt: assessments.createdAt,
name: users.name,
code: rolesSchema.code,
assessmentId: assessments.id,
tanggal: assessments.createdAt,
status: assessments.status,
respondentId: respondents.id,
})
.from(users)
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
.leftJoin(respondents, eq(users.id, respondents.userId))
.leftJoin(assessments, eq(respondents.id, assessments.respondentId))
.where(eq(users.id, userId));
.where(
and(
eq(users.id, userId),
q && q.trim() !== ""
? ilike(sql`${assessments.status}::text`, `%${q}%`) // Cast status to text for ilike
: undefined
)
)
.orderBy(desc(assessments.createdAt))
.offset(page * limit)
.limit(limit);
if (!queryResult[0]) throw notFound();
const assessmentRequestData = {
...queryResult,
};
return c.json(assessmentRequestData);
return c.json({
data: queryResult,
_metadata: {
currentPage: page,
totalPages: Math.ceil(totalItems / limit),
totalItems,
perPage: limit,
},
});
}
)
// Post assessment request by user ID
.post(
"/:id",
"/",
checkPermission("assessmentRequest.create"),
requestValidator(
"json",
z.object({
respondentId: z.string().min(1),
respondentId: z.string().min(1), // Memastikan respondentId minimal ada
})
),
async (c) => {
const { respondentId } = c.req.valid("json");
const userId = c.req.param("id");
const currentUser = c.get("currentUser");
const userId = currentUser?.id; // Mengambil userId dari currentUser yang disimpan di context
// Make sure the userId exists
// Memastikan user sudah terautentikasi
if (!userId) {
throw new HTTPException(400, { message: "User ID is required." });
return c.text("User not authenticated", 401);
}
// Validate if respondent exists
// Validasi apakah respondent dengan respondentId tersebut ada
const respondent = await db
.select()
.from(respondents)
.where(eq(respondents.id, respondentId));
.where(and(eq(respondents.id, respondentId), eq(respondents.userId, userId)));
if (!respondent.length) {
throw new HTTPException(404, { message: "Respondent not found." });
throw new HTTPException(404, { message: "Respondent not found or unauthorized." });
}
// Create the assessment request
// Membuat permohonan asesmen baru
const newAssessment = await db
.insert(assessments)
.values({
id: createId(),
respondentId,
status: "menunggu konfirmasi",
status: "menunggu konfirmasi", // Status awal permohonan
validatedBy: null,
validatedAt: null,
createdAt: new Date(),
})
.returning();
return c.json({ message: "Successfully submitted the assessment request" }, 201);
return c.json({ message: "Successfully submitted the assessment request", data: newAssessment }, 201);
}
);

View File

@ -1,173 +1,191 @@
import { and, eq, ilike, or, sql } from "drizzle-orm";
import { Hono } from "hono";
import checkPermission from "../../middlewares/checkPermission";
import { z } from "zod";
import { HTTPException } from "hono/http-exception";
import db from "../../drizzle";
import { assessments } from "../../drizzle/schema/assessments";
import { respondents } from "../../drizzle/schema/respondents";
import { users } from "../../drizzle/schema/users";
import HonoEnv from "../../types/HonoEnv";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
import { and, eq, ilike, or, sql, desc } from "drizzle-orm";
import { Hono } from "hono";
import checkPermission from "../../middlewares/checkPermission";
import { z } from "zod";
import { HTTPException } from "hono/http-exception";
import db from "../../drizzle";
import { assessments } from "../../drizzle/schema/assessments";
import { respondents } from "../../drizzle/schema/respondents";
import { users } from "../../drizzle/schema/users";
import HonoEnv from "../../types/HonoEnv";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
export const assessmentFormSchema = z.object({
respondentId: z.string().min(1),
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]),
reviewedBy: z.string().min(1),
validatedBy: z.string().min(1),
validatedAt: z.string().optional(),
});
export const assessmentFormSchema = z.object({
respondentId: z.string().min(1),
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]),
reviewedBy: z.string().min(1),
validatedBy: z.string().min(1),
validatedAt: z.string().optional(),
});
export const assessmentUpdateSchema = assessmentFormSchema.extend({
validatedAt: z.string().optional().or(z.literal("")),
});
export const assessmentUpdateSchema = assessmentFormSchema.extend({
validatedAt: z.string().optional().or(z.literal("")),
});
const assessmentsRequestManagementRoutes = new Hono<HonoEnv>()
.use(authInfo)
/**
* Get All Assessments (With Metadata)
*
* Query params:
* - withMetadata: boolean
*/
.get(
"/",
checkPermission("assessmentRequestManagement.readAll"),
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(10),
q: z.string().default(""),
const assessmentsRequestManagementRoutes = new Hono<HonoEnv>()
.use(authInfo)
/**
* Get All Assessments (With Metadata)
*
* Query params:
* - withMetadata: boolean
*/
.get(
"/",
checkPermission("assessmentRequestManagement.readAll"),
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(10),
q: z.string().default(""),
})
),
async (c) => {
const { page, limit, q } = c.req.valid("query");
// Query untuk menghitung total jumlah item (totalCountQuery)
const assessmentCountQuery = await db
.select({
count: sql<number>`count(*)`,
})
),
async (c) => {
const { page, limit, q } = c.req.valid("query");
const totalCountQuery = sql<number>`(SELECT count(*) FROM ${assessments})`;
const result = await db
.select({
idPermohonan: assessments.id,
namaResponden: users.name,
namaPerusahaan: respondents.companyName,
status: assessments.status,
tanggal: assessments.createdAt,
fullCount: totalCountQuery,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(
q
? or(
ilike(users.name, `%${q}%`),
ilike(respondents.companyName, `%${q}%`),
eq(assessments.id, q)
)
: undefined
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(
q
? or(
ilike(users.name, `%${q}%`),
ilike(respondents.companyName, `%${q}%`),
sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`,
eq(assessments.id, q)
)
.offset(page * limit)
.limit(limit);
: undefined
);
return c.json({
data: result.map((d) => ({
idPermohonan: d.idPermohonan,
namaResponden: d.namaResponden,
namaPerusahaan: d.namaPerusahaan,
status: d.status,
tanggal: d.tanggal,
})),
_metadata: {
currentPage: page,
totalPages: Math.ceil(
(Number(result[0]?.fullCount) ?? 0) / limit
),
totalItems: Number(result[0]?.fullCount) ?? 0,
perPage: limit,
},
});
}
)
const totalItems = Number(assessmentCountQuery[0]?.count) || 0;
// Get assessment by id
.get(
"/:id",
checkPermission("assessmentRequestManagement.read"),
async (c) => {
const assessmentId = c.req.param("id");
const queryResult = await db
.select({
// id: assessments.id,
tanggal: assessments.createdAt,
nama: users.name,
posisi: respondents.position,
pengalamanKerja: respondents.workExperience,
email: users.email,
namaPerusahaan: respondents.companyName,
alamat: respondents.address,
nomorTelepon: respondents.phoneNumber,
username: users.username,
status: assessments.status,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(eq(assessments.id, assessmentId));
if (!queryResult.length)
throw new HTTPException(404, {
message: "The assessment does not exist",
});
const assessmentData = queryResult[0];
return c.json(assessmentData);
}
)
.patch(
"/:id",
checkPermission("assessmentRequestManagement.update"),
requestValidator(
"json",
z.object({
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]),
// Query utama untuk mendapatkan data permohonan assessment
const result = await db
.select({
idPermohonan: assessments.id,
namaResponden: users.name,
namaPerusahaan: respondents.companyName,
status: assessments.status,
tanggal: assessments.createdAt,
})
),
async (c) => {
const assessmentId = c.req.param("id");
const { status } = c.req.valid("json");
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(
q
? or(
ilike(users.name, `%${q}%`),
ilike(respondents.companyName, `%${q}%`),
sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`,
eq(assessments.id, q)
)
: undefined
)
.orderBy(desc(assessments.createdAt))
.offset(page * limit)
.limit(limit);
const assessment = await db
.select()
.from(assessments)
.where(and(eq(assessments.id, assessmentId),));
return c.json({
data: result.map((d) => ({
idPermohonan: d.idPermohonan,
namaResponden: d.namaResponden,
namaPerusahaan: d.namaPerusahaan,
status: d.status,
tanggal: d.tanggal,
})),
_metadata: {
currentPage: page,
totalPages: Math.ceil(totalItems / limit),
totalItems,
perPage: limit,
},
});
}
)
if (!assessment[0]) throw new HTTPException(404, {
message: "Assessment tidak ditemukan.",
// Get assessment by id
.get(
"/:id",
checkPermission("assessmentRequestManagement.read"),
async (c) => {
const assessmentId = c.req.param("id");
const queryResult = await db
.select({
tanggal: assessments.createdAt,
nama: users.name,
posisi: respondents.position,
pengalamanKerja: respondents.workExperience,
email: users.email,
namaPerusahaan: respondents.companyName,
alamat: respondents.address,
nomorTelepon: respondents.phoneNumber,
username: users.username,
status: assessments.status,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(eq(assessments.id, assessmentId));
if (!queryResult.length)
throw new HTTPException(404, {
message: "The assessment does not exist",
});
await db
.update(assessments)
.set({
status,
})
.where(eq(assessments.id, assessmentId));
const assessmentData = queryResult[0];
return c.json({
message: "Status assessment berhasil diperbarui.",
});
}
)
return c.json(assessmentData);
}
)
.patch(
"/:id",
checkPermission("assessmentRequestManagement.update"),
requestValidator(
"json",
z.object({
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]),
})
),
async (c) => {
const assessmentId = c.req.param("id");
const { status } = c.req.valid("json");
const assessment = await db
.select()
.from(assessments)
.where(and(eq(assessments.id, assessmentId),));
if (!assessment[0]) throw new HTTPException(404, {
message: "Assessment tidak ditemukan.",
});
await db
.update(assessments)
.set({
status,
})
.where(eq(assessments.id, assessmentId));
return c.json({
message: "Status assessment berhasil diperbarui.",
});
}
)
export default assessmentsRequestManagementRoutes;
export default assessmentsRequestManagementRoutes;

View File

@ -80,9 +80,19 @@ const managementAspectRoute = new Hono<HonoEnv>()
async (c) => {
const { includeTrashed, page, limit, q } = c.req.valid("query");
const totalCountQuery = includeTrashed
? sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})`
: sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`;
const aspectCountQuery = await db
.select({
count: sql<number>`count(*)`,
})
.from(aspects)
.where(
and(
includeTrashed ? undefined : isNull(aspects.deletedAt),
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
)
);
const totalItems = Number(aspectCountQuery[0]?.count) || 0;
const aspectIdsQuery = await db
.select({
@ -95,6 +105,7 @@ const managementAspectRoute = new Hono<HonoEnv>()
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
)
)
.orderBy(aspects.name)
.offset(page * limit)
.limit(limit);
@ -128,11 +139,11 @@ const managementAspectRoute = new Hono<HonoEnv>()
FROM ${questions}
WHERE ${questions.subAspectId} = ${subAspects.id}
)`.as('questionCount'),
fullCount: totalCountQuery,
})
.from(aspects)
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
.where(inArray(aspects.id, aspectIds));
.where(inArray(aspects.id, aspectIds))
.orderBy(aspects.name);
// Grouping sub aspects by aspect ID
const groupedResult = result.reduce((acc, curr) => {
@ -176,8 +187,8 @@ const managementAspectRoute = new Hono<HonoEnv>()
data: groupedArray,
_metadata: {
currentPage: page,
totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit),
totalItems: Number(result[0]?.fullCount) ?? 0,
totalPages: Math.ceil(totalItems / limit),
totalItems,
perPage: limit,
},
});
@ -287,10 +298,23 @@ const managementAspectRoute = new Hono<HonoEnv>()
if (aspectData.subAspects) {
const subAspectsArray = JSON.parse(aspectData.subAspects) as string[];
// Insert new sub aspects into the database without checking for sub aspect duplication
if (subAspectsArray.length) {
// Create a Set to check for duplicates
const uniqueSubAspects = new Set<string>();
// Filter out duplicates
const filteredSubAspects = subAspectsArray.filter((subAspect) => {
if (uniqueSubAspects.has(subAspect)) {
return false; // Skip duplicates
}
uniqueSubAspects.add(subAspect);
return true; // Keep unique sub-aspects
});
// Check if there are any unique sub aspects to insert
if (filteredSubAspects.length) {
// Insert new sub aspects into the database
await db.insert(subAspects).values(
subAspectsArray.map((subAspect) => ({
filteredSubAspects.map((subAspect) => ({
aspectId,
name: subAspect,
}))
@ -379,10 +403,20 @@ const managementAspectRoute = new Hono<HonoEnv>()
);
}
// Create a Set to check for duplicate sub-aspects
const uniqueSubAspectNames = new Set(currentSubAspects.map(sub => sub.name));
// Update or add new sub aspects
for (const subAspect of newSubAspects) {
const existingSubAspect = currentSubAspectMap.has(subAspect.id);
// Check for duplicate sub-aspect names
if (uniqueSubAspectNames.has(subAspect.name) && !existingSubAspect) {
throw notFound({
message: `Sub aspect name "${subAspect.name}" already exists for this aspect.`,
});
}
if (existingSubAspect) {
// Update if sub aspect already exists
await db
@ -402,12 +436,14 @@ const managementAspectRoute = new Hono<HonoEnv>()
await db
.insert(subAspects)
.values({
id: subAspect.id,
aspectId,
name: subAspect.name,
createdAt: new Date(),
});
}
// Add the name to the Set after processing
uniqueSubAspectNames.add(subAspect.name);
}
return c.json({

View File

@ -5,6 +5,7 @@ type HonoEnv = {
Variables: {
uid?: string;
currentUser?: {
id: string;
name: string;
permissions: SpecificPermissionCode[];
roles: RoleCode[];

View File

@ -11,6 +11,7 @@
"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",

View File

@ -86,7 +86,7 @@ export default function MenuItem({ menu, isActive, onClick }: Props) {
<Icon className="w-4 h-4" />
</span>
{/* Label */}
<span className="text-xs font-bold">{menu.label}</span>
<span className="text-xs font-bold whitespace-normal">{menu.label}</span>
</div>
{/* Chevron Icon */}
{hasChildren && (

View File

@ -69,5 +69,5 @@
}
:root {
--primary-color: #2555FF
--primary-color: #2555FF;
}

View File

@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import "./styles/tailwind.css";
import "./styles/fonts/manrope.css";
import "./styles/fonts/inter.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>

View File

@ -0,0 +1,97 @@
import client from "@/honoClient";
import { Button, Flex, Modal, Text } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi, useSearch } from "@tanstack/react-router";
import { deleteAspect } from "../queries/aspectQueries";
import { notifications } from "@mantine/notifications";
import fetchRPC from "@/utils/fetchRPC";
const routeApi = getRouteApi("/_dashboardLayout/aspect/");
export default function AspectDeleteModal() {
const queryClient = useQueryClient();
const searchParams = useSearch({ from: "/_dashboardLayout/aspect/" }) as {
delete: string;
};
const aspectId = searchParams.delete;
const navigate = routeApi.useNavigate();
const aspectQuery = useQuery({
queryKey: ["management-aspect", aspectId],
queryFn: async () => {
if (!aspectId) return null;
return await fetchRPC(
client["management-aspect"][":id"].$get({
param: {
id: aspectId,
},
query: {},
})
);
},
});
const mutation = useMutation({
mutationKey: ["deleteAspectMutation"],
mutationFn: async ({ id }: { id: string }) => {
return await deleteAspect(id);
},
onError: (error: unknown) => {
if (error instanceof Error) {
notifications.show({
message: error.message,
color: "red",
});
}
},
onSuccess: () => {
notifications.show({
message: "Aspek berhasil dihapus.",
color: "green",
});
queryClient.removeQueries({ queryKey: ["management-aspect", aspectId] });
queryClient.invalidateQueries({ queryKey: ["management-aspect"] });
navigate({ search: {} });
},
});
const isModalOpen = Boolean(searchParams.delete && aspectQuery.data);
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={`Konfirmasi Hapus`}
>
<Text size="sm">
Apakah Anda yakin ingin menghapus aspek{" "}
<Text span fw={700}>
{aspectQuery.data?.name}
</Text>
? Tindakan ini tidak dapat diubah.
</Text>
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Batal
</Button>
<Button
variant="subtle"
type="submit"
color="red"
loading={mutation.isPending}
onClick={() => mutation.mutate({ id: aspectId })}
>
Hapus Aspek
</Button>
</Flex>
</Modal>
);
}

View File

@ -0,0 +1,248 @@
import { Button, Flex, Modal, ScrollArea, TextInput, Group, Text } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { createAspect, updateAspect, getAspectByIdQueryOptions } from "../queries/aspectQueries";
import { TbDeviceFloppy } from "react-icons/tb";
import { useEffect } from "react";
import { notifications } from "@mantine/notifications";
import FormResponseError from "@/errors/FormResponseError";
import { createId } from "@paralleldrive/cuid2";
// Initialize route API
const routeApi = getRouteApi("/_dashboardLayout/aspect/");
export default function AspectFormModal() {
const queryClient = useQueryClient();
const navigate = routeApi.useNavigate();
const searchParams = routeApi.useSearch();
const dataId = searchParams.detail || searchParams.edit;
const isModalOpen = Boolean(dataId || searchParams.create);
const formType = searchParams.detail ? "detail" : searchParams.edit ? "edit" : "create";
// Fetch aspect data if editing or viewing details
const aspectQuery = useQuery(getAspectByIdQueryOptions(dataId));
const modalTitle = `${formType.charAt(0).toUpperCase() + formType.slice(1)} Aspek`;
const form = useForm({
initialValues: {
id: "",
name: "",
subAspects: [{ id: "", name: "", questionCount: 0 }] as { id: string; name: string; questionCount: number }[],
},
});
useEffect(() => {
const data = aspectQuery.data;
if (!data) {
form.reset();
return;
}
form.setValues({
id: data.id,
name: data.name,
subAspects: data.subAspects?.map(subAspect => ({
id: subAspect.id || "",
name: subAspect.name,
questionCount: subAspect.questionCount || 0,
})) || [],
});
form.setErrors({});
}, [aspectQuery.data]);
const mutation = useMutation({
mutationKey: ["aspectMutation"],
mutationFn: async (
options:
| { action: "edit"; data: Parameters<typeof updateAspect>[0] }
| { action: "create"; data: Parameters<typeof createAspect>[0] }
) => {
return options.action === "edit"
? await updateAspect(options.data)
: await createAspect(options.data);
},
onError: (error: unknown) => {
if (error instanceof FormResponseError) {
form.setErrors(error.formErrors);
return;
}
if (error instanceof Error) {
notifications.show({
message: error.message,
color: "red",
});
}
},
});
type CreateAspectPayload = {
name: string;
subAspects?: string;
};
type EditAspectPayload = {
id: string;
name: string;
subAspects?: string;
};
const handleSubmit = async (values: typeof form.values) => {
try {
// Name field validation
if (values.name.trim() === "") {
form.setErrors({ name: "Nama aspek harus diisi" });
return;
}
let payload: CreateAspectPayload | EditAspectPayload;
if (formType === "create") {
payload = {
name: values.name,
subAspects: values.subAspects.length > 0
? JSON.stringify(
values.subAspects
.filter(subAspect => subAspect.name.trim() !== "")
.map(subAspect => subAspect.name)
)
: "",
};
await createAspect(payload);
} else if (formType === "edit") {
// Add validation for aspect name here
payload = {
id: values.id,
name: values.name,
subAspects: values.subAspects.length > 0
? JSON.stringify(
values.subAspects
.filter(subAspect => subAspect.name.trim() !== "")
.map(subAspect => ({
id: subAspect.id || "",
name: subAspect.name,
questionCount: subAspect.questionCount || 0,
}))
)
: "",
};
await updateAspect(payload);
}
queryClient.invalidateQueries({ queryKey: ["management-aspect"] });
notifications.show({
message: `Aspek ${formType === "create" ? "berhasil dibuat" : "berhasil diedit"}`,
});
navigate({ search: {} });
} catch (error) {
console.error("Error during submit:", error);
if (error instanceof Error && error.message === "Aspect name already exists") {
notifications.show({
message: "Nama aspek sudah ada. Silakan gunakan nama lain.",
color: "red",
});
} else {
notifications.show({
message: "Nama Sub Aspek sudah ada. Silakan gunakan nama lain.",
color: "red",
});
}
}
};
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={modalTitle}
scrollAreaComponent={ScrollArea.Autosize}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<TextInput
type="text"
label="Nama"
{...form.getInputProps("name")}
disabled={formType === "detail"}
error={form.errors.name}
/>
{form.values.subAspects.map((field, index) => (
<Group key={index} mt="md" align="center">
<TextInput
type="text"
label={`Sub Aspek ${index + 1}`}
value={field.name}
onChange={(event) => {
const newSubAspects = [...form.values.subAspects];
newSubAspects[index] = { ...newSubAspects[index], name: event.target.value };
form.setValues({ subAspects: newSubAspects });
}}
disabled={formType === "detail"}
style={{ flex: 1 }}
/>
{formType === "detail" && (
<Text>Jumlah Soal: {field.questionCount}</Text>
)}
{formType !== "detail" && (
<Button
className="mt-6"
variant="outline"
onClick={() => {
const newSubAspects = form.values.subAspects.filter((_, i) => i !== index);
form.setValues({ subAspects: newSubAspects });
}}
>
Hapus
</Button>
)}
</Group>
))}
{formType !== "detail" && (
<Button
variant="outline"
mt="md"
onClick={() => {
const newSubAspects = [
...form.values.subAspects,
{ id: createId(), name: "", questionCount: 0 }
];
form.setValues({ subAspects: newSubAspects });
}}
>
Tambah Sub Aspek
</Button>
)}
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Tutup
</Button>
{formType !== "detail" && (
<Button
variant="filled"
leftSection={<TbDeviceFloppy size={20} />}
type="submit"
loading={mutation.isPending}
>
Simpan
</Button>
)}
</Flex>
</form>
</Modal>
);
}

View File

@ -0,0 +1,83 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions } from "@tanstack/react-query";
import { InferRequestType } from "hono";
export const aspectQueryOptions = (page: number, limit: number, q?: string) =>
queryOptions({
queryKey: ["management-aspect", { page, limit, q }],
queryFn: async () => {
const response = await fetchRPC(
client["management-aspect"].$get({
query: {
limit: String(limit),
page: String(page),
q,
},
})
);
return response;
},
});
export const getAspectByIdQueryOptions = (aspectId: string | undefined) =>
queryOptions({
queryKey: ["management-aspect", aspectId],
queryFn: () =>
fetchRPC(
client["management-aspect"][":id"].$get({
param: {
id: aspectId!,
},
query: {},
})
),
enabled: Boolean(aspectId),
});
export const createAspect = async (
json: { name: string; subAspects?: string }
) => {
try {
return await fetchRPC(
client["management-aspect"].$post({
json,
})
);
} catch (error) {
console.error("Error creating aspect:", error);
throw error;
}
};
export const updateAspect = async (
form: { id: string; name: string; subAspects?: string }
) => {
try {
const payload = {
name: form.name,
subAspects: form.subAspects
? JSON.parse(form.subAspects)
: [],
};
return await fetchRPC(
client["management-aspect"][":id"].$patch({
param: {
id: form.id,
},
json: payload,
})
);
} catch (error) {
console.error("Error updating aspect:", error);
throw error;
}
};
export const deleteAspect = async (id: string) => {
return await fetchRPC(
(client["management-aspect"] as { [key: string]: any })[id].$delete()
);
};

View File

@ -0,0 +1,34 @@
import { Modal, Text, Flex } from "@mantine/core";
import { Button } from "@/shadcn/components/ui/button";
interface StartAssessmentModalProps {
assessmentId: string;
isOpen: boolean;
onClose: () => void;
onConfirm: (assessmentId: string) => void;
}
export default function StartAssessmentModal({
assessmentId,
isOpen,
onClose,
onConfirm,
}: StartAssessmentModalProps) {
return (
<Modal opened={isOpen} onClose={onClose} title="Konfirmasi Mulai Asesmen">
<Text>Apakah Anda yakin ingin memulai asesmen ini?</Text>
<Flex gap="sm" justify="flex-end" mt="md">
<Button variant="outline" onClick={onClose}>
Batal
</Button>
<Button
onClick={() => {
onConfirm(assessmentId); // Use assessmentId when confirming
}}
>
Mulai Asesmen
</Button>
</Flex>
</Modal>
);
}

View File

@ -0,0 +1,174 @@
import {
Flex,
Modal,
Text
} from "@mantine/core";
import { Button } from "@/shadcn/components/ui/button";
import { useForm } from "@mantine/form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { useEffect } from "react";
import { notifications } from "@mantine/notifications";
import FormResponseError from "@/errors/FormResponseError";
import createInputComponents from "@/utils/createInputComponents";
import { assessmentRequestQueryOptions, createAssessmentRequest } from "../queries/assessmentRequestQueries";
/**
* Change this
*/
const routeApi = getRouteApi("/_dashboardLayout/assessmentRequest/");
export default function UserFormModal() {
const queryClient = useQueryClient();
const navigate = routeApi.useNavigate();
const searchParams = routeApi.useSearch();
const isModalOpen = Boolean(searchParams.create);
const formType = "create";
const userQuery = useQuery(assessmentRequestQueryOptions(0, 10));
const modalTitle = <b>Konfirmasi</b>
const form = useForm({
initialValues: {
respondentsId: "",
name: "",
},
});
// used to get the respondentId of the currently logged in user
// and then set respondentsId in the form to create an assessment request
useEffect(() => {
const data = userQuery.data;
if (!data) {
form.reset();
return;
}
form.setValues({
respondentsId: data.data[0].respondentId ?? "",
name: data.data[0].name ?? "",
});
form.setErrors({});
}, [userQuery.data]);
// Mutation function to create a new assessment request and refresh query after success
const mutation = useMutation({
mutationKey: ["usersMutation"],
mutationFn: async (options: { action: "create"; data: { respondentsId: string } }) => {
console.log("called");
if (options.action === "create") {
return await createAssessmentRequest(options.data);
}
},
// auto refresh after mutation
onSuccess: () => {
// force a query-reaction to retrieve the latest data
queryClient.invalidateQueries({ queryKey: ["assessmentRequest"] });
notifications.show({
message: "Permohonan Asesmen berhasil dibuat!",
color: "green",
});
// close modal
navigate({ search: {} });
},
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",
});
}
},
});
// Handle submit form, mutate data to server and close modal after success
const handleSubmit = async (values: typeof form.values) => {
if (formType === "create") {
try {
await mutation.mutateAsync({
action: "create",
data: {
respondentsId: values.respondentsId,
},
});
} catch (error) {
console.error(error);
}
}
queryClient.invalidateQueries({ queryKey: ["users"] });
navigate({ search: {} });
};
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title= {modalTitle}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Text>Apakah anda yakin ingin membuat Permohonan Asesmen Baru?</Text>
{/* Fields to display data will be sent but only respondentId */}
{createInputComponents({
disableAll: mutation.isPending,
readonlyAll: formType === "create",
inputs: [
{
type: "text",
label: "Respondent ID",
...form.getInputProps("respondentsId"),
hidden: true,
},
{
type: "text",
label: "Name",
...form.getInputProps("name"),
hidden: true,
},
],
})}
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="sm" mt="lg">
<Button
variant="outline"
type="button"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Tidak
</Button>
<Button
type="submit"
isLoading={mutation.isPending}
>
Buat Permohonan
</Button>
</Flex>
</form>
</Modal>
);
}

View File

@ -0,0 +1,30 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions } from "@tanstack/react-query";
export const assessmentRequestQueryOptions = (page: number, limit: number, q?: string) =>
queryOptions({
queryKey: ["assessmentRequest", { page, limit, q }],
queryFn: () =>
fetchRPC(
client.assessmentRequest.$get({
query: {
limit: String(limit),
page: String(page),
q,
},
})
),
});
export const createAssessmentRequest = async ({ respondentsId }: { respondentsId: string }) => {
const response = await client.assessmentRequest.$post({
json: { respondentId: respondentsId },
});
if (!response.ok) {
throw new Error("Failed to create assessment request");
}
return await response.json();
};

View File

@ -0,0 +1,214 @@
import { Button, Flex, Modal, ScrollArea } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { notifications } from "@mantine/notifications";
import { fetchAssessmentRequestManagementById, updateAssessmentRequestManagementStatus } from "../queries/assessmentRequestManagementQueries";
import createInputComponents from "@/utils/createInputComponents"; // Assuming you have this utility
import { useEffect } from "react";
// Define the API route for navigation
const routeApi = getRouteApi("/_dashboardLayout/assessmentRequestManagements/");
// Define allowed status values
type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "selesai";
interface AssessmentRequestManagementFormModalProps {
assessmentId: string | null;
isOpen: boolean;
onClose: () => void;
}
export default function AssessmentRequestManagementFormModal({
assessmentId,
isOpen,
onClose,
}: AssessmentRequestManagementFormModalProps) {
const queryClient = useQueryClient();
const navigate = routeApi.useNavigate();
const AssessmentRequestManagementQuery = useQuery({
queryKey: ["assessmentRequestManagements", assessmentId],
queryFn: async () => {
if (!assessmentId) return null;
return await fetchAssessmentRequestManagementById(assessmentId);
},
});
const form = useForm({
initialValues: {
tanggal: "",
nama: "",
posisi: "",
pengalamanKerja: "",
email: "",
namaPerusahaan: "",
alamat: "",
nomorTelepon: "",
username: "",
status: "menunggu konfirmasi" as AssessmentStatus,
},
});
// Populate the form once data is available
useEffect(() => {
if (AssessmentRequestManagementQuery.data) {
form.setValues({
tanggal: formatDate(AssessmentRequestManagementQuery.data.tanggal || "Data Kosong"),
nama: AssessmentRequestManagementQuery.data.nama || "Data Kosong",
posisi: AssessmentRequestManagementQuery.data.posisi || "Data Kosong",
pengalamanKerja: AssessmentRequestManagementQuery.data.pengalamanKerja || "Data Kosong",
email: AssessmentRequestManagementQuery.data.email || "Data Kosong",
namaPerusahaan: AssessmentRequestManagementQuery.data.namaPerusahaan || "Data Kosong",
alamat: AssessmentRequestManagementQuery.data.alamat || "Data Kosong",
nomorTelepon: AssessmentRequestManagementQuery.data.nomorTelepon || "Data Kosong",
username: AssessmentRequestManagementQuery.data.username || "Data Kosong",
status: AssessmentRequestManagementQuery.data.status || "menunggu konfirmasi",
});
}
}, [AssessmentRequestManagementQuery.data, form]);
const mutation = useMutation({
mutationKey: ["updateAssessmentRequestManagementStatusMutation"],
mutationFn: async ({
id,
status,
}: {
id: string;
status: AssessmentStatus;
}) => {
return await updateAssessmentRequestManagementStatus(id, status);
},
onError: (error: unknown) => {
if (error instanceof Error) {
notifications.show({
message: error.message,
color: "red",
});
}
},
onSuccess: () => {
notifications.show({
message: "Status Permohonan Asesmen berhasil diperbarui.",
color: "green",
});
queryClient.invalidateQueries({
queryKey: ["assessmentRequestManagements", assessmentId],
});
onClose();
},
});
const handleStatusChange = (status: AssessmentStatus) => {
if (assessmentId) {
mutation.mutate({ id: assessmentId, status });
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "Tanggal tidak tersedia";
const date = new Date(dateString);
if (isNaN(date.getTime())) return "Tanggal tidak valid";
return new Intl.DateTimeFormat("id-ID", {
hour12: true,
minute: "2-digit",
hour: "2-digit",
day: "2-digit",
month: "long",
year: "numeric",
}).format(date);
};
const { status } = form.values;
return (
<Modal opened={isOpen} onClose={onClose} title="Detail Permohonan Asesmen">
<ScrollArea style={{ height: "400px", paddingRight: "15px" }} scrollbarSize={8}>
{createInputComponents({
disableAll: mutation.isPending,
readonlyAll: true,
inputs: [
{
type: "text",
label: "Tanggal",
...form.getInputProps("tanggal"),
},
{
type: "text",
label: "Nama",
...form.getInputProps("nama"),
},
{
type: "text",
label: "Posisi",
...form.getInputProps("posisi"),
},
{
type: "text",
label: "Pengalaman Kerja",
...form.getInputProps("pengalamanKerja"),
},
{
type: "text",
label: "Email",
...form.getInputProps("email"),
},
{
type: "text",
label: "Nama Perusahaan",
...form.getInputProps("namaPerusahaan"),
},
{
type: "text",
label: "Alamat",
...form.getInputProps("alamat"),
},
{
type: "text",
label: "Nomor Telepon",
...form.getInputProps("nomorTelepon"),
},
{
type: "text",
label: "Username",
...form.getInputProps("username"),
},
{
type: "text",
label: "Status",
...form.getInputProps("status"),
},
],
})}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>
Tutup
</Button>
{status !== "selesai" && (
<>
<Button
variant="filled"
color="red"
onClick={() => handleStatusChange("ditolak")}
disabled={mutation.isPending}
>
Tolak
</Button>
<Button
variant="filled"
color="blue"
onClick={() => handleStatusChange("diterima")}
disabled={mutation.isPending}
>
Terima
</Button>
</>
)}
</Flex>
</ScrollArea>
</Modal>
);
}

View File

@ -0,0 +1,38 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions } from "@tanstack/react-query";
// Define allowed status values
type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "selesai";
export const assessmentRequestManagementQueryOptions = (page: number, limit: number, q?: string) =>
queryOptions({
queryKey: ["assessmentRequestManagements", { page, limit, q }],
queryFn: () =>
fetchRPC(
client.assessmentRequestManagement.$get({
query: {
limit: String(limit),
page: String(page),
q,
},
})
),
});
export async function updateAssessmentRequestManagementStatus(id: string, status: AssessmentStatus) {
return await fetchRPC(
client.assessmentRequestManagement[":id"].$patch({
param: { id },
json: { status },
})
);
}
export async function fetchAssessmentRequestManagementById(id: string) {
return await fetchRPC(
client.assessmentRequestManagement[":id"].$get({
param: { id },
})
);
}

View File

@ -17,10 +17,13 @@ import { Route as DashboardLayoutImport } from './routes/_dashboardLayout'
import { Route as DashboardLayoutUsersIndexImport } from './routes/_dashboardLayout/users/index'
import { Route as DashboardLayoutTimetableIndexImport } from './routes/_dashboardLayout/timetable/index'
import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboardLayout/dashboard/index'
import { Route as DashboardLayoutAssessmentRequestIndexImport } from './routes/_dashboardLayout/assessmentRequest/index'
import { Route as DashboardLayoutAspectIndexImport } from './routes/_dashboardLayout/aspect/index'
// Create Virtual Routes
const IndexLazyImport = createFileRoute('/')()
const RegisterIndexLazyImport = createFileRoute('/register/')()
const LogoutIndexLazyImport = createFileRoute('/logout/')()
const LoginIndexLazyImport = createFileRoute('/login/')()
const ForgotPasswordIndexLazyImport = createFileRoute('/forgot-password/')()
@ -40,6 +43,13 @@ const IndexLazyRoute = IndexLazyImport.update({
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
const RegisterIndexLazyRoute = RegisterIndexLazyImport.update({
path: '/register/',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/register/index.lazy').then((d) => d.Route),
)
const LogoutIndexLazyRoute = LogoutIndexLazyImport.update({
path: '/logout/',
getParentRoute: () => rootRoute,
@ -83,6 +93,25 @@ const DashboardLayoutDashboardIndexRoute =
getParentRoute: () => DashboardLayoutRoute,
} as any)
const DashboardLayoutAssessmentRequestIndexRoute =
DashboardLayoutAssessmentRequestIndexImport.update({
path: '/assessmentRequest/',
getParentRoute: () => DashboardLayoutRoute,
} as any).lazy(() =>
import('./routes/_dashboardLayout/assessmentRequest/index.lazy').then(
(d) => d.Route,
),
)
const DashboardLayoutAspectIndexRoute = DashboardLayoutAspectIndexImport.update(
{
path: '/aspect/',
getParentRoute: () => DashboardLayoutRoute,
} as any,
).lazy(() =>
import('./routes/_dashboardLayout/aspect/index.lazy').then((d) => d.Route),
)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@ -129,6 +158,27 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogoutIndexLazyImport
parentRoute: typeof rootRoute
}
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register'
preLoaderRoute: typeof RegisterIndexLazyImport
parentRoute: typeof rootRoute
}
'/_dashboardLayout/aspect/': {
id: '/_dashboardLayout/aspect/'
path: '/aspect'
fullPath: '/aspect'
preLoaderRoute: typeof DashboardLayoutAspectIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/assessmentRequest/': {
id: '/_dashboardLayout/assessmentRequest/'
path: '/assessmentRequest'
fullPath: '/assessmentRequest'
preLoaderRoute: typeof DashboardLayoutAssessmentRequestIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/dashboard/': {
id: '/_dashboardLayout/dashboard/'
path: '/dashboard'
@ -158,6 +208,8 @@ declare module '@tanstack/react-router' {
export const routeTree = rootRoute.addChildren({
IndexLazyRoute,
DashboardLayoutRoute: DashboardLayoutRoute.addChildren({
DashboardLayoutAspectIndexRoute,
DashboardLayoutAssessmentRequestIndexRoute,
DashboardLayoutDashboardIndexRoute,
DashboardLayoutTimetableIndexRoute,
DashboardLayoutUsersIndexRoute,
@ -166,6 +218,7 @@ export const routeTree = rootRoute.addChildren({
ForgotPasswordIndexLazyRoute,
LoginIndexLazyRoute,
LogoutIndexLazyRoute,
RegisterIndexLazyRoute,
})
/* prettier-ignore-end */
@ -181,7 +234,8 @@ export const routeTree = rootRoute.addChildren({
"/forgot-password/verify",
"/forgot-password/",
"/login/",
"/logout/"
"/logout/",
"/register/"
]
},
"/": {
@ -190,6 +244,8 @@ export const routeTree = rootRoute.addChildren({
"/_dashboardLayout": {
"filePath": "_dashboardLayout.tsx",
"children": [
"/_dashboardLayout/aspect/",
"/_dashboardLayout/assessmentRequest/",
"/_dashboardLayout/dashboard/",
"/_dashboardLayout/timetable/",
"/_dashboardLayout/users/"
@ -207,6 +263,17 @@ export const routeTree = rootRoute.addChildren({
"/logout/": {
"filePath": "logout/index.lazy.tsx"
},
"/register/": {
"filePath": "register/index.lazy.tsx"
},
"/_dashboardLayout/aspect/": {
"filePath": "_dashboardLayout/aspect/index.tsx",
"parent": "/_dashboardLayout"
},
"/_dashboardLayout/assessmentRequest/": {
"filePath": "_dashboardLayout/assessmentRequest/index.tsx",
"parent": "/_dashboardLayout"
},
"/_dashboardLayout/dashboard/": {
"filePath": "_dashboardLayout/dashboard/index.tsx",
"parent": "/_dashboardLayout"

View File

@ -8,7 +8,7 @@ interface RouteContext {
export const Route = createRootRouteWithContext<RouteContext>()({
component: () => (
<div className="font-manrope">
<div className="font-inter">
<Outlet />
<TanStackRouterDevtools />
</div>

View File

@ -0,0 +1,93 @@
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
import PageTemplate from "@/components/PageTemplate";
import { createLazyFileRoute } from "@tanstack/react-router";
import AspectFormModal from "@/modules/aspectManagement/modals/AspectFormModal";
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
import { createColumnHelper } from "@tanstack/react-table";
import { Flex } from "@mantine/core";
import createActionButtons from "@/utils/createActionButton";
import { TbEye, TbPencil, TbTrash } from "react-icons/tb";
import AspectDeleteModal from "@/modules/aspectManagement/modals/AspectDeleteModal";
export const Route = createLazyFileRoute("/_dashboardLayout/aspect/")({
component: AspectPage,
});
type DataType = ExtractQueryDataType<typeof aspectQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
export default function AspectPage() {
return (
<PageTemplate
title="Manajemen Aspek"
queryOptions={aspectQueryOptions}
modals={[<AspectFormModal />, <AspectDeleteModal />]}
columnDefs={[
// Number of columns
columnHelper.display({
header: "#",
cell: (props) => props.row.index + 1,
}),
// Aspect columns
columnHelper.display({
header: "Nama Aspek",
cell: (props) => props.row.original.name || "Tidak ada Aspek",
}),
// Sub aspect columns
columnHelper.display({
header: "Sub Aspek",
cell: (props) => {
const subAspects = props.row.original.subAspects || [];
return subAspects.length > 0 ? (
<span>
{subAspects.map((subAspect, index) => (
<span key={subAspect.id}>
{subAspect.name}
{index < subAspects.length - 1 ? ", " : ""}
</span>
))}
</span>
) : (
<span>Tidak ada Sub Aspek</span>
);
},
}),
// Actions columns
columnHelper.display({
header: "Aksi",
cell: (props) => (
<Flex gap="xs">
{createActionButtons([
{
label: "Detail",
permission: true,
action: `?detail=${props.row.original.id}`,
color: "green",
icon: <TbEye />,
},
{
label: "Edit",
permission: true,
action: `?edit=${props.row.original.id}`,
color: "orange",
icon: <TbPencil />,
},
{
label: "Hapus",
permission: true,
action: `?delete=${props.row.original.id}`,
color: "red",
icon: <TbTrash />,
},
])}
</Flex>
),
}),
]}
/>
);
}

View File

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

View File

@ -0,0 +1,153 @@
import { useState } from "react";
import { assessmentRequestQueryOptions } from "@/modules/assessmentRequest/queries/assessmentRequestQueries";
import PageTemplate from "@/components/PageTemplate";
import { createLazyFileRoute } from "@tanstack/react-router";
import FormModal from "@/modules/assessmentRequest/modals/CreateAssessmentRequestModal";
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
import { createColumnHelper } from "@tanstack/react-table";
import { Badge } from "@/shadcn/components/ui/badge";
import { Button } from "@/shadcn/components/ui/button";
import StartAssessmentModal from "@/modules/assessmentRequest/modals/ConfirmModal";
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentRequest/")({
component: UsersPage,
});
type DataType = ExtractQueryDataType<typeof assessmentRequestQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
export default function UsersPage() {
const [modalOpen, setModalOpen] = useState(false);
const [selectedAssessmentId, setSelectedAssessmentId] = useState<string | null>(null);
/**
* Function to open confirmation modal to start assessment
* @param {string} assessmentId ID of the assessment to be started
*/
const handleOpenModal = (assessmentId: string) => {
if (!assessmentId) {
console.error("Assessment ID is missing");
return;
}
setSelectedAssessmentId(assessmentId);
setModalOpen(true);
};
/**
* Function to open assessment page in new tab
* @param {string} assessmentId ID of the assessment to be opened
*/
const handleStartAssessment = (assessmentId: string) => {
// Redirect to new URL in new tab
const assessmentUrl = `/assessment?id=${assessmentId}`;
window.open(assessmentUrl, "_blank");
setModalOpen(false);
};
/**
* Function to open assessment result page based on valid ID
* Used when "View Result" button is clicked
* @param {string} assessmentId ID of the assessment to be opened
*/
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/${assessmentId}`;
window.location.href = resultUrl;
};
return (
<>
<PageTemplate
title="Permohonan Asesmen"
queryOptions={assessmentRequestQueryOptions}
modals={[<FormModal />]}
columnDefs={[
columnHelper.display({
header: "No",
cell: (props) => props.row.index + 1,
}),
columnHelper.display({
header: "Tanggal",
cell: (props) =>
props.row.original.tanggal
? new Intl.DateTimeFormat("ID", {
year: "numeric",
month: "long",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true,
}).format(new Date(props.row.original.tanggal))
: 'N/A',
}),
columnHelper.display({
header: "Status",
cell: (props) => {
const status = props.row.original.status;
switch (status) {
case "menunggu konfirmasi":
return <Badge variant={"waiting"}>Menunggu Konfirmasi</Badge>;
case "diterima":
return <Badge variant={"accepted"}>Diterima</Badge>;
case "ditolak":
return <Badge variant={"rejected"}>Ditolak</Badge>;
case "selesai":
return <Badge variant={"completed"}>Selesai</Badge>;
default:
return <Badge variant={"outline"}>Tidak diketahui</Badge>;
}
},
}),
columnHelper.display({
header: "Actions",
cell: (props) => {
const status = props.row.original.status;
const assessmentId = props.row.original.assessmentId; // Retrieve the assessmentId from the data row
return (
<div className="flex gap-2">
{/* Button Create Assessment */}
{status === "selesai" ? (
<Button variant={"secondary"} disabled>Mulai Asesmen</Button>
) : status === "diterima" ? (
<Button
onClick={() => handleOpenModal(assessmentId ?? '')}
>
Mulai Asesmen
</Button>
) : (
<Button variant={"secondary"} disabled>Mulai Asesmen</Button>
)}
{/* Button View Result */}
{status === "selesai" ? (
<Button variant={"outline"} onClick={()=>handleViewResult(assessmentId ?? '')}>Lihat Hasil</Button>
) : (
<Button variant={"outline"} disabled>Lihat Hasil</Button>
)}
</div>
);
},
}),
]}
/>
{/* Confirmation Modal to Start Assessment */}
{selectedAssessmentId && (
<StartAssessmentModal
assessmentId={selectedAssessmentId}
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onConfirm={handleStartAssessment}
/>
)}
</>
);
}

View File

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

View File

@ -0,0 +1,137 @@
import { assessmentRequestManagementQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries";
import PageTemplate from "@/components/PageTemplate";
import { createLazyFileRoute } from "@tanstack/react-router";
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
import { createColumnHelper } from "@tanstack/react-table";
import { Badge, Flex } from "@mantine/core";
import createActionButtons from "@/utils/createActionButton";
import { TbEye } from "react-icons/tb";
import AssessmentRequestManagementFormModal from "@/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentRequestManagements/")({
component: AssessmentRequestManagementsPage,
});
type DataType = ExtractQueryDataType<typeof assessmentRequestManagementQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
export default function AssessmentRequestManagementsPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const queryClient = useQueryClient();
const handleDetailClick = (id: string) => {
setSelectedId(id);
setModalOpen(true);
};
// Helper function to format the date
const formatDate = (dateString: string | null) => {
if (!dateString) {
return "Tanggal tidak tersedia";
}
const date = new Date(dateString);
return new Intl.DateTimeFormat("id-ID", {
hour12: true,
minute: "2-digit",
hour: "2-digit",
day: "2-digit",
month: "long",
year: "numeric",
}).format(date);
};
return (
<PageTemplate
title="Manajemen Permohonan Asesmen"
queryOptions={assessmentRequestManagementQueryOptions}
modals={[
<AssessmentRequestManagementFormModal
key="form-modal"
assessmentId={selectedId}
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
queryClient.invalidateQueries();
}}
/>,
]}
createButton={null}
columnDefs={[
columnHelper.display({
header: "#",
cell: (props) => props.row.index + 1,
}),
columnHelper.display({
header: "Tanggal",
cell: (props) => formatDate(props.row.original.tanggal),
}),
columnHelper.display({
header: "Nama Responden",
cell: (props) => props.row.original.namaResponden,
}),
columnHelper.display({
header: "Nama Perusahaan",
cell: (props) => props.row.original.namaPerusahaan,
}),
columnHelper.display({
header: "Status",
cell: (props) => {
const status = props.row.original.status;
let statusLabel;
let color;
switch (status) {
case "menunggu konfirmasi":
statusLabel = "Menunggu Konfirmasi";
color = "yellow";
break;
case "diterima":
statusLabel = "Diterima";
color = "green";
break;
case "ditolak":
statusLabel = "Ditolak";
color = "red";
break;
case "selesai":
statusLabel = "Selesai";
color = "blue";
break;
default:
statusLabel = "Tidak Diketahui";
color = "gray";
break;
}
return <Badge color={color}>{statusLabel}</Badge>;
},
}),
columnHelper.display({
header: "Aksi",
cell: (props) => (
<Flex gap="xs">
{createActionButtons([
{
label: "Detail",
permission: true,
action: () => handleDetailClick(props.row.original.idPermohonan),
color: "green",
icon: <TbEye />,
},
])}
</Flex>
),
}),
]}
/>
);
}

View File

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

View File

@ -0,0 +1,322 @@
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Input } from '@/shadcn/components/ui/input.tsx';
import { Button } from '@/shadcn/components/ui/button.tsx';
import { Alert } from '@/shadcn/components/ui/alert.tsx';
import { Checkbox } from "@/shadcn/components/ui/checkbox";
import { Form, FormField, FormControl, FormLabel, FormMessage, FormItem } from '@/shadcn/components/ui/form.tsx';
import { TbArrowNarrowRight } from 'react-icons/tb';
import client from "../../honoClient";
// Define the schema for validation
const formSchema = z.object({
name: z.string().min(1, "Kolom ini wajib diisi"),
username: z.string().min(1, "Kolom ini wajib diisi"),
email: z.string().email("Alamat email tidak valid").min(1, "Kolom ini wajib diisi"),
password: z.string().min(6, "Kata sandi harus minimal 6 karakter"),
companyName: z.string().min(1, "Kolom ini wajib diisi"),
position: z.string().min(1, "Kolom ini wajib diisi"),
workExperience: z.string().min(1, "Kolom ini wajib diisi"),
address: z.string().min(1, "Kolom ini wajib diisi"),
phoneNumber: z.string().min(1, "Kolom ini wajib diisi"),
terms: z.boolean().refine((val) => val, "Anda harus menyetujui persyaratan dan layanan"),
});
// Define the form type
type FormSchema = z.infer<typeof formSchema>;
export const Route = createLazyFileRoute("/register/")({
component: RegisterPage,
});
export default function RegisterPage() {
const [errorFields, setErrorFields] = useState<Partial<Record<keyof FormSchema, string>> | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const navigate = useNavigate();
const form = useForm<FormSchema>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
username: "",
email: "",
password: "",
companyName: "",
position: "",
workExperience: "",
address: "",
phoneNumber: "",
terms: false,
},
});
const handleSubmit = async (values: FormSchema) => {
try {
const res = await client.register.$post({
json: values,
});
if (res.ok) {
// Redirect to login page on success
navigate({ to: "/login", replace: true });
} else {
// Handle non-200 responses from backend
const errorData = await res.json();
throw new Error(errorData.message || "An unknown error occurred");
}
} catch (error: any) {
const message = error.message;
if (message.includes("Email atau username sudah terdaftar")) {
setErrorFields({
email: "Email sudah terdaftar",
username: "Username sudah terdaftar",
});
} else if (message.includes("Nomor telepon sudah terdaftar")) {
setErrorFields({
phoneNumber: "Nomor telepon sudah terdaftar",
});
} else {
setErrorMessage(message);
}
}
};
return (
<div className="flex flex-col lg:flex-row min-h-screen overflow-hidden">
{/* Image */}
<div className="relative h-[40vw] lg:h-screen -z-20 lg:mt-24">
<div className="-translate-y-[calc(35vw+1rem)] lg:translate-y-0 w-full lg:translate-x-[calc(10vh-45vw)] ">
<span className="absolute scale-50 lg:scale-50 -rotate-12 w-[100vw] h-[100vw] lg:w-[140vh] lg:h-[140vh] border border-gray-300 flex rounded-3xl"></span>
<span className="absolute scale-[85%] lg:scale-[70%] -rotate-12 w-[100vw] h-[100vw] lg:w-[140vh] lg:h-[140vh] border border-gray-300 flex rounded-3xl"></span>
<span className="absolute scale-[120%] lg:scale-90 -rotate-12 w-[100vw] h-[100vw] lg:w-[140vh] lg:h-[140vh] border border-gray-300 flex rounded-3xl"></span>
<span className="absolute scale-150 lg:scale-110 -rotate-12 w-[100vw] h-[100vw] lg:w-[140vh] lg:h-[140vh] border border-gray-300 hidden lg:flex rounded-3xl"></span>
</div>
</div>
{/* Logo */}
<div className="absolute top-7 left-6">
<img src="../src/assets/logos/amati-logo.png" alt="Amati Logo" className="h-4 w-full object-contain" />
</div>
{/* Main content */}
<div className="flex-1 flex flex-col md:flex-row items-center justify-center pt-10 lg:pt-20 lg:pl-56 md:justify-center lg:justify-end space-x-12 px-6 md:px-0">
{/* Form column */}
<div className="w-full md:w-1/2 mx-auto md:mx-20">
{/* Title and Already have an account */}
<div className="flex flex-col gap-1 mb-7">
<h1 className="text-3xl lg:text-4xl font-extrabold text-left">Daftar Akun</h1>
<p className="text-sm md:text-sm text-gray-400">
Sudah punya akun?{' '}
<a href="/login" className="text-blue-500 font-semibold hover:text-blue-800">
Sign In now
</a>
</p>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
{errorMessage && (
<Alert variant="destructive">
<p>{errorMessage}</p>
</Alert>
)}
{Object.keys(errorFields || {}).length > 0 && (
<Alert variant="destructive">
{Object.values(errorFields || {}).map((msg, idx) => (
<p key={idx}>{msg}</p>
))}
</Alert>
)}
{/* Form fields */}
<div className="space-y-4">
<div className="space-y-4">
<FormField name="name" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Nama Lengkap</FormLabel>
<FormControl>
<Input
placeholder="Masukkan nama lengkap sesuai dengan Kartu Identitas"
{...field}
className={`${form.formState.errors.name ? "border-red-500" : ""} truncate text-sm md:text-base`}
style={{ width: "100%" }}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<FormField name="username" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Username</FormLabel>
<FormControl>
<Input
placeholder="Username"
{...field}
className={form.formState.errors.username ? "border-red-500" : ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="email" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="eg; user@mail.com"
{...field}
className={form.formState.errors.email ? "border-red-500" : ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="password" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Kata Sandi</FormLabel>
<FormControl>
<Input
type="password"
placeholder="******"
{...field}
className={form.formState.errors.password ? "border-red-500" : ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="companyName" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Nama Perusahaan</FormLabel>
<FormControl>
<Input
placeholder="Nama Perusahaan"
{...field}
className={form.formState.errors.companyName ? "border-red-500" : ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="position" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Jabatan</FormLabel>
<FormControl>
<Input
placeholder="Jabatan"
{...field}
className={form.formState.errors.position ? "border-red-500" : ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="workExperience" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Pengalaman Kerja</FormLabel>
<FormControl>
<Input
placeholder="Pengalaman Kerja"
{...field}
className={form.formState.errors.workExperience ? "border-red-500" : ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="address" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Alamat</FormLabel>
<FormControl>
<Input
placeholder="Alamat"
{...field}
className={form.formState.errors.address ? "border-red-500" : ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField name="phoneNumber" render={({ field }) => (
<FormItem>
<FormLabel className="font-bold text-sm">Nomor Telepon</FormLabel>
<FormControl>
<Input
placeholder="Nomor Telepon"
{...field}
className={form.formState.errors.phoneNumber ? "border-red-500" : ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)} />
<FormField
control={form.control}
name="terms"
render={() => (
<FormItem>
<FormControl>
<div className="flex items-center space-x-0.5 pb-10">
<Checkbox
checked={!!form.watch("terms")}
onCheckedChange={(checked) => form.setValue("terms", !!checked)}
className={`border ${form.formState.errors.terms ? "border-red-500" : "border-[#00000099]"}`}
onChange={form.register("terms").onChange}
onBlur={form.register("terms").onBlur}
name="terms"
ref={form.register("terms").ref}
id="terms"
/>
<label
htmlFor="terms"
className="text-sm font-normal leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-[#00000099] p-2 rounded"
>
Saya setuju dengan syarat dan layanan
</label>
</div>
</FormControl>
{form.formState.errors.terms && (
<FormMessage>{form.formState.errors.terms.message}</FormMessage>
)}
</FormItem>
)}
/>
</div>
<div className="space-y-4 pb-6">
<div className="flex justify-end">
<Button
type="submit"
className="w-full flex items-center justify-between text-base font-medium md:w-auto bg-[--primary-color] text-white"
>
<span className="flex-grow text-left">Daftar Akun</span>
<TbArrowNarrowRight className="ml-12 h-5 w-5" />
</Button>
</div>
</div>
</form>
</Form>
</div>
</div>
</div>
);
}

View File

@ -15,6 +15,12 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
// Custom variants for status
waiting: "border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
accepted: "border-transparent bg-green-500 text-white hover:bg-green-600",
rejected: "border-transparent bg-red-500 text-white hover:bg-red-600",
completed: "border-transparent bg-blue-500 text-white hover:bg-blue-600",
},
},
defaultVariants: {

View File

@ -1,6 +1,7 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { TbLoader2 } from "react-icons/tb"
import { cn } from "@/lib/utils"
@ -37,17 +38,25 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
isLoading?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
({ className, variant, size, asChild = false, isLoading, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={isLoading || props.disabled} // Disable button if loading
{...props}
/>
>
{isLoading ? (
<TbLoader2 className="mr-2 h-4 w-4 animate-spin" /> // Show spinner when loading
) : (
children
)}
</Comp>
)
}
)

View File

@ -0,0 +1,6 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
.font-inter {
font-family: "Inter", sans-serif;
font-optical-sizing: auto;
}