diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index 85d98d6..3c2fd86 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -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; diff --git a/apps/backend/src/middlewares/authInfo.ts b/apps/backend/src/middlewares/authInfo.ts index 10514c2..957dc24 100644 --- a/apps/backend/src/middlewares/authInfo.ts +++ b/apps/backend/src/middlewares/authInfo.ts @@ -71,6 +71,7 @@ const authInfo = createMiddleware(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), diff --git a/apps/backend/src/routes/assessmentRequest/route.ts b/apps/backend/src/routes/assessmentRequest/route.ts index 3f189a9..ff912bf 100644 --- a/apps/backend/src/routes/assessmentRequest/route.ts +++ b/apps/backend/src/routes/assessmentRequest/route.ts @@ -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() // 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`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); } ); diff --git a/apps/backend/src/routes/assessmentRequestManagement/route.ts b/apps/backend/src/routes/assessmentRequestManagement/route.ts index 802c702..8822f31 100644 --- a/apps/backend/src/routes/assessmentRequestManagement/route.ts +++ b/apps/backend/src/routes/assessmentRequestManagement/route.ts @@ -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() - .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() + .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`count(*)`, }) - ), - async (c) => { - const { page, limit, q } = c.req.valid("query"); - - const totalCountQuery = sql`(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"); - - 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.", - }); - } - ) + .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); + 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, + }, + }); + } + ) + + // 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", + }); + + 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"]), + }) + ), + 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; diff --git a/apps/backend/src/routes/managementAspect/route.ts b/apps/backend/src/routes/managementAspect/route.ts index ebf11fd..0a5699f 100644 --- a/apps/backend/src/routes/managementAspect/route.ts +++ b/apps/backend/src/routes/managementAspect/route.ts @@ -80,9 +80,19 @@ const managementAspectRoute = new Hono() async (c) => { const { includeTrashed, page, limit, q } = c.req.valid("query"); - const totalCountQuery = includeTrashed - ? sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})` - : sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; + const aspectCountQuery = await db + .select({ + count: sql`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() 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() 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() 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() 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(); + + // 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, })) @@ -305,7 +329,7 @@ const managementAspectRoute = new Hono() 201 ); } - ) + ) // Update aspect .patch( @@ -379,10 +403,20 @@ const managementAspectRoute = new Hono() ); } + // 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() 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({ diff --git a/apps/backend/src/types/HonoEnv.d.ts b/apps/backend/src/types/HonoEnv.d.ts index 4cf35db..e9ceb7d 100644 --- a/apps/backend/src/types/HonoEnv.d.ts +++ b/apps/backend/src/types/HonoEnv.d.ts @@ -5,6 +5,7 @@ type HonoEnv = { Variables: { uid?: string; currentUser?: { + id: string; name: string; permissions: SpecificPermissionCode[]; roles: RoleCode[]; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6fd0053..6034b9f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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", diff --git a/apps/frontend/src/components/NavbarMenuItem.tsx b/apps/frontend/src/components/NavbarMenuItem.tsx index 4b96a4a..d44e45a 100644 --- a/apps/frontend/src/components/NavbarMenuItem.tsx +++ b/apps/frontend/src/components/NavbarMenuItem.tsx @@ -86,7 +86,7 @@ export default function MenuItem({ menu, isActive, onClick }: Props) { {/* Label */} - {menu.label} + {menu.label} {/* Chevron Icon */} {hasChildren && ( diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 6f4ed59..c6f68d5 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -69,5 +69,5 @@ } :root { - --primary-color: #2555FF + --primary-color: #2555FF; } \ No newline at end of file diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 6e738f1..6ba9c8f 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -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( diff --git a/apps/frontend/src/modules/aspectManagement/modals/AspectDeleteModal.tsx b/apps/frontend/src/modules/aspectManagement/modals/AspectDeleteModal.tsx new file mode 100644 index 0000000..c971a90 --- /dev/null +++ b/apps/frontend/src/modules/aspectManagement/modals/AspectDeleteModal.tsx @@ -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 ( + navigate({ search: {} })} + title={`Konfirmasi Hapus`} + > + + Apakah Anda yakin ingin menghapus aspek{" "} + + {aspectQuery.data?.name} + + ? Tindakan ini tidak dapat diubah. + + + {/* Buttons */} + + + + + + ); +} \ No newline at end of file diff --git a/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx new file mode 100644 index 0000000..1079c67 --- /dev/null +++ b/apps/frontend/src/modules/aspectManagement/modals/AspectFormModal.tsx @@ -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[0] } + | { action: "create"; data: Parameters[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 ( + navigate({ search: {} })} + title={modalTitle} + scrollAreaComponent={ScrollArea.Autosize} + size="md" + > +
handleSubmit(values))}> + + + {form.values.subAspects.map((field, index) => ( + + { + 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" && ( + Jumlah Soal: {field.questionCount} + )} + {formType !== "detail" && ( + + )} + + ))} + + {formType !== "detail" && ( + + )} + + {/* Buttons */} + + + {formType !== "detail" && ( + + )} + + +
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/modules/aspectManagement/queries/aspectQueries.ts b/apps/frontend/src/modules/aspectManagement/queries/aspectQueries.ts new file mode 100644 index 0000000..19d09d2 --- /dev/null +++ b/apps/frontend/src/modules/aspectManagement/queries/aspectQueries.ts @@ -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() + ); +}; \ No newline at end of file diff --git a/apps/frontend/src/modules/assessmentRequest/modals/ConfirmModal.tsx b/apps/frontend/src/modules/assessmentRequest/modals/ConfirmModal.tsx new file mode 100644 index 0000000..a05220b --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequest/modals/ConfirmModal.tsx @@ -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 ( + + Apakah Anda yakin ingin memulai asesmen ini? + + + + + + ); +} diff --git a/apps/frontend/src/modules/assessmentRequest/modals/CreateAssessmentRequestModal.tsx b/apps/frontend/src/modules/assessmentRequest/modals/CreateAssessmentRequestModal.tsx new file mode 100644 index 0000000..ab53f48 --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequest/modals/CreateAssessmentRequestModal.tsx @@ -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 = Konfirmasi + + + 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 ( + navigate({ search: {} })} + title= {modalTitle} + size="md" + > +
handleSubmit(values))}> + + Apakah anda yakin ingin membuat Permohonan Asesmen Baru? + + {/* 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 */} + + + + +
+
+ ); +} diff --git a/apps/frontend/src/modules/assessmentRequest/queries/assessmentRequestQueries.ts b/apps/frontend/src/modules/assessmentRequest/queries/assessmentRequestQueries.ts new file mode 100644 index 0000000..1be2a03 --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequest/queries/assessmentRequestQueries.ts @@ -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(); + }; \ No newline at end of file diff --git a/apps/frontend/src/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal.tsx b/apps/frontend/src/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal.tsx new file mode 100644 index 0000000..1aaa767 --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal.tsx @@ -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 ( + + + {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"), + }, + ], + })} + + + + {status !== "selesai" && ( + <> + + + + )} + + + + ); +} diff --git a/apps/frontend/src/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries.ts b/apps/frontend/src/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries.ts new file mode 100644 index 0000000..60d6e0f --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries.ts @@ -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 }, + }) + ); +} diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 3f1430d..22543a9 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -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" diff --git a/apps/frontend/src/routes/__root.tsx b/apps/frontend/src/routes/__root.tsx index 91b0ffe..4dd18f8 100644 --- a/apps/frontend/src/routes/__root.tsx +++ b/apps/frontend/src/routes/__root.tsx @@ -8,7 +8,7 @@ interface RouteContext { export const Route = createRootRouteWithContext()({ component: () => ( -
+
diff --git a/apps/frontend/src/routes/_dashboardLayout/aspect/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/aspect/index.lazy.tsx new file mode 100644 index 0000000..e9b9c57 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/aspect/index.lazy.tsx @@ -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; + +const columnHelper = createColumnHelper(); + +export default function AspectPage() { + return ( + , ]} + 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 ? ( + + {subAspects.map((subAspect, index) => ( + + {subAspect.name} + {index < subAspects.length - 1 ? ", " : ""} + + ))} + + ) : ( + Tidak ada Sub Aspek + ); + }, + }), + + // Actions columns + columnHelper.display({ + header: "Aksi", + cell: (props) => ( + + {createActionButtons([ + { + label: "Detail", + permission: true, + action: `?detail=${props.row.original.id}`, + color: "green", + icon: , + }, + { + label: "Edit", + permission: true, + action: `?edit=${props.row.original.id}`, + color: "orange", + icon: , + }, + { + label: "Hapus", + permission: true, + action: `?delete=${props.row.original.id}`, + color: "red", + icon: , + }, + ])} + + ), + }), + ]} + /> + ); +} \ No newline at end of file diff --git a/apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx b/apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx new file mode 100644 index 0000000..bad7abb --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx @@ -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)); + }, +}); diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx new file mode 100644 index 0000000..f2aa30f --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx @@ -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; + +const columnHelper = createColumnHelper(); + +export default function UsersPage() { + const [modalOpen, setModalOpen] = useState(false); + const [selectedAssessmentId, setSelectedAssessmentId] = useState(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 ( + <> + ]} + 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 Menunggu Konfirmasi; + case "diterima": + return Diterima; + case "ditolak": + return Ditolak; + case "selesai": + return Selesai; + default: + return Tidak diketahui; + } + }, + }), + 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 ( +
+ {/* Button Create Assessment */} + {status === "selesai" ? ( + + ) : status === "diterima" ? ( + + ) : ( + + )} + + {/* Button View Result */} + {status === "selesai" ? ( + + ) : ( + + )} +
+ ); + }, + }), + ]} + /> + + {/* Confirmation Modal to Start Assessment */} + {selectedAssessmentId && ( + setModalOpen(false)} + onConfirm={handleStartAssessment} + /> + )} + + ); +} diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.tsx new file mode 100644 index 0000000..2cff5d2 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.tsx @@ -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)); + }, +}); diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.lazy.tsx new file mode 100644 index 0000000..77f21a8 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.lazy.tsx @@ -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; + +const columnHelper = createColumnHelper(); + +export default function AssessmentRequestManagementsPage() { + const [selectedId, setSelectedId] = useState(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 ( + { + 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 {statusLabel}; + }, + }), + + columnHelper.display({ + header: "Aksi", + cell: (props) => ( + + {createActionButtons([ + { + label: "Detail", + permission: true, + action: () => handleDetailClick(props.row.original.idPermohonan), + color: "green", + icon: , + }, + ])} + + ), + }), + ]} + /> + ); +} diff --git a/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.tsx new file mode 100644 index 0000000..426c086 --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequestManagements/index.tsx @@ -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)); + }, +}); diff --git a/apps/frontend/src/routes/register/index.lazy.tsx b/apps/frontend/src/routes/register/index.lazy.tsx new file mode 100644 index 0000000..3468815 --- /dev/null +++ b/apps/frontend/src/routes/register/index.lazy.tsx @@ -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; + +export const Route = createLazyFileRoute("/register/")({ + component: RegisterPage, +}); + +export default function RegisterPage() { + const [errorFields, setErrorFields] = useState> | null>(null); + const [errorMessage, setErrorMessage] = useState(null); + + const navigate = useNavigate(); + + const form = useForm({ + 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 ( +
+ {/* Image */} +
+
+ + + + +
+
+ + {/* Logo */} +
+ Amati Logo +
+ + {/* Main content */} +
+ + {/* Form column */} +
+ + {/* Title and Already have an account */} +
+

Daftar Akun

+

+ Sudah punya akun?{' '} + + Sign In now + +

+
+ +
+ + {errorMessage && ( + +

{errorMessage}

+
+ )} + + {Object.keys(errorFields || {}).length > 0 && ( + + {Object.values(errorFields || {}).map((msg, idx) => ( +

{msg}

+ ))} +
+ )} + + {/* Form fields */} +
+
+ ( + + Nama Lengkap + + + + + + )} /> +
+ + ( + + Username + + + + + + )} /> + + ( + + Email + + + + + + )} /> + + ( + + Kata Sandi + + + + + + )} /> + + ( + + Nama Perusahaan + + + + + + )} /> + + ( + + Jabatan + + + + + + )} /> + + ( + + Pengalaman Kerja + + + + + + )} /> + + ( + + Alamat + + + + + + )} /> + + ( + + Nomor Telepon + + + + + + )} /> + + ( + + +
+ 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" + /> + +
+
+ {form.formState.errors.terms && ( + {form.formState.errors.terms.message} + )} +
+ )} + /> +
+ +
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/apps/frontend/src/shadcn/components/ui/badge.tsx b/apps/frontend/src/shadcn/components/ui/badge.tsx index f000e3e..d6abc25 100644 --- a/apps/frontend/src/shadcn/components/ui/badge.tsx +++ b/apps/frontend/src/shadcn/components/ui/badge.tsx @@ -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: { diff --git a/apps/frontend/src/shadcn/components/ui/button.tsx b/apps/frontend/src/shadcn/components/ui/button.tsx index 0ba4277..f79e70e 100644 --- a/apps/frontend/src/shadcn/components/ui/button.tsx +++ b/apps/frontend/src/shadcn/components/ui/button.tsx @@ -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, VariantProps { asChild?: boolean + isLoading?: boolean } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, asChild = false, isLoading, children, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( + > + {isLoading ? ( + // Show spinner when loading + ) : ( + children + )} + ) } ) diff --git a/apps/frontend/src/styles/fonts/inter.css b/apps/frontend/src/styles/fonts/inter.css new file mode 100644 index 0000000..3fa8fb1 --- /dev/null +++ b/apps/frontend/src/styles/fonts/inter.css @@ -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; +}