diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index d501029..3c2fd86 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -35,6 +35,13 @@ const sidebarMenus: SidebarMenu[] = [ 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/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/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/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/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)); + }, +});