Merge branch 'dev-clone' of https://github.com/digitalsolutiongroup/amati into feat/assessment-frontend

This commit is contained in:
abiyasa05 2024-10-09 12:17:36 +07:00
commit b45b743967
24 changed files with 1460 additions and 48 deletions

View File

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

View File

@ -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),

View File

@ -1,15 +1,13 @@
import { eq } from "drizzle-orm"; import { eq, sql, ilike, and, desc} from "drizzle-orm";
import { Hono } from "hono"; import { 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);
} }
); );

View File

@ -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,
})) }))
@ -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({

View File

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

View File

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

View File

@ -69,5 +69,5 @@
} }
:root { :root {
--primary-color: #2555FF --primary-color: #2555FF;
} }

View File

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

View File

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

View File

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

View File

@ -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()
);
};

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import { Route as DashboardLayoutImport } from './routes/_dashboardLayout'
import { Route as DashboardLayoutUsersIndexImport } from './routes/_dashboardLayout/users/index' import { Route as 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"

View File

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

View File

@ -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>
),
}),
]}
/>
);
}

View 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));
},
});

View File

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

View File

@ -0,0 +1,18 @@
import { assessmentRequestQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestQueries"
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchParamSchema = z.object({
create: z.boolean().default(false).optional(),
edit: z.string().default("").optional(),
delete: z.string().default("").optional(),
detail: z.string().default("").optional(),
});
export const Route = createFileRoute("/_dashboardLayout/assessmentRequest/")({
validateSearch: searchParamSchema,
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(assessmentRequestQueryOptions(0, 10));
},
});

View File

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

View File

@ -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: {

View File

@ -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>
) )
} }
) )

View 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;
}