Merge pull request #25 from digitalsolutiongroup/assessment-request-frontend

Assessment request frontend
This commit is contained in:
Fikri 2024-10-09 11:53:23 +07:00 committed by GitHub
commit d8c95229fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 534 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,18 @@
import { assessmentRequestQueryOptions } from "@/modules/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));
},
});

View File

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

View File

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