Merge branch 'dev-clone' of https://github.com/digitalsolutiongroup/amati into feat/assessment-frontend
This commit is contained in:
commit
b45b743967
|
|
@ -21,6 +21,20 @@ const sidebarMenus: SidebarMenu[] = [
|
||||||
link: "/questions",
|
link: "/questions",
|
||||||
color: "green",
|
color: "green",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Permohonan Asesmen",
|
||||||
|
icon: { tb: "TbChecklist" },
|
||||||
|
allowedPermissions: ["permissions.read"],
|
||||||
|
link: "/assessmentRequest",
|
||||||
|
color: "green",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Manajemen Aspek",
|
||||||
|
icon: { tb: "TbClipboardText" },
|
||||||
|
allowedPermissions: ["permissions.read"],
|
||||||
|
link: "/aspect",
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default sidebarMenus;
|
export default sidebarMenus;
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ const authInfo = createMiddleware<HonoEnv>(async (c, next) => {
|
||||||
|
|
||||||
// Setting the currentUser with fetched data
|
// Setting the currentUser with fetched data
|
||||||
c.set("currentUser", {
|
c.set("currentUser", {
|
||||||
|
id: user[0].users.id, // Adding user ID here
|
||||||
name: user[0].users.name, // Assuming the first result is the user
|
name: user[0].users.name, // Assuming the first result is the user
|
||||||
permissions: Array.from(permissions),
|
permissions: Array.from(permissions),
|
||||||
roles: Array.from(roles),
|
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 { Hono } from "hono";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import db from "../../drizzle";
|
import db from "../../drizzle";
|
||||||
import { respondents } from "../../drizzle/schema/respondents";
|
import { respondents } from "../../drizzle/schema/respondents";
|
||||||
import { assessments } from "../../drizzle/schema/assessments";
|
import { assessments } from "../../drizzle/schema/assessments";
|
||||||
import { users } from "../../drizzle/schema/users";
|
import { users } from "../../drizzle/schema/users";
|
||||||
import { rolesToUsers } from "../../drizzle/schema/rolesToUsers";
|
|
||||||
import { rolesSchema } from "../../drizzle/schema/roles";
|
|
||||||
import HonoEnv from "../../types/HonoEnv";
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
import authInfo from "../../middlewares/authInfo";
|
import authInfo from "../../middlewares/authInfo";
|
||||||
import { notFound } from "../../errors/DashboardError";
|
import { forbidden, notFound } from "../../errors/DashboardError";
|
||||||
import checkPermission from "../../middlewares/checkPermission";
|
import checkPermission from "../../middlewares/checkPermission";
|
||||||
import requestValidator from "../../utils/requestValidator";
|
import requestValidator from "../../utils/requestValidator";
|
||||||
import { HTTPException } from "hono/http-exception";
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
|
@ -20,85 +18,131 @@ const assessmentRequestRoute = new Hono<HonoEnv>()
|
||||||
|
|
||||||
// Get assessment request by user ID
|
// Get assessment request by user ID
|
||||||
.get(
|
.get(
|
||||||
"/:id",
|
"/",
|
||||||
checkPermission("assessmentRequest.read"),
|
checkPermission("assessmentRequest.read"),
|
||||||
requestValidator(
|
requestValidator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
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) => {
|
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
|
const queryResult = await db
|
||||||
.select({
|
.select({
|
||||||
userId: users.id,
|
userId: users.id,
|
||||||
createdAt: assessments.createdAt,
|
|
||||||
name: users.name,
|
name: users.name,
|
||||||
code: rolesSchema.code,
|
assessmentId: assessments.id,
|
||||||
|
tanggal: assessments.createdAt,
|
||||||
status: assessments.status,
|
status: assessments.status,
|
||||||
|
respondentId: respondents.id,
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
|
|
||||||
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
|
|
||||||
.leftJoin(respondents, eq(users.id, respondents.userId))
|
.leftJoin(respondents, eq(users.id, respondents.userId))
|
||||||
.leftJoin(assessments, eq(respondents.id, assessments.respondentId))
|
.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();
|
if (!queryResult[0]) throw notFound();
|
||||||
|
|
||||||
const assessmentRequestData = {
|
return c.json({
|
||||||
...queryResult,
|
data: queryResult,
|
||||||
};
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
return c.json(assessmentRequestData);
|
totalPages: Math.ceil(totalItems / limit),
|
||||||
|
totalItems,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Post assessment request by user ID
|
// Post assessment request by user ID
|
||||||
.post(
|
.post(
|
||||||
"/:id",
|
"/",
|
||||||
checkPermission("assessmentRequest.create"),
|
checkPermission("assessmentRequest.create"),
|
||||||
requestValidator(
|
requestValidator(
|
||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
respondentId: z.string().min(1),
|
respondentId: z.string().min(1), // Memastikan respondentId minimal ada
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { respondentId } = c.req.valid("json");
|
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) {
|
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
|
const respondent = await db
|
||||||
.select()
|
.select()
|
||||||
.from(respondents)
|
.from(respondents)
|
||||||
.where(eq(respondents.id, respondentId));
|
.where(and(eq(respondents.id, respondentId), eq(respondents.userId, userId)));
|
||||||
|
|
||||||
if (!respondent.length) {
|
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
|
const newAssessment = await db
|
||||||
.insert(assessments)
|
.insert(assessments)
|
||||||
.values({
|
.values({
|
||||||
id: createId(),
|
id: createId(),
|
||||||
respondentId,
|
respondentId,
|
||||||
status: "menunggu konfirmasi",
|
status: "menunggu konfirmasi", // Status awal permohonan
|
||||||
validatedBy: null,
|
validatedBy: null,
|
||||||
validatedAt: null,
|
validatedAt: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return c.json({ message: "Successfully submitted the assessment request" }, 201);
|
return c.json({ message: "Successfully submitted the assessment request", data: newAssessment }, 201);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,9 +80,19 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
const totalCountQuery = includeTrashed
|
const aspectCountQuery = await db
|
||||||
? sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})`
|
.select({
|
||||||
: sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`;
|
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
|
const aspectIdsQuery = await db
|
||||||
.select({
|
.select({
|
||||||
|
|
@ -95,6 +105,7 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
||||||
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
|
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.orderBy(aspects.name)
|
||||||
.offset(page * limit)
|
.offset(page * limit)
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
|
|
@ -128,11 +139,11 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
||||||
FROM ${questions}
|
FROM ${questions}
|
||||||
WHERE ${questions.subAspectId} = ${subAspects.id}
|
WHERE ${questions.subAspectId} = ${subAspects.id}
|
||||||
)`.as('questionCount'),
|
)`.as('questionCount'),
|
||||||
fullCount: totalCountQuery,
|
|
||||||
})
|
})
|
||||||
.from(aspects)
|
.from(aspects)
|
||||||
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
|
.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
|
// Grouping sub aspects by aspect ID
|
||||||
const groupedResult = result.reduce((acc, curr) => {
|
const groupedResult = result.reduce((acc, curr) => {
|
||||||
|
|
@ -176,8 +187,8 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
||||||
data: groupedArray,
|
data: groupedArray,
|
||||||
_metadata: {
|
_metadata: {
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit),
|
totalPages: Math.ceil(totalItems / limit),
|
||||||
totalItems: Number(result[0]?.fullCount) ?? 0,
|
totalItems,
|
||||||
perPage: limit,
|
perPage: limit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -287,10 +298,23 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
||||||
if (aspectData.subAspects) {
|
if (aspectData.subAspects) {
|
||||||
const subAspectsArray = JSON.parse(aspectData.subAspects) as string[];
|
const subAspectsArray = JSON.parse(aspectData.subAspects) as string[];
|
||||||
|
|
||||||
// Insert new sub aspects into the database without checking for sub aspect duplication
|
// Create a Set to check for duplicates
|
||||||
if (subAspectsArray.length) {
|
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(
|
await db.insert(subAspects).values(
|
||||||
subAspectsArray.map((subAspect) => ({
|
filteredSubAspects.map((subAspect) => ({
|
||||||
aspectId,
|
aspectId,
|
||||||
name: subAspect,
|
name: subAspect,
|
||||||
}))
|
}))
|
||||||
|
|
@ -305,7 +329,7 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
||||||
201
|
201
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Update aspect
|
// Update aspect
|
||||||
.patch(
|
.patch(
|
||||||
|
|
@ -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
|
// Update or add new sub aspects
|
||||||
for (const subAspect of newSubAspects) {
|
for (const subAspect of newSubAspects) {
|
||||||
const existingSubAspect = currentSubAspectMap.has(subAspect.id);
|
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) {
|
if (existingSubAspect) {
|
||||||
// Update if sub aspect already exists
|
// Update if sub aspect already exists
|
||||||
await db
|
await db
|
||||||
|
|
@ -402,12 +436,14 @@ const managementAspectRoute = new Hono<HonoEnv>()
|
||||||
await db
|
await db
|
||||||
.insert(subAspects)
|
.insert(subAspects)
|
||||||
.values({
|
.values({
|
||||||
id: subAspect.id,
|
|
||||||
aspectId,
|
aspectId,
|
||||||
name: subAspect.name,
|
name: subAspect.name,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the name to the Set after processing
|
||||||
|
uniqueSubAspectNames.add(subAspect.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
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: {
|
Variables: {
|
||||||
uid?: string;
|
uid?: string;
|
||||||
currentUser?: {
|
currentUser?: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
permissions: SpecificPermissionCode[];
|
permissions: SpecificPermissionCode[];
|
||||||
roles: RoleCode[];
|
roles: RoleCode[];
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@mantine/core": "^7.10.2",
|
"@mantine/core": "^7.10.2",
|
||||||
"@mantine/dates": "^7.10.2",
|
"@mantine/dates": "^7.10.2",
|
||||||
"@mantine/form": "^7.10.2",
|
"@mantine/form": "^7.10.2",
|
||||||
|
|
|
||||||
|
|
@ -69,5 +69,5 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--primary-color: #2555FF
|
--primary-color: #2555FF;
|
||||||
}
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import "./styles/tailwind.css";
|
import "./styles/tailwind.css";
|
||||||
import "./styles/fonts/manrope.css";
|
import "./styles/fonts/inter.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<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();
|
||||||
|
};
|
||||||
|
|
@ -17,6 +17,7 @@ import { Route as DashboardLayoutImport } from './routes/_dashboardLayout'
|
||||||
import { Route as DashboardLayoutUsersIndexImport } from './routes/_dashboardLayout/users/index'
|
import { Route as DashboardLayoutUsersIndexImport } from './routes/_dashboardLayout/users/index'
|
||||||
import { Route as DashboardLayoutTimetableIndexImport } from './routes/_dashboardLayout/timetable/index'
|
import { Route as DashboardLayoutTimetableIndexImport } from './routes/_dashboardLayout/timetable/index'
|
||||||
import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboardLayout/dashboard/index'
|
import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboardLayout/dashboard/index'
|
||||||
|
import { Route as DashboardLayoutAssessmentRequestIndexImport } from './routes/_dashboardLayout/assessmentRequest/index'
|
||||||
|
|
||||||
// Create Virtual Routes
|
// Create Virtual Routes
|
||||||
|
|
||||||
|
|
@ -83,6 +84,16 @@ const DashboardLayoutDashboardIndexRoute =
|
||||||
getParentRoute: () => DashboardLayoutRoute,
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
} as any)
|
} 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
|
// Populate the FileRoutesByPath interface
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
|
|
@ -129,6 +140,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof LogoutIndexLazyImport
|
preLoaderRoute: typeof LogoutIndexLazyImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
'/_dashboardLayout/assessmentRequest/': {
|
||||||
|
id: '/_dashboardLayout/assessmentRequest/'
|
||||||
|
path: '/assessmentRequest'
|
||||||
|
fullPath: '/assessmentRequest'
|
||||||
|
preLoaderRoute: typeof DashboardLayoutAssessmentRequestIndexImport
|
||||||
|
parentRoute: typeof DashboardLayoutImport
|
||||||
|
}
|
||||||
'/_dashboardLayout/dashboard/': {
|
'/_dashboardLayout/dashboard/': {
|
||||||
id: '/_dashboardLayout/dashboard/'
|
id: '/_dashboardLayout/dashboard/'
|
||||||
path: '/dashboard'
|
path: '/dashboard'
|
||||||
|
|
@ -158,6 +176,7 @@ declare module '@tanstack/react-router' {
|
||||||
export const routeTree = rootRoute.addChildren({
|
export const routeTree = rootRoute.addChildren({
|
||||||
IndexLazyRoute,
|
IndexLazyRoute,
|
||||||
DashboardLayoutRoute: DashboardLayoutRoute.addChildren({
|
DashboardLayoutRoute: DashboardLayoutRoute.addChildren({
|
||||||
|
DashboardLayoutAssessmentRequestIndexRoute,
|
||||||
DashboardLayoutDashboardIndexRoute,
|
DashboardLayoutDashboardIndexRoute,
|
||||||
DashboardLayoutTimetableIndexRoute,
|
DashboardLayoutTimetableIndexRoute,
|
||||||
DashboardLayoutUsersIndexRoute,
|
DashboardLayoutUsersIndexRoute,
|
||||||
|
|
@ -190,6 +209,7 @@ export const routeTree = rootRoute.addChildren({
|
||||||
"/_dashboardLayout": {
|
"/_dashboardLayout": {
|
||||||
"filePath": "_dashboardLayout.tsx",
|
"filePath": "_dashboardLayout.tsx",
|
||||||
"children": [
|
"children": [
|
||||||
|
"/_dashboardLayout/assessmentRequest/",
|
||||||
"/_dashboardLayout/dashboard/",
|
"/_dashboardLayout/dashboard/",
|
||||||
"/_dashboardLayout/timetable/",
|
"/_dashboardLayout/timetable/",
|
||||||
"/_dashboardLayout/users/"
|
"/_dashboardLayout/users/"
|
||||||
|
|
@ -207,6 +227,10 @@ export const routeTree = rootRoute.addChildren({
|
||||||
"/logout/": {
|
"/logout/": {
|
||||||
"filePath": "logout/index.lazy.tsx"
|
"filePath": "logout/index.lazy.tsx"
|
||||||
},
|
},
|
||||||
|
"/_dashboardLayout/assessmentRequest/": {
|
||||||
|
"filePath": "_dashboardLayout/assessmentRequest/index.tsx",
|
||||||
|
"parent": "/_dashboardLayout"
|
||||||
|
},
|
||||||
"/_dashboardLayout/dashboard/": {
|
"/_dashboardLayout/dashboard/": {
|
||||||
"filePath": "_dashboardLayout/dashboard/index.tsx",
|
"filePath": "_dashboardLayout/dashboard/index.tsx",
|
||||||
"parent": "/_dashboardLayout"
|
"parent": "/_dashboardLayout"
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ interface RouteContext {
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouteContext>()({
|
export const Route = createRootRouteWithContext<RouteContext>()({
|
||||||
component: () => (
|
component: () => (
|
||||||
<div className="font-manrope">
|
<div className="font-inter">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackRouterDevtools />
|
<TanStackRouterDevtools />
|
||||||
</div>
|
</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/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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
},
|
||||||
|
});
|
||||||
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:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||||
outline: "text-foreground",
|
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: {
|
defaultVariants: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { TbLoader2 } from "react-icons/tb"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -37,17 +38,25 @@ export interface ButtonProps
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
VariantProps<typeof buttonVariants> {
|
VariantProps<typeof buttonVariants> {
|
||||||
asChild?: boolean
|
asChild?: boolean
|
||||||
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
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"
|
const Comp = asChild ? Slot : "button"
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
disabled={isLoading || props.disabled} // Disable button if loading
|
||||||
{...props}
|
{...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