Pull Request branch dev-clone to main #1
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, eq, ilike, or, sql } from "drizzle-orm";
|
||||
import { and, eq, ilike, or, sql, desc } from "drizzle-orm";
|
||||
import { Hono } from "hono";
|
||||
import checkPermission from "../../middlewares/checkPermission";
|
||||
import { z } from "zod";
|
||||
|
|
@ -49,16 +49,10 @@
|
|||
async (c) => {
|
||||
const { page, limit, q } = c.req.valid("query");
|
||||
|
||||
const totalCountQuery = sql<number>`(SELECT count(*) FROM ${assessments})`;
|
||||
|
||||
const result = await db
|
||||
// Query untuk menghitung total jumlah item (totalCountQuery)
|
||||
const assessmentCountQuery = await db
|
||||
.select({
|
||||
idPermohonan: assessments.id,
|
||||
namaResponden: users.name,
|
||||
namaPerusahaan: respondents.companyName,
|
||||
status: assessments.status,
|
||||
tanggal: assessments.createdAt,
|
||||
fullCount: totalCountQuery,
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(assessments)
|
||||
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||
|
|
@ -68,10 +62,37 @@
|
|||
? or(
|
||||
ilike(users.name, `%${q}%`),
|
||||
ilike(respondents.companyName, `%${q}%`),
|
||||
sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`,
|
||||
eq(assessments.id, q)
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const totalItems = Number(assessmentCountQuery[0]?.count) || 0;
|
||||
|
||||
// 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,
|
||||
})
|
||||
.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);
|
||||
|
||||
|
|
@ -85,10 +106,8 @@
|
|||
})),
|
||||
_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,
|
||||
},
|
||||
});
|
||||
|
|
@ -104,7 +123,6 @@
|
|||
|
||||
const queryResult = await db
|
||||
.select({
|
||||
// id: assessments.id,
|
||||
tanggal: assessments.createdAt,
|
||||
nama: users.name,
|
||||
posisi: respondents.position,
|
||||
|
|
|
|||
|
|
@ -80,9 +80,19 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
|||
async (c) => {
|
||||
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
||||
|
||||
const totalCountQuery = includeTrashed
|
||||
? sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})`
|
||||
: sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`;
|
||||
const aspectCountQuery = await db
|
||||
.select({
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(aspects)
|
||||
.where(
|
||||
and(
|
||||
includeTrashed ? undefined : isNull(aspects.deletedAt),
|
||||
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
|
||||
)
|
||||
);
|
||||
|
||||
const totalItems = Number(aspectCountQuery[0]?.count) || 0;
|
||||
|
||||
const aspectIdsQuery = await db
|
||||
.select({
|
||||
|
|
@ -95,6 +105,7 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
|||
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
|
||||
)
|
||||
)
|
||||
.orderBy(aspects.name)
|
||||
.offset(page * limit)
|
||||
.limit(limit);
|
||||
|
||||
|
|
@ -128,11 +139,11 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
|||
FROM ${questions}
|
||||
WHERE ${questions.subAspectId} = ${subAspects.id}
|
||||
)`.as('questionCount'),
|
||||
fullCount: totalCountQuery,
|
||||
})
|
||||
.from(aspects)
|
||||
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
|
||||
.where(inArray(aspects.id, aspectIds));
|
||||
.where(inArray(aspects.id, aspectIds))
|
||||
.orderBy(aspects.name);
|
||||
|
||||
// Grouping sub aspects by aspect ID
|
||||
const groupedResult = result.reduce((acc, curr) => {
|
||||
|
|
@ -176,8 +187,8 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
|||
data: groupedArray,
|
||||
_metadata: {
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit),
|
||||
totalItems: Number(result[0]?.fullCount) ?? 0,
|
||||
totalPages: Math.ceil(totalItems / limit),
|
||||
totalItems,
|
||||
perPage: limit,
|
||||
},
|
||||
});
|
||||
|
|
@ -287,10 +298,23 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
|||
if (aspectData.subAspects) {
|
||||
const subAspectsArray = JSON.parse(aspectData.subAspects) as string[];
|
||||
|
||||
// Insert new sub aspects into the database without checking for sub aspect duplication
|
||||
if (subAspectsArray.length) {
|
||||
// Create a Set to check for duplicates
|
||||
const uniqueSubAspects = new Set<string>();
|
||||
|
||||
// Filter out duplicates
|
||||
const filteredSubAspects = subAspectsArray.filter((subAspect) => {
|
||||
if (uniqueSubAspects.has(subAspect)) {
|
||||
return false; // Skip duplicates
|
||||
}
|
||||
uniqueSubAspects.add(subAspect);
|
||||
return true; // Keep unique sub-aspects
|
||||
});
|
||||
|
||||
// Check if there are any unique sub aspects to insert
|
||||
if (filteredSubAspects.length) {
|
||||
// Insert new sub aspects into the database
|
||||
await db.insert(subAspects).values(
|
||||
subAspectsArray.map((subAspect) => ({
|
||||
filteredSubAspects.map((subAspect) => ({
|
||||
aspectId,
|
||||
name: subAspect,
|
||||
}))
|
||||
|
|
@ -379,10 +403,20 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
|||
);
|
||||
}
|
||||
|
||||
// Create a Set to check for duplicate sub-aspects
|
||||
const uniqueSubAspectNames = new Set(currentSubAspects.map(sub => sub.name));
|
||||
|
||||
// Update or add new sub aspects
|
||||
for (const subAspect of newSubAspects) {
|
||||
const existingSubAspect = currentSubAspectMap.has(subAspect.id);
|
||||
|
||||
// Check for duplicate sub-aspect names
|
||||
if (uniqueSubAspectNames.has(subAspect.name) && !existingSubAspect) {
|
||||
throw notFound({
|
||||
message: `Sub aspect name "${subAspect.name}" already exists for this aspect.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (existingSubAspect) {
|
||||
// Update if sub aspect already exists
|
||||
await db
|
||||
|
|
@ -402,12 +436,14 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
|||
await db
|
||||
.insert(subAspects)
|
||||
.values({
|
||||
id: subAspect.id,
|
||||
aspectId,
|
||||
name: subAspect.name,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Add the name to the Set after processing
|
||||
uniqueSubAspectNames.add(subAspect.name);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
|
|
|
|||
1
apps/backend/src/types/HonoEnv.d.ts
vendored
1
apps/backend/src/types/HonoEnv.d.ts
vendored
|
|
@ -5,6 +5,7 @@ type HonoEnv = {
|
|||
Variables: {
|
||||
uid?: string;
|
||||
currentUser?: {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: SpecificPermissionCode[];
|
||||
roles: RoleCode[];
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export default function MenuItem({ menu, isActive, onClick }: Props) {
|
|||
<Icon className="w-4 h-4" />
|
||||
</span>
|
||||
{/* Label */}
|
||||
<span className="text-xs font-bold">{menu.label}</span>
|
||||
<span className="text-xs font-bold whitespace-normal">{menu.label}</span>
|
||||
</div>
|
||||
{/* Chevron Icon */}
|
||||
{hasChildren && (
|
||||
|
|
|
|||
|
|
@ -69,5 +69,5 @@
|
|||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #2555FF
|
||||
--primary-color: #2555FF;
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
|
|||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import "./styles/tailwind.css";
|
||||
import "./styles/fonts/manrope.css";
|
||||
import "./styles/fonts/inter.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import client from "@/honoClient";
|
||||
import { Button, Flex, Modal, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getRouteApi, useSearch } from "@tanstack/react-router";
|
||||
import { deleteAspect } from "../queries/aspectQueries";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import fetchRPC from "@/utils/fetchRPC";
|
||||
|
||||
const routeApi = getRouteApi("/_dashboardLayout/aspect/");
|
||||
|
||||
export default function AspectDeleteModal() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const searchParams = useSearch({ from: "/_dashboardLayout/aspect/" }) as {
|
||||
delete: string;
|
||||
};
|
||||
|
||||
const aspectId = searchParams.delete;
|
||||
const navigate = routeApi.useNavigate();
|
||||
|
||||
const aspectQuery = useQuery({
|
||||
queryKey: ["management-aspect", aspectId],
|
||||
queryFn: async () => {
|
||||
if (!aspectId) return null;
|
||||
return await fetchRPC(
|
||||
client["management-aspect"][":id"].$get({
|
||||
param: {
|
||||
id: aspectId,
|
||||
},
|
||||
query: {},
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["deleteAspectMutation"],
|
||||
mutationFn: async ({ id }: { id: string }) => {
|
||||
return await deleteAspect(id);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
notifications.show({
|
||||
message: error.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
message: "Aspek berhasil dihapus.",
|
||||
color: "green",
|
||||
});
|
||||
queryClient.removeQueries({ queryKey: ["management-aspect", aspectId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["management-aspect"] });
|
||||
navigate({ search: {} });
|
||||
},
|
||||
});
|
||||
|
||||
const isModalOpen = Boolean(searchParams.delete && aspectQuery.data);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isModalOpen}
|
||||
onClose={() => navigate({ search: {} })}
|
||||
title={`Konfirmasi Hapus`}
|
||||
>
|
||||
<Text size="sm">
|
||||
Apakah Anda yakin ingin menghapus aspek{" "}
|
||||
<Text span fw={700}>
|
||||
{aspectQuery.data?.name}
|
||||
</Text>
|
||||
? Tindakan ini tidak dapat diubah.
|
||||
</Text>
|
||||
|
||||
{/* Buttons */}
|
||||
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate({ search: {} })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
type="submit"
|
||||
color="red"
|
||||
loading={mutation.isPending}
|
||||
onClick={() => mutation.mutate({ id: aspectId })}
|
||||
>
|
||||
Hapus Aspek
|
||||
</Button>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import { Button, Flex, Modal, ScrollArea, TextInput, Group, Text } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getRouteApi } from "@tanstack/react-router";
|
||||
import { createAspect, updateAspect, getAspectByIdQueryOptions } from "../queries/aspectQueries";
|
||||
import { TbDeviceFloppy } from "react-icons/tb";
|
||||
import { useEffect } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import FormResponseError from "@/errors/FormResponseError";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
// Initialize route API
|
||||
const routeApi = getRouteApi("/_dashboardLayout/aspect/");
|
||||
|
||||
export default function AspectFormModal() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = routeApi.useNavigate();
|
||||
const searchParams = routeApi.useSearch();
|
||||
const dataId = searchParams.detail || searchParams.edit;
|
||||
const isModalOpen = Boolean(dataId || searchParams.create);
|
||||
const formType = searchParams.detail ? "detail" : searchParams.edit ? "edit" : "create";
|
||||
|
||||
// Fetch aspect data if editing or viewing details
|
||||
const aspectQuery = useQuery(getAspectByIdQueryOptions(dataId));
|
||||
|
||||
const modalTitle = `${formType.charAt(0).toUpperCase() + formType.slice(1)} Aspek`;
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
id: "",
|
||||
name: "",
|
||||
subAspects: [{ id: "", name: "", questionCount: 0 }] as { id: string; name: string; questionCount: number }[],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const data = aspectQuery.data;
|
||||
|
||||
if (!data) {
|
||||
form.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValues({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
subAspects: data.subAspects?.map(subAspect => ({
|
||||
id: subAspect.id || "",
|
||||
name: subAspect.name,
|
||||
questionCount: subAspect.questionCount || 0,
|
||||
})) || [],
|
||||
});
|
||||
|
||||
form.setErrors({});
|
||||
}, [aspectQuery.data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["aspectMutation"],
|
||||
mutationFn: async (
|
||||
options:
|
||||
| { action: "edit"; data: Parameters<typeof updateAspect>[0] }
|
||||
| { action: "create"; data: Parameters<typeof createAspect>[0] }
|
||||
) => {
|
||||
return options.action === "edit"
|
||||
? await updateAspect(options.data)
|
||||
: await createAspect(options.data);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof FormResponseError) {
|
||||
form.setErrors(error.formErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
notifications.show({
|
||||
message: error.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
type CreateAspectPayload = {
|
||||
name: string;
|
||||
subAspects?: string;
|
||||
};
|
||||
|
||||
type EditAspectPayload = {
|
||||
id: string;
|
||||
name: string;
|
||||
subAspects?: string;
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
try {
|
||||
// Name field validation
|
||||
if (values.name.trim() === "") {
|
||||
form.setErrors({ name: "Nama aspek harus diisi" });
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: CreateAspectPayload | EditAspectPayload;
|
||||
|
||||
if (formType === "create") {
|
||||
payload = {
|
||||
name: values.name,
|
||||
subAspects: values.subAspects.length > 0
|
||||
? JSON.stringify(
|
||||
values.subAspects
|
||||
.filter(subAspect => subAspect.name.trim() !== "")
|
||||
.map(subAspect => subAspect.name)
|
||||
)
|
||||
: "",
|
||||
};
|
||||
await createAspect(payload);
|
||||
} else if (formType === "edit") {
|
||||
// Add validation for aspect name here
|
||||
payload = {
|
||||
id: values.id,
|
||||
name: values.name,
|
||||
subAspects: values.subAspects.length > 0
|
||||
? JSON.stringify(
|
||||
values.subAspects
|
||||
.filter(subAspect => subAspect.name.trim() !== "")
|
||||
.map(subAspect => ({
|
||||
id: subAspect.id || "",
|
||||
name: subAspect.name,
|
||||
questionCount: subAspect.questionCount || 0,
|
||||
}))
|
||||
)
|
||||
: "",
|
||||
};
|
||||
await updateAspect(payload);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["management-aspect"] });
|
||||
|
||||
notifications.show({
|
||||
message: `Aspek ${formType === "create" ? "berhasil dibuat" : "berhasil diedit"}`,
|
||||
});
|
||||
|
||||
navigate({ search: {} });
|
||||
} catch (error) {
|
||||
console.error("Error during submit:", error);
|
||||
|
||||
if (error instanceof Error && error.message === "Aspect name already exists") {
|
||||
notifications.show({
|
||||
message: "Nama aspek sudah ada. Silakan gunakan nama lain.",
|
||||
color: "red",
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
message: "Nama Sub Aspek sudah ada. Silakan gunakan nama lain.",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isModalOpen}
|
||||
onClose={() => navigate({ search: {} })}
|
||||
title={modalTitle}
|
||||
scrollAreaComponent={ScrollArea.Autosize}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||
<TextInput
|
||||
type="text"
|
||||
label="Nama"
|
||||
{...form.getInputProps("name")}
|
||||
disabled={formType === "detail"}
|
||||
error={form.errors.name}
|
||||
/>
|
||||
|
||||
{form.values.subAspects.map((field, index) => (
|
||||
<Group key={index} mt="md" align="center">
|
||||
<TextInput
|
||||
type="text"
|
||||
label={`Sub Aspek ${index + 1}`}
|
||||
value={field.name}
|
||||
onChange={(event) => {
|
||||
const newSubAspects = [...form.values.subAspects];
|
||||
newSubAspects[index] = { ...newSubAspects[index], name: event.target.value };
|
||||
form.setValues({ subAspects: newSubAspects });
|
||||
}}
|
||||
disabled={formType === "detail"}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{formType === "detail" && (
|
||||
<Text>Jumlah Soal: {field.questionCount}</Text>
|
||||
)}
|
||||
{formType !== "detail" && (
|
||||
<Button
|
||||
className="mt-6"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newSubAspects = form.values.subAspects.filter((_, i) => i !== index);
|
||||
form.setValues({ subAspects: newSubAspects });
|
||||
}}
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
|
||||
{formType !== "detail" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
mt="md"
|
||||
onClick={() => {
|
||||
const newSubAspects = [
|
||||
...form.values.subAspects,
|
||||
{ id: createId(), name: "", questionCount: 0 }
|
||||
];
|
||||
form.setValues({ subAspects: newSubAspects });
|
||||
}}
|
||||
>
|
||||
Tambah Sub Aspek
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate({ search: {} })}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Tutup
|
||||
</Button>
|
||||
{formType !== "detail" && (
|
||||
<Button
|
||||
variant="filled"
|
||||
leftSection={<TbDeviceFloppy size={20} />}
|
||||
type="submit"
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Simpan
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { Button, Flex, Modal, ScrollArea } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getRouteApi } from "@tanstack/react-router";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { fetchAssessmentRequestManagementById, updateAssessmentRequestManagementStatus } from "../queries/assessmentRequestManagementQueries";
|
||||
import createInputComponents from "@/utils/createInputComponents"; // Assuming you have this utility
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Define the API route for navigation
|
||||
const routeApi = getRouteApi("/_dashboardLayout/assessmentRequestManagements/");
|
||||
|
||||
// Define allowed status values
|
||||
type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "selesai";
|
||||
|
||||
interface AssessmentRequestManagementFormModalProps {
|
||||
assessmentId: string | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AssessmentRequestManagementFormModal({
|
||||
assessmentId,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: AssessmentRequestManagementFormModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = routeApi.useNavigate();
|
||||
|
||||
const AssessmentRequestManagementQuery = useQuery({
|
||||
queryKey: ["assessmentRequestManagements", assessmentId],
|
||||
queryFn: async () => {
|
||||
if (!assessmentId) return null;
|
||||
return await fetchAssessmentRequestManagementById(assessmentId);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
tanggal: "",
|
||||
nama: "",
|
||||
posisi: "",
|
||||
pengalamanKerja: "",
|
||||
email: "",
|
||||
namaPerusahaan: "",
|
||||
alamat: "",
|
||||
nomorTelepon: "",
|
||||
username: "",
|
||||
status: "menunggu konfirmasi" as AssessmentStatus,
|
||||
},
|
||||
});
|
||||
|
||||
// Populate the form once data is available
|
||||
useEffect(() => {
|
||||
if (AssessmentRequestManagementQuery.data) {
|
||||
form.setValues({
|
||||
tanggal: formatDate(AssessmentRequestManagementQuery.data.tanggal || "Data Kosong"),
|
||||
nama: AssessmentRequestManagementQuery.data.nama || "Data Kosong",
|
||||
posisi: AssessmentRequestManagementQuery.data.posisi || "Data Kosong",
|
||||
pengalamanKerja: AssessmentRequestManagementQuery.data.pengalamanKerja || "Data Kosong",
|
||||
email: AssessmentRequestManagementQuery.data.email || "Data Kosong",
|
||||
namaPerusahaan: AssessmentRequestManagementQuery.data.namaPerusahaan || "Data Kosong",
|
||||
alamat: AssessmentRequestManagementQuery.data.alamat || "Data Kosong",
|
||||
nomorTelepon: AssessmentRequestManagementQuery.data.nomorTelepon || "Data Kosong",
|
||||
username: AssessmentRequestManagementQuery.data.username || "Data Kosong",
|
||||
status: AssessmentRequestManagementQuery.data.status || "menunggu konfirmasi",
|
||||
});
|
||||
}
|
||||
}, [AssessmentRequestManagementQuery.data, form]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationKey: ["updateAssessmentRequestManagementStatusMutation"],
|
||||
mutationFn: async ({
|
||||
id,
|
||||
status,
|
||||
}: {
|
||||
id: string;
|
||||
status: AssessmentStatus;
|
||||
}) => {
|
||||
return await updateAssessmentRequestManagementStatus(id, status);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (error instanceof Error) {
|
||||
notifications.show({
|
||||
message: error.message,
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
message: "Status Permohonan Asesmen berhasil diperbarui.",
|
||||
color: "green",
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["assessmentRequestManagements", assessmentId],
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleStatusChange = (status: AssessmentStatus) => {
|
||||
if (assessmentId) {
|
||||
mutation.mutate({ id: assessmentId, status });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "Tanggal tidak tersedia";
|
||||
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return "Tanggal tidak valid";
|
||||
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
hour12: true,
|
||||
minute: "2-digit",
|
||||
hour: "2-digit",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const { status } = form.values;
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Detail Permohonan Asesmen">
|
||||
<ScrollArea style={{ height: "400px", paddingRight: "15px" }} scrollbarSize={8}>
|
||||
{createInputComponents({
|
||||
disableAll: mutation.isPending,
|
||||
readonlyAll: true,
|
||||
inputs: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Tanggal",
|
||||
...form.getInputProps("tanggal"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Nama",
|
||||
...form.getInputProps("nama"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Posisi",
|
||||
...form.getInputProps("posisi"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Pengalaman Kerja",
|
||||
...form.getInputProps("pengalamanKerja"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Email",
|
||||
...form.getInputProps("email"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Nama Perusahaan",
|
||||
...form.getInputProps("namaPerusahaan"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Alamat",
|
||||
...form.getInputProps("alamat"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Nomor Telepon",
|
||||
...form.getInputProps("nomorTelepon"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Username",
|
||||
...form.getInputProps("username"),
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
label: "Status",
|
||||
...form.getInputProps("status"),
|
||||
},
|
||||
],
|
||||
})}
|
||||
|
||||
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
||||
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>
|
||||
Tutup
|
||||
</Button>
|
||||
{status !== "selesai" && (
|
||||
<>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="red"
|
||||
onClick={() => handleStatusChange("ditolak")}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Tolak
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={() => handleStatusChange("diterima")}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Terima
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface RouteContext {
|
|||
|
||||
export const Route = createRootRouteWithContext<RouteContext>()({
|
||||
component: () => (
|
||||
<div className="font-manrope">
|
||||
<div className="font-inter">
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
|
||||
import PageTemplate from "@/components/PageTemplate";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import AspectFormModal from "@/modules/aspectManagement/modals/AspectFormModal";
|
||||
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { Flex } from "@mantine/core";
|
||||
import createActionButtons from "@/utils/createActionButton";
|
||||
import { TbEye, TbPencil, TbTrash } from "react-icons/tb";
|
||||
import AspectDeleteModal from "@/modules/aspectManagement/modals/AspectDeleteModal";
|
||||
|
||||
export const Route = createLazyFileRoute("/_dashboardLayout/aspect/")({
|
||||
component: AspectPage,
|
||||
});
|
||||
|
||||
type DataType = ExtractQueryDataType<typeof aspectQueryOptions>;
|
||||
|
||||
const columnHelper = createColumnHelper<DataType>();
|
||||
|
||||
export default function AspectPage() {
|
||||
return (
|
||||
<PageTemplate
|
||||
title="Manajemen Aspek"
|
||||
queryOptions={aspectQueryOptions}
|
||||
modals={[<AspectFormModal />, <AspectDeleteModal />]}
|
||||
columnDefs={[
|
||||
// Number of columns
|
||||
columnHelper.display({
|
||||
header: "#",
|
||||
cell: (props) => props.row.index + 1,
|
||||
}),
|
||||
|
||||
// Aspect columns
|
||||
columnHelper.display({
|
||||
header: "Nama Aspek",
|
||||
cell: (props) => props.row.original.name || "Tidak ada Aspek",
|
||||
}),
|
||||
|
||||
// Sub aspect columns
|
||||
columnHelper.display({
|
||||
header: "Sub Aspek",
|
||||
cell: (props) => {
|
||||
const subAspects = props.row.original.subAspects || [];
|
||||
return subAspects.length > 0 ? (
|
||||
<span>
|
||||
{subAspects.map((subAspect, index) => (
|
||||
<span key={subAspect.id}>
|
||||
{subAspect.name}
|
||||
{index < subAspects.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span>Tidak ada Sub Aspek</span>
|
||||
);
|
||||
},
|
||||
}),
|
||||
|
||||
// Actions columns
|
||||
columnHelper.display({
|
||||
header: "Aksi",
|
||||
cell: (props) => (
|
||||
<Flex gap="xs">
|
||||
{createActionButtons([
|
||||
{
|
||||
label: "Detail",
|
||||
permission: true,
|
||||
action: `?detail=${props.row.original.id}`,
|
||||
color: "green",
|
||||
icon: <TbEye />,
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
permission: true,
|
||||
action: `?edit=${props.row.original.id}`,
|
||||
color: "orange",
|
||||
icon: <TbPencil />,
|
||||
},
|
||||
{
|
||||
label: "Hapus",
|
||||
permission: true,
|
||||
action: `?delete=${props.row.original.id}`,
|
||||
color: "red",
|
||||
icon: <TbTrash />,
|
||||
},
|
||||
])}
|
||||
</Flex>
|
||||
),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx
Normal file
18
apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { z } from "zod";
|
||||
|
||||
const searchParamSchema = z.object({
|
||||
create: z.boolean().default(false).optional(),
|
||||
edit: z.string().default("").optional(),
|
||||
delete: z.string().default("").optional(),
|
||||
detail: z.string().default("").optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/_dashboardLayout/aspect/")({
|
||||
validateSearch: searchParamSchema,
|
||||
|
||||
loader: ({ context: { queryClient } }) => {
|
||||
queryClient.ensureQueryData(aspectQueryOptions(0, 10));
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import { useState } from "react";
|
||||
import { assessmentRequestQueryOptions } from "@/modules/assessmentRequest/queries/assessmentRequestQueries";
|
||||
import PageTemplate from "@/components/PageTemplate";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import FormModal from "@/modules/assessmentRequest/modals/CreateAssessmentRequestModal";
|
||||
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { Badge } from "@/shadcn/components/ui/badge";
|
||||
import { Button } from "@/shadcn/components/ui/button";
|
||||
import StartAssessmentModal from "@/modules/assessmentRequest/modals/ConfirmModal";
|
||||
|
||||
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentRequest/")({
|
||||
component: UsersPage,
|
||||
});
|
||||
|
||||
type DataType = ExtractQueryDataType<typeof assessmentRequestQueryOptions>;
|
||||
|
||||
const columnHelper = createColumnHelper<DataType>();
|
||||
|
||||
export default function UsersPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedAssessmentId, setSelectedAssessmentId] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Function to open confirmation modal to start assessment
|
||||
* @param {string} assessmentId ID of the assessment to be started
|
||||
*/
|
||||
const handleOpenModal = (assessmentId: string) => {
|
||||
if (!assessmentId) {
|
||||
console.error("Assessment ID is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAssessmentId(assessmentId);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to open assessment page in new tab
|
||||
* @param {string} assessmentId ID of the assessment to be opened
|
||||
*/
|
||||
const handleStartAssessment = (assessmentId: string) => {
|
||||
// Redirect to new URL in new tab
|
||||
const assessmentUrl = `/assessment?id=${assessmentId}`;
|
||||
window.open(assessmentUrl, "_blank");
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Function to open assessment result page based on valid ID
|
||||
* Used when "View Result" button is clicked
|
||||
* @param {string} assessmentId ID of the assessment to be opened
|
||||
*/
|
||||
const handleViewResult = (assessmentId: string) => {
|
||||
// to make sure assessmentId is valid and not null
|
||||
if (!assessmentId) {
|
||||
console.error("Assessment ID is missing");
|
||||
return;
|
||||
}
|
||||
const resultUrl = `/assessmentResult/${assessmentId}`;
|
||||
window.location.href = resultUrl;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTemplate
|
||||
title="Permohonan Asesmen"
|
||||
queryOptions={assessmentRequestQueryOptions}
|
||||
modals={[<FormModal />]}
|
||||
columnDefs={[
|
||||
columnHelper.display({
|
||||
header: "No",
|
||||
cell: (props) => props.row.index + 1,
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: "Tanggal",
|
||||
cell: (props) =>
|
||||
props.row.original.tanggal
|
||||
? new Intl.DateTimeFormat("ID", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
}).format(new Date(props.row.original.tanggal))
|
||||
: 'N/A',
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: "Status",
|
||||
cell: (props) => {
|
||||
const status = props.row.original.status;
|
||||
switch (status) {
|
||||
case "menunggu konfirmasi":
|
||||
return <Badge variant={"waiting"}>Menunggu Konfirmasi</Badge>;
|
||||
case "diterima":
|
||||
return <Badge variant={"accepted"}>Diterima</Badge>;
|
||||
case "ditolak":
|
||||
return <Badge variant={"rejected"}>Ditolak</Badge>;
|
||||
case "selesai":
|
||||
return <Badge variant={"completed"}>Selesai</Badge>;
|
||||
default:
|
||||
return <Badge variant={"outline"}>Tidak diketahui</Badge>;
|
||||
}
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
header: "Actions",
|
||||
cell: (props) => {
|
||||
const status = props.row.original.status;
|
||||
const assessmentId = props.row.original.assessmentId; // Retrieve the assessmentId from the data row
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{/* Button Create Assessment */}
|
||||
{status === "selesai" ? (
|
||||
<Button variant={"secondary"} disabled>Mulai Asesmen</Button>
|
||||
) : status === "diterima" ? (
|
||||
<Button
|
||||
onClick={() => handleOpenModal(assessmentId ?? '')}
|
||||
>
|
||||
Mulai Asesmen
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant={"secondary"} disabled>Mulai Asesmen</Button>
|
||||
)}
|
||||
|
||||
{/* Button View Result */}
|
||||
{status === "selesai" ? (
|
||||
<Button variant={"outline"} onClick={()=>handleViewResult(assessmentId ?? '')}>Lihat Hasil</Button>
|
||||
) : (
|
||||
<Button variant={"outline"} disabled>Lihat Hasil</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Confirmation Modal to Start Assessment */}
|
||||
{selectedAssessmentId && (
|
||||
<StartAssessmentModal
|
||||
assessmentId={selectedAssessmentId}
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onConfirm={handleStartAssessment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { assessmentRequestManagementQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries";
|
||||
import PageTemplate from "@/components/PageTemplate";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { Badge, Flex } from "@mantine/core";
|
||||
import createActionButtons from "@/utils/createActionButton";
|
||||
import { TbEye } from "react-icons/tb";
|
||||
import AssessmentRequestManagementFormModal from "@/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal";
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentRequestManagements/")({
|
||||
component: AssessmentRequestManagementsPage,
|
||||
});
|
||||
|
||||
type DataType = ExtractQueryDataType<typeof assessmentRequestManagementQueryOptions>;
|
||||
|
||||
const columnHelper = createColumnHelper<DataType>();
|
||||
|
||||
export default function AssessmentRequestManagementsPage() {
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleDetailClick = (id: string) => {
|
||||
setSelectedId(id);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// Helper function to format the date
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) {
|
||||
return "Tanggal tidak tersedia";
|
||||
}
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("id-ID", {
|
||||
hour12: true,
|
||||
minute: "2-digit",
|
||||
hour: "2-digit",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageTemplate
|
||||
title="Manajemen Permohonan Asesmen"
|
||||
queryOptions={assessmentRequestManagementQueryOptions}
|
||||
modals={[
|
||||
<AssessmentRequestManagementFormModal
|
||||
key="form-modal"
|
||||
assessmentId={selectedId}
|
||||
isOpen={modalOpen}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
queryClient.invalidateQueries();
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
createButton={null}
|
||||
columnDefs={[
|
||||
columnHelper.display({
|
||||
header: "#",
|
||||
cell: (props) => props.row.index + 1,
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
header: "Tanggal",
|
||||
cell: (props) => formatDate(props.row.original.tanggal),
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
header: "Nama Responden",
|
||||
cell: (props) => props.row.original.namaResponden,
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
header: "Nama Perusahaan",
|
||||
cell: (props) => props.row.original.namaPerusahaan,
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
header: "Status",
|
||||
cell: (props) => {
|
||||
const status = props.row.original.status;
|
||||
let statusLabel;
|
||||
let color;
|
||||
|
||||
switch (status) {
|
||||
case "menunggu konfirmasi":
|
||||
statusLabel = "Menunggu Konfirmasi";
|
||||
color = "yellow";
|
||||
break;
|
||||
case "diterima":
|
||||
statusLabel = "Diterima";
|
||||
color = "green";
|
||||
break;
|
||||
case "ditolak":
|
||||
statusLabel = "Ditolak";
|
||||
color = "red";
|
||||
break;
|
||||
case "selesai":
|
||||
statusLabel = "Selesai";
|
||||
color = "blue";
|
||||
break;
|
||||
default:
|
||||
statusLabel = "Tidak Diketahui";
|
||||
color = "gray";
|
||||
break;
|
||||
}
|
||||
|
||||
return <Badge color={color}>{statusLabel}</Badge>;
|
||||
},
|
||||
}),
|
||||
|
||||
columnHelper.display({
|
||||
header: "Aksi",
|
||||
cell: (props) => (
|
||||
<Flex gap="xs">
|
||||
{createActionButtons([
|
||||
{
|
||||
label: "Detail",
|
||||
permission: true,
|
||||
action: () => handleDetailClick(props.row.original.idPermohonan),
|
||||
color: "green",
|
||||
icon: <TbEye />,
|
||||
},
|
||||
])}
|
||||
</Flex>
|
||||
),
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
});
|
||||
322
apps/frontend/src/routes/register/index.lazy.tsx
Normal file
322
apps/frontend/src/routes/register/index.lazy.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import { createLazyFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Input } from '@/shadcn/components/ui/input.tsx';
|
||||
import { Button } from '@/shadcn/components/ui/button.tsx';
|
||||
import { Alert } from '@/shadcn/components/ui/alert.tsx';
|
||||
import { Checkbox } from "@/shadcn/components/ui/checkbox";
|
||||
import { Form, FormField, FormControl, FormLabel, FormMessage, FormItem } from '@/shadcn/components/ui/form.tsx';
|
||||
import { TbArrowNarrowRight } from 'react-icons/tb';
|
||||
import client from "../../honoClient";
|
||||
|
||||
// Define the schema for validation
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Kolom ini wajib diisi"),
|
||||
username: z.string().min(1, "Kolom ini wajib diisi"),
|
||||
email: z.string().email("Alamat email tidak valid").min(1, "Kolom ini wajib diisi"),
|
||||
password: z.string().min(6, "Kata sandi harus minimal 6 karakter"),
|
||||
companyName: z.string().min(1, "Kolom ini wajib diisi"),
|
||||
position: z.string().min(1, "Kolom ini wajib diisi"),
|
||||
workExperience: z.string().min(1, "Kolom ini wajib diisi"),
|
||||
address: z.string().min(1, "Kolom ini wajib diisi"),
|
||||
phoneNumber: z.string().min(1, "Kolom ini wajib diisi"),
|
||||
terms: z.boolean().refine((val) => val, "Anda harus menyetujui persyaratan dan layanan"),
|
||||
});
|
||||
|
||||
// Define the form type
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export const Route = createLazyFileRoute("/register/")({
|
||||
component: RegisterPage,
|
||||
});
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [errorFields, setErrorFields] = useState<Partial<Record<keyof FormSchema, string>> | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
companyName: "",
|
||||
position: "",
|
||||
workExperience: "",
|
||||
address: "",
|
||||
phoneNumber: "",
|
||||
terms: false,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: FormSchema) => {
|
||||
try {
|
||||
const res = await client.register.$post({
|
||||
json: values,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
// Redirect to login page on success
|
||||
navigate({ to: "/login", replace: true });
|
||||
} else {
|
||||
// Handle non-200 responses from backend
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.message || "An unknown error occurred");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const message = error.message;
|
||||
|
||||
if (message.includes("Email atau username sudah terdaftar")) {
|
||||
setErrorFields({
|
||||
email: "Email sudah terdaftar",
|
||||
username: "Username sudah terdaftar",
|
||||
});
|
||||
} else if (message.includes("Nomor telepon sudah terdaftar")) {
|
||||
setErrorFields({
|
||||
phoneNumber: "Nomor telepon sudah terdaftar",
|
||||
});
|
||||
} else {
|
||||
setErrorMessage(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col lg:flex-row min-h-screen overflow-hidden">
|
||||
{/* Image */}
|
||||
<div className="relative h-[40vw] lg:h-screen -z-20 lg:mt-24">
|
||||
<div className="-translate-y-[calc(35vw+1rem)] lg:translate-y-0 w-full lg:translate-x-[calc(10vh-45vw)] ">
|
||||
<span className="absolute scale-50 lg:scale-50 -rotate-12 w-[100vw] h-[100vw] lg:w-[140vh] lg:h-[140vh] border border-gray-300 flex rounded-3xl"></span>
|
||||
<span className="absolute scale-[85%] lg:scale-[70%] -rotate-12 w-[100vw] h-[100vw] lg:w-[140vh] lg:h-[140vh] border border-gray-300 flex rounded-3xl"></span>
|
||||
<span className="absolute scale-[120%] lg:scale-90 -rotate-12 w-[100vw] h-[100vw] lg:w-[140vh] lg:h-[140vh] border border-gray-300 flex rounded-3xl"></span>
|
||||
<span className="absolute scale-150 lg:scale-110 -rotate-12 w-[100vw] h-[100vw] lg:w-[140vh] lg:h-[140vh] border border-gray-300 hidden lg:flex rounded-3xl"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="absolute top-7 left-6">
|
||||
<img src="../src/assets/logos/amati-logo.png" alt="Amati Logo" className="h-4 w-full object-contain" />
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col md:flex-row items-center justify-center pt-10 lg:pt-20 lg:pl-56 md:justify-center lg:justify-end space-x-12 px-6 md:px-0">
|
||||
|
||||
{/* Form column */}
|
||||
<div className="w-full md:w-1/2 mx-auto md:mx-20">
|
||||
|
||||
{/* Title and Already have an account */}
|
||||
<div className="flex flex-col gap-1 mb-7">
|
||||
<h1 className="text-3xl lg:text-4xl font-extrabold text-left">Daftar Akun</h1>
|
||||
<p className="text-sm md:text-sm text-gray-400">
|
||||
Sudah punya akun?{' '}
|
||||
<a href="/login" className="text-blue-500 font-semibold hover:text-blue-800">
|
||||
Sign In now
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
{errorMessage && (
|
||||
<Alert variant="destructive">
|
||||
<p>{errorMessage}</p>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{Object.keys(errorFields || {}).length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
{Object.values(errorFields || {}).map((msg, idx) => (
|
||||
<p key={idx}>{msg}</p>
|
||||
))}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Form fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<FormField name="name" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Nama Lengkap</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Masukkan nama lengkap sesuai dengan Kartu Identitas"
|
||||
{...field}
|
||||
className={`${form.formState.errors.name ? "border-red-500" : ""} truncate text-sm md:text-base`}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<FormField name="username" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Username"
|
||||
{...field}
|
||||
className={form.formState.errors.username ? "border-red-500" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField name="email" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="eg; user@mail.com"
|
||||
{...field}
|
||||
className={form.formState.errors.email ? "border-red-500" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField name="password" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Kata Sandi</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="******"
|
||||
{...field}
|
||||
className={form.formState.errors.password ? "border-red-500" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField name="companyName" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Nama Perusahaan</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Nama Perusahaan"
|
||||
{...field}
|
||||
className={form.formState.errors.companyName ? "border-red-500" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField name="position" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Jabatan</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Jabatan"
|
||||
{...field}
|
||||
className={form.formState.errors.position ? "border-red-500" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField name="workExperience" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Pengalaman Kerja</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Pengalaman Kerja"
|
||||
{...field}
|
||||
className={form.formState.errors.workExperience ? "border-red-500" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField name="address" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Alamat</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Alamat"
|
||||
{...field}
|
||||
className={form.formState.errors.address ? "border-red-500" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField name="phoneNumber" render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="font-bold text-sm">Nomor Telepon</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Nomor Telepon"
|
||||
{...field}
|
||||
className={form.formState.errors.phoneNumber ? "border-red-500" : ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)} />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terms"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex items-center space-x-0.5 pb-10">
|
||||
<Checkbox
|
||||
checked={!!form.watch("terms")}
|
||||
onCheckedChange={(checked) => form.setValue("terms", !!checked)}
|
||||
className={`border ${form.formState.errors.terms ? "border-red-500" : "border-[#00000099]"}`}
|
||||
onChange={form.register("terms").onChange}
|
||||
onBlur={form.register("terms").onBlur}
|
||||
name="terms"
|
||||
ref={form.register("terms").ref}
|
||||
id="terms"
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-normal leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-[#00000099] p-2 rounded"
|
||||
>
|
||||
Saya setuju dengan syarat dan layanan
|
||||
</label>
|
||||
</div>
|
||||
</FormControl>
|
||||
{form.formState.errors.terms && (
|
||||
<FormMessage>{form.formState.errors.terms.message}</FormMessage>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pb-6">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full flex items-center justify-between text-base font-medium md:w-auto bg-[--primary-color] text-white"
|
||||
>
|
||||
<span className="flex-grow text-left">Daftar Akun</span>
|
||||
<TbArrowNarrowRight className="ml-12 h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
6
apps/frontend/src/styles/fonts/inter.css
Normal file
6
apps/frontend/src/styles/fonts/inter.css
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
|
||||
|
||||
.font-inter {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user