diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index 85d98d6..fb8f84d 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -21,6 +21,13 @@ const sidebarMenus: SidebarMenu[] = [ link: "/questions", color: "green", }, + { + label: "Permohonan Asesmen", + icon: { tb: "TbChecklist" }, + allowedPermissions: ["permissions.read"], + link: "/assessmentRequest", + color: "green", + }, ]; 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/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/src/modules/assessmentRequestManagement/modals/ConfirmModal.tsx b/apps/frontend/src/modules/assessmentRequestManagement/modals/ConfirmModal.tsx new file mode 100644 index 0000000..a05220b --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequestManagement/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/assessmentRequestManagement/modals/CreateAssessmentRequestModal.tsx b/apps/frontend/src/modules/assessmentRequestManagement/modals/CreateAssessmentRequestModal.tsx new file mode 100644 index 0000000..ab53f48 --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequestManagement/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/assessmentRequestManagement/queries/assessmentRequestQueries.ts b/apps/frontend/src/modules/assessmentRequestManagement/queries/assessmentRequestQueries.ts new file mode 100644 index 0000000..1be2a03 --- /dev/null +++ b/apps/frontend/src/modules/assessmentRequestManagement/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/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 3f1430d..c0c2314 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -17,6 +17,7 @@ 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' // Create Virtual Routes @@ -83,6 +84,16 @@ 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, + ), + ) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -129,6 +140,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LogoutIndexLazyImport parentRoute: typeof rootRoute } + '/_dashboardLayout/assessmentRequest/': { + id: '/_dashboardLayout/assessmentRequest/' + path: '/assessmentRequest' + fullPath: '/assessmentRequest' + preLoaderRoute: typeof DashboardLayoutAssessmentRequestIndexImport + parentRoute: typeof DashboardLayoutImport + } '/_dashboardLayout/dashboard/': { id: '/_dashboardLayout/dashboard/' path: '/dashboard' @@ -158,6 +176,7 @@ declare module '@tanstack/react-router' { export const routeTree = rootRoute.addChildren({ IndexLazyRoute, DashboardLayoutRoute: DashboardLayoutRoute.addChildren({ + DashboardLayoutAssessmentRequestIndexRoute, DashboardLayoutDashboardIndexRoute, DashboardLayoutTimetableIndexRoute, DashboardLayoutUsersIndexRoute, @@ -190,6 +209,7 @@ export const routeTree = rootRoute.addChildren({ "/_dashboardLayout": { "filePath": "_dashboardLayout.tsx", "children": [ + "/_dashboardLayout/assessmentRequest/", "/_dashboardLayout/dashboard/", "/_dashboardLayout/timetable/", "/_dashboardLayout/users/" @@ -207,6 +227,10 @@ export const routeTree = rootRoute.addChildren({ "/logout/": { "filePath": "logout/index.lazy.tsx" }, + "/_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/_dashboardLayout/assessmentRequest/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx new file mode 100644 index 0000000..1a3503b --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.lazy.tsx @@ -0,0 +1,153 @@ +import { useState } from "react"; +import { assessmentRequestQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestQueries"; +import PageTemplate from "@/components/PageTemplate"; +import { createLazyFileRoute } from "@tanstack/react-router"; +import FormModal from "@/modules/assessmentRequestManagement/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/assessmentRequestManagement/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..3eb169d --- /dev/null +++ b/apps/frontend/src/routes/_dashboardLayout/assessmentRequest/index.tsx @@ -0,0 +1,18 @@ +import { assessmentRequestQueryOptions } from "@/modules/assessmentRequestManagement/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/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 + )} + ) } )