Pull Request branch dev-clone to main #1

Merged
gitea merged 429 commits from dev-clone into main 2024-12-23 09:31:34 +00:00
14 changed files with 1462 additions and 185 deletions
Showing only changes of commit 8a97277d00 - Show all commits

View File

@ -14,6 +14,13 @@ const sidebarMenus: SidebarMenu[] = [
link: "/users",
color: "red",
},
{
label: "Manajemen Aspek",
icon: { tb: "TbClipboardText" },
allowedPermissions: ["permissions.read"],
link: "/aspect",
color: "blue",
},
{
label: "Manajemen Pertanyaan",
icon: { tb: "TbChecklist" },
@ -29,11 +36,11 @@ const sidebarMenus: SidebarMenu[] = [
color: "green",
},
{
label: "Manajemen Aspek",
icon: { tb: "TbClipboardText" },
label: "Manajemen Permohonan Asesmen",
icon: { tb: "TbReport" },
allowedPermissions: ["permissions.read"],
link: "/aspect",
color: "blue",
link: "/assessmentRequestManagements",
color: "orange",
},
];

View File

@ -1,29 +1,29 @@
import { and, eq, ilike, or, sql } from "drizzle-orm";
import { Hono } from "hono";
import checkPermission from "../../middlewares/checkPermission";
import { z } from "zod";
import { HTTPException } from "hono/http-exception";
import db from "../../drizzle";
import { assessments } from "../../drizzle/schema/assessments";
import { respondents } from "../../drizzle/schema/respondents";
import { users } from "../../drizzle/schema/users";
import HonoEnv from "../../types/HonoEnv";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
import { and, eq, ilike, or, sql, desc } from "drizzle-orm";
import { Hono } from "hono";
import checkPermission from "../../middlewares/checkPermission";
import { z } from "zod";
import { HTTPException } from "hono/http-exception";
import db from "../../drizzle";
import { assessments } from "../../drizzle/schema/assessments";
import { respondents } from "../../drizzle/schema/respondents";
import { users } from "../../drizzle/schema/users";
import HonoEnv from "../../types/HonoEnv";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
export const assessmentFormSchema = z.object({
export const assessmentFormSchema = z.object({
respondentId: z.string().min(1),
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]),
reviewedBy: z.string().min(1),
validatedBy: z.string().min(1),
validatedAt: z.string().optional(),
});
});
export const assessmentUpdateSchema = assessmentFormSchema.extend({
export const assessmentUpdateSchema = assessmentFormSchema.extend({
validatedAt: z.string().optional().or(z.literal("")),
});
});
const assessmentsRequestManagementRoutes = new Hono<HonoEnv>()
const assessmentsRequestManagementRoutes = new Hono<HonoEnv>()
.use(authInfo)
/**
* Get All Assessments (With Metadata)
@ -49,16 +49,10 @@
async (c) => {
const { page, limit, q } = c.req.valid("query");
const totalCountQuery = sql<number>`(SELECT count(*) FROM ${assessments})`;
const result = await db
// Query untuk menghitung total jumlah item (totalCountQuery)
const assessmentCountQuery = await db
.select({
idPermohonan: assessments.id,
namaResponden: users.name,
namaPerusahaan: respondents.companyName,
status: assessments.status,
tanggal: assessments.createdAt,
fullCount: totalCountQuery,
count: sql<number>`count(*)`,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
@ -68,10 +62,37 @@
? or(
ilike(users.name, `%${q}%`),
ilike(respondents.companyName, `%${q}%`),
sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`,
eq(assessments.id, q)
)
: undefined
);
const totalItems = Number(assessmentCountQuery[0]?.count) || 0;
// Query utama untuk mendapatkan data permohonan assessment
const result = await db
.select({
idPermohonan: assessments.id,
namaResponden: users.name,
namaPerusahaan: respondents.companyName,
status: assessments.status,
tanggal: assessments.createdAt,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(
q
? or(
ilike(users.name, `%${q}%`),
ilike(respondents.companyName, `%${q}%`),
sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`,
eq(assessments.id, q)
)
: undefined
)
.orderBy(desc(assessments.createdAt))
.offset(page * limit)
.limit(limit);
@ -85,10 +106,8 @@
})),
_metadata: {
currentPage: page,
totalPages: Math.ceil(
(Number(result[0]?.fullCount) ?? 0) / limit
),
totalItems: Number(result[0]?.fullCount) ?? 0,
totalPages: Math.ceil(totalItems / limit),
totalItems,
perPage: limit,
},
});
@ -104,7 +123,6 @@
const queryResult = await db
.select({
// id: assessments.id,
tanggal: assessments.createdAt,
nama: users.name,
posisi: respondents.position,
@ -170,4 +188,4 @@
export default assessmentsRequestManagementRoutes;
export default assessmentsRequestManagementRoutes;

View File

@ -12,11 +12,20 @@ import checkPermission from "../../middlewares/checkPermission";
import { aspects } from "../../drizzle/schema/aspects";
import { subAspects } from "../../drizzle/schema/subAspects";
import { notFound } from "../../errors/DashboardError";
import { options } from "../../drizzle/schema/options";
// Schema for creating and updating options
export const optionFormSchema = z.object({
text: z.string().min(1).max(255),
score: z.number().min(0),
});
// Schema for creating and updating questions
export const questionFormSchema = z.object({
subAspectId: z.string().min(1).max(255),
question: z.string().min(1).max(255),
needFile: z.boolean().default(false),
options: z.array(optionFormSchema).optional(), // Allow options to be included
});
export const questionUpdateSchema = questionFormSchema.extend({
@ -27,6 +36,59 @@ export const questionUpdateSchema = questionFormSchema.extend({
const questionsRoute = new Hono<HonoEnv>()
.use(authInfo)
/**
* Get All Aspects
*/
.get("/aspects",
checkPermission("questions.readAll"),
async (c) => {
const result = await db
.select({
id: aspects.id,
name: aspects.name,
createdAt: aspects.createdAt,
updatedAt: aspects.updatedAt,
})
.from(aspects);
return c.json(result);
})
/**
* Get All Sub Aspects
*
* Query params:
* - aspectId: string (optional)
*/
.get("/subAspects",
checkPermission("questions.readAll"),
requestValidator(
"query",
z.object({
aspectId: z.string().optional(),
})
),
async (c) => {
const { aspectId } = c.req.valid("query");
const query = db
.select({
id: subAspects.id,
name: subAspects.name,
aspectId: subAspects.aspectId,
createdAt: subAspects.createdAt,
updatedAt: subAspects.updatedAt,
})
.from(subAspects);
if (aspectId) {
query.where(eq(subAspects.aspectId, aspectId));
}
const result = await query;
return c.json(result);
}
)
/**
* Get All Questions (With Metadata)
*
@ -70,6 +132,12 @@ const questionsRoute = new Hono<HonoEnv>()
createdAt: questions.createdAt,
updatedAt: questions.updatedAt,
...(includeTrashed ? { deletedAt: questions.deletedAt } : {}),
averageScore: sql<number | null>`(
SELECT ROUND(AVG(${options.score}), 2)
FROM ${options}
WHERE ${options.questionId} = ${questions.id}
AND ${options.deletedAt} IS NULL -- Include only non-deleted options
)`,
fullCount: totalCountQuery,
})
.from(questions)
@ -80,14 +148,15 @@ const questionsRoute = new Hono<HonoEnv>()
includeTrashed ? undefined : isNull(questions.deletedAt),
q
? or(
ilike(questions.createdAt, q),
ilike(questions.updatedAt, q),
ilike(questions.deletedAt, q),
ilike(questions.question, `%${q}%`),
ilike(aspects.name, `%${q}%`),
ilike(subAspects.name, `%${q}%`),
eq(questions.id, q)
)
: undefined
)
)
.orderBy(questions.question)
.offset(page * limit)
.limit(limit);
@ -123,6 +192,11 @@ const questionsRoute = new Hono<HonoEnv>()
async (c) => {
const questionId = c.req.param("id");
if (!questionId)
throw notFound({
message: "Missing id",
});
const includeTrashed =
c.req.query("includeTrashed")?.toLowerCase() === "true";
@ -131,26 +205,53 @@ const questionsRoute = new Hono<HonoEnv>()
id: questions.id,
question: questions.question,
needFile: questions.needFile,
aspectId: aspects.id,
subAspectId: subAspects.id,
subAspectId: questions.subAspectId,
subAspectName: subAspects.name,
aspectId: subAspects.aspectId,
aspectName: aspects.name,
createdAt: questions.createdAt,
updatedAt: questions.updatedAt,
...(includeTrashed ? { deletedAt: questions.deletedAt } : {}),
options: {
id: options.id,
text: options.text,
score: options.score,
},
})
.from(questions)
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
.leftJoin(options, and(eq(questions.id, options.questionId), isNull(options.deletedAt))) // Filter out soft-deleted options
.where(
and(
eq(questions.id, questionId),
!includeTrashed ? isNull(questions.deletedAt) : undefined
)
);
)
.groupBy(questions.id, questions.question, questions.needFile, subAspects.aspectId,
aspects.name, questions.subAspectId, subAspects.name, questions.createdAt,
questions.updatedAt, options.id, options.text, options.score);
if (!queryResult[0]) throw notFound();
const optionsList = queryResult.reduce((prev, curr) => {
if (!curr.options) return prev;
prev.set(curr.options.id,
{
text: curr.options.text,
score: curr.options.score,
}
);
return prev;
}, new Map<string, { text: string; score: number }>());
// Convert Map to Array and sort by the score field in ascending order
const sortedOptions = Array.from(optionsList, ([id, { text, score }]) => ({ id, text, score }))
.sort((a, b) => a.score - b.score); // Sort based on score field in ascending order
const questionData = {
...queryResult[0],
options: sortedOptions,
};
return c.json(questionData);
@ -169,6 +270,17 @@ const questionsRoute = new Hono<HonoEnv>()
async (c) => {
const questionData = c.req.valid("json");
// Check if the sub aspect exists
const existingSubAspect = await db
.select()
.from(subAspects)
.where(eq(subAspects.id, questionData.subAspectId));
if (existingSubAspect.length === 0) {
return c.json({ message: "Sub aspect not found" }, 404);
}
// Insert question data into the questions table
const question = await db
.insert(questions)
.values({
@ -178,10 +290,23 @@ const questionsRoute = new Hono<HonoEnv>()
})
.returning();
const questionId = question[0].id;
// Insert options data if provided
if (questionData.options && questionData.options.length > 0) {
const optionsData = questionData.options.map((option) => ({
questionId: questionId,
text: option.text,
score: option.score,
}));
await db.insert(options).values(optionsData);
}
return c.json(
{
message: "Question created successfully",
data: question,
message: "Question and options created successfully",
data: question[0],
},
201
);
@ -201,6 +326,7 @@ const questionsRoute = new Hono<HonoEnv>()
const questionId = c.req.param("id");
const questionData = c.req.valid("json");
// Check if the question exists and is not soft deleted
const question = await db
.select()
.from(questions)
@ -208,6 +334,7 @@ const questionsRoute = new Hono<HonoEnv>()
if (!question[0]) throw notFound();
// Update question data
await db
.update(questions)
.set({
@ -216,8 +343,58 @@ const questionsRoute = new Hono<HonoEnv>()
})
.where(eq(questions.id, questionId));
// Check if options data is provided
if (questionData.options !== undefined) {
// Fetch existing options from the database for this question
const existingOptions = await db
.select()
.from(options)
.where(and(eq(options.questionId, questionId), isNull(options.deletedAt)));
// Prepare new options data for comparison
const newOptionsData = questionData.options.map((option) => ({
questionId: questionId,
text: option.text,
score: option.score,
}));
// Iterate through existing options and perform updates or soft deletes if needed
for (const existingOption of existingOptions) {
const matchingOption = newOptionsData.find(
(newOption) => newOption.text === existingOption.text
);
if (!matchingOption) {
// If the existing option is not in the new options data, soft delete it
await db
.update(options)
.set({ deletedAt: new Date() })
.where(eq(options.id, existingOption.id));
} else {
// If the option is found, update it if the score has changed
if (existingOption.score !== matchingOption.score) {
await db
.update(options)
.set({
score: matchingOption.score,
updatedAt: new Date(),
})
.where(eq(options.id, existingOption.id));
}
}
}
// Insert new options that do not exist in the database
const existingOptionTexts = existingOptions.map((opt) => opt.text);
const optionsToInsert = newOptionsData.filter((newOption) => !existingOptionTexts.includes(newOption.text));
if (optionsToInsert.length > 0) {
await db.insert(options).values(optionsToInsert);
}
}
return c.json({
message: "Question updated successfully",
message: "Question and options updated successfully",
});
}
)

View File

@ -48,6 +48,8 @@ export default function DashboardTable<T>({ table }: Props<T>) {
className="px-6 py-4 whitespace-nowrap text-sm text-black"
style={{
maxWidth: `${cell.column.columnDef.maxSize}px`,
whiteSpace: "normal",
wordWrap: "break-word",
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}

View File

@ -86,7 +86,7 @@ export default function MenuItem({ menu, isActive, onClick }: Props) {
<Icon className="w-4 h-4" />
</span>
{/* Label */}
<span className="text-xs font-bold">{menu.label}</span>
<span className="text-xs font-bold whitespace-normal">{menu.label}</span>
</div>
{/* Chevron Icon */}
{hasChildren && (

View File

@ -0,0 +1,214 @@
import { Button, Flex, Modal, ScrollArea } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { notifications } from "@mantine/notifications";
import { fetchAssessmentRequestManagementById, updateAssessmentRequestManagementStatus } from "../queries/assessmentRequestManagementQueries";
import createInputComponents from "@/utils/createInputComponents"; // Assuming you have this utility
import { useEffect } from "react";
// Define the API route for navigation
const routeApi = getRouteApi("/_dashboardLayout/assessmentRequestManagements/");
// Define allowed status values
type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "selesai";
interface AssessmentRequestManagementFormModalProps {
assessmentId: string | null;
isOpen: boolean;
onClose: () => void;
}
export default function AssessmentRequestManagementFormModal({
assessmentId,
isOpen,
onClose,
}: AssessmentRequestManagementFormModalProps) {
const queryClient = useQueryClient();
const navigate = routeApi.useNavigate();
const AssessmentRequestManagementQuery = useQuery({
queryKey: ["assessmentRequestManagements", assessmentId],
queryFn: async () => {
if (!assessmentId) return null;
return await fetchAssessmentRequestManagementById(assessmentId);
},
});
const form = useForm({
initialValues: {
tanggal: "",
nama: "",
posisi: "",
pengalamanKerja: "",
email: "",
namaPerusahaan: "",
alamat: "",
nomorTelepon: "",
username: "",
status: "menunggu konfirmasi" as AssessmentStatus,
},
});
// Populate the form once data is available
useEffect(() => {
if (AssessmentRequestManagementQuery.data) {
form.setValues({
tanggal: formatDate(AssessmentRequestManagementQuery.data.tanggal || "Data Kosong"),
nama: AssessmentRequestManagementQuery.data.nama || "Data Kosong",
posisi: AssessmentRequestManagementQuery.data.posisi || "Data Kosong",
pengalamanKerja: AssessmentRequestManagementQuery.data.pengalamanKerja || "Data Kosong",
email: AssessmentRequestManagementQuery.data.email || "Data Kosong",
namaPerusahaan: AssessmentRequestManagementQuery.data.namaPerusahaan || "Data Kosong",
alamat: AssessmentRequestManagementQuery.data.alamat || "Data Kosong",
nomorTelepon: AssessmentRequestManagementQuery.data.nomorTelepon || "Data Kosong",
username: AssessmentRequestManagementQuery.data.username || "Data Kosong",
status: AssessmentRequestManagementQuery.data.status || "menunggu konfirmasi",
});
}
}, [AssessmentRequestManagementQuery.data, form]);
const mutation = useMutation({
mutationKey: ["updateAssessmentRequestManagementStatusMutation"],
mutationFn: async ({
id,
status,
}: {
id: string;
status: AssessmentStatus;
}) => {
return await updateAssessmentRequestManagementStatus(id, status);
},
onError: (error: unknown) => {
if (error instanceof Error) {
notifications.show({
message: error.message,
color: "red",
});
}
},
onSuccess: () => {
notifications.show({
message: "Status Permohonan Asesmen berhasil diperbarui.",
color: "green",
});
queryClient.invalidateQueries({
queryKey: ["assessmentRequestManagements", assessmentId],
});
onClose();
},
});
const handleStatusChange = (status: AssessmentStatus) => {
if (assessmentId) {
mutation.mutate({ id: assessmentId, status });
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "Tanggal tidak tersedia";
const date = new Date(dateString);
if (isNaN(date.getTime())) return "Tanggal tidak valid";
return new Intl.DateTimeFormat("id-ID", {
hour12: true,
minute: "2-digit",
hour: "2-digit",
day: "2-digit",
month: "long",
year: "numeric",
}).format(date);
};
const { status } = form.values;
return (
<Modal opened={isOpen} onClose={onClose} title="Detail Permohonan Asesmen">
<ScrollArea style={{ height: "400px", paddingRight: "15px" }} scrollbarSize={8}>
{createInputComponents({
disableAll: mutation.isPending,
readonlyAll: true,
inputs: [
{
type: "text",
label: "Tanggal",
...form.getInputProps("tanggal"),
},
{
type: "text",
label: "Nama",
...form.getInputProps("nama"),
},
{
type: "text",
label: "Posisi",
...form.getInputProps("posisi"),
},
{
type: "text",
label: "Pengalaman Kerja",
...form.getInputProps("pengalamanKerja"),
},
{
type: "text",
label: "Email",
...form.getInputProps("email"),
},
{
type: "text",
label: "Nama Perusahaan",
...form.getInputProps("namaPerusahaan"),
},
{
type: "text",
label: "Alamat",
...form.getInputProps("alamat"),
},
{
type: "text",
label: "Nomor Telepon",
...form.getInputProps("nomorTelepon"),
},
{
type: "text",
label: "Username",
...form.getInputProps("username"),
},
{
type: "text",
label: "Status",
...form.getInputProps("status"),
},
],
})}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button variant="outline" onClick={onClose} disabled={mutation.isPending}>
Tutup
</Button>
{status !== "selesai" && (
<>
<Button
variant="filled"
color="red"
onClick={() => handleStatusChange("ditolak")}
disabled={mutation.isPending}
>
Tolak
</Button>
<Button
variant="filled"
color="blue"
onClick={() => handleStatusChange("diterima")}
disabled={mutation.isPending}
>
Terima
</Button>
</>
)}
</Flex>
</ScrollArea>
</Modal>
);
}

View File

@ -0,0 +1,38 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions } from "@tanstack/react-query";
// Define allowed status values
type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "selesai";
export const assessmentRequestManagementQueryOptions = (page: number, limit: number, q?: string) =>
queryOptions({
queryKey: ["assessmentRequestManagements", { page, limit, q }],
queryFn: () =>
fetchRPC(
client.assessmentRequestManagement.$get({
query: {
limit: String(limit),
page: String(page),
q,
},
})
),
});
export async function updateAssessmentRequestManagementStatus(id: string, status: AssessmentStatus) {
return await fetchRPC(
client.assessmentRequestManagement[":id"].$patch({
param: { id },
json: { status },
})
);
}
export async function fetchAssessmentRequestManagementById(id: string) {
return await fetchRPC(
client.assessmentRequestManagement[":id"].$get({
param: { id },
})
);
}

View File

@ -0,0 +1,99 @@
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 { deleteQuestion } from "../queries/questionQueries";
import { notifications } from "@mantine/notifications";
import fetchRPC from "@/utils/fetchRPC";
const routeApi = getRouteApi("/_dashboardLayout/questions/");
export default function QuestionDeleteModal() {
const queryClient = useQueryClient();
const searchParams = useSearch({ from: "/_dashboardLayout/questions/" }) as {
delete: string;
};
const questionId = searchParams.delete;
const navigate = routeApi.useNavigate();
const questionQuery = useQuery({
queryKey: ["questions", questionId],
queryFn: async () => {
if (!questionId) return null;
return await fetchRPC(
client.questions[":id"].$get({
param: {
id: questionId,
},
query: {},
})
);
},
});
const mutation = useMutation({
mutationKey: ["deleteQuestionMutation"],
mutationFn: async ({ id }: { id: string }) => {
return await deleteQuestion(id);
},
onError: (error: unknown) => {
if (error instanceof Error) {
notifications.show({
message: error.message,
color: "red",
});
}
},
onSuccess: () => {
notifications.show({
message: "Question deleted successfully.",
color: "green",
});
queryClient.removeQueries({ queryKey: ["question", questionId] });
queryClient.invalidateQueries({ queryKey: ["questions"] });
navigate({ search: {} });
},
});
const isModalOpen = Boolean(searchParams.delete && questionQuery.data);
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={`Delete confirmation`}
>
<Text size="sm">
Are you sure you want to delete question{" "}
<Text span fw={700}>
"{questionQuery.data?.question}"
</Text>
{" "}? This action is irreversible.
</Text>
{/* {errorMessage && <Alert color="red">{errorMessage}</Alert>} */}
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
variant="subtle"
// leftSection={<TbDeviceFloppy size={20} />}
type="submit"
color="red"
loading={mutation.isPending}
onClick={() => mutation.mutate({ id: questionId })}
>
Delete Question
</Button>
</Flex>
</Modal>
);
}

View File

@ -0,0 +1,346 @@
import { useForm } from "@mantine/form";
import {
Modal,
Stack,
Button,
Flex,
ActionIcon,
ScrollArea,
TextInput,
NumberInput,
Group,
} from "@mantine/core";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { TbDeviceFloppy, TbPlus, TbTrash } from "react-icons/tb";
import { useEffect } from "react";
import { notifications } from "@mantine/notifications";
import FormResponseError from "@/errors/FormResponseError";
import createInputComponents from "@/utils/createInputComponents";
import {
createQuestion,
getQuestionByIdQueryOptions,
updateQuestion,
fetchAspects,
fetchSubAspects,
} from "../queries/questionQueries";
const routeApi = getRouteApi("/_dashboardLayout/questions/");
interface Option {
questionId: string;
text: string;
score: number;
}
interface CreateQuestionPayload {
id?: string;
subAspectId: string;
question: string;
needFile: boolean;
options: Option[];
}
interface UpdateQuestionPayload {
id: string;
subAspectId: string;
question: string;
needFile: boolean;
options?: Option[];
}
export default function QuestionFormModal() {
const queryClient = useQueryClient();
const navigate = routeApi.useNavigate();
const searchParams = routeApi.useSearch();
const dataId = searchParams.detail || searchParams.edit;
const isModalOpen = Boolean(dataId || searchParams.create);
const detailId = searchParams.detail;
const editId = searchParams.edit;
const formType = detailId ? "detail" : editId ? "edit" : "create";
const form = useForm({
initialValues: {
id: "",
question: "",
needFile: false,
aspectId: "",
subAspectId: "",
options: [] as { id: string; text: string; score: number; questionId: string }[],
},
validate: {
aspectId: (value) => (value ? null : "Nama Aspek harus dipilih."),
subAspectId: (value) => (value ? null : "Nama Sub Aspek harus dipilih."),
question: (value) => (value ? null : "Pertanyaan tidak boleh kosong."),
options: {
text: (value) => (value ? null : "Jawaban tidak boleh kosong."),
score: (value) => (value >= 0 ? null : "Skor harus diisi dengan angka."),
},
},
});
// Fetch aspects and sub-aspects
const aspectsQuery = useQuery({
queryKey: ["aspects"],
queryFn: fetchAspects,
});
const subAspectsQuery = useQuery({
queryKey: ["subAspects"],
queryFn: fetchSubAspects,
});
// Check for form initialization and aspectId before filtering
const filteredSubAspects = form.values.aspectId
? subAspectsQuery.data?.filter(
(subAspect) => subAspect.aspectId === form.values.aspectId
) || []
: [];
const questionQuery = useQuery(getQuestionByIdQueryOptions(dataId));
const modalTitle =
formType.charAt(0).toUpperCase() + formType.slice(1) + " Pertanyaan";
useEffect(() => {
const data = questionQuery.data;
if (!data) {
form.reset();
return;
}
form.setValues({
id: data.id,
question: data.question ?? "",
needFile: data.needFile ?? false,
aspectId: data.aspectId ?? "",
subAspectId: data.subAspectId ?? "",
options: data.options.map((option) => ({
...option,
questionId: data.id,
})),
});
form.setErrors({});
}, [questionQuery.data]);
// Define possible actions, depending on the action, it can be one or the other
interface MutationOptions {
action: "edit" | "create";
data: CreateQuestionPayload | UpdateQuestionPayload;
}
interface MutationResponse {
message: string;
}
const mutation = useMutation<MutationResponse, Error, MutationOptions>({
mutationKey: ["questionsMutation"],
mutationFn: async (options) => {
if (options.action === "edit") {
return await updateQuestion(options.data as UpdateQuestionPayload);
} else {
return await createQuestion(options.data as CreateQuestionPayload);
}
},
});
const handleSubmit = async (values: CreateQuestionPayload) => {
if (formType === "detail") return;
const payload: CreateQuestionPayload = {
id: values.id,
question: values.question,
needFile: values.needFile,
subAspectId: values.subAspectId,
options: values.options.map((option) => ({
questionId: values.id || "",
text: option.text,
score: option.score,
})),
};
try {
if (formType === "create") {
await mutation.mutateAsync({ action: "create", data: payload });
notifications.show({
message: "Data pertanyaan berhasil dibuat!",
color: "green",
});
} else {
await mutation.mutateAsync({ action: "edit", data: payload });
notifications.show({
message: "Data pertanyaan berhasil diperbarui!",
color: "green",
});
}
queryClient.invalidateQueries({ queryKey: ["questions"] });
navigate({ search: {} });
} catch (error) {
if (error instanceof FormResponseError) {
form.setErrors(error.formErrors);
} else if (error instanceof Error) {
notifications.show({
message: error.message,
color: "red",
});
}
}
};
const handleAddOption = () => {
form.insertListItem("options", { id: "", text: "", score: 0 });
};
const handleRemoveOption = (index: number) => {
form.removeListItem("options", index);
};
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={modalTitle}
scrollAreaComponent={ScrollArea.Autosize}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
{createInputComponents({
disableAll: mutation.isPending,
readonlyAll: formType === "detail",
inputs: [
formType === "detail"
? {
type: "text",
label: "Nama Aspek",
readOnly: true,
value: aspectsQuery.data?.find(aspect => aspect.id === form.values.aspectId)?.name || "",
}
: {
type: "select",
label: "Nama Aspek",
placeholder: "Pilih Aspek",
data: aspectsQuery.data?.map((aspect) => ({
value: aspect.id,
label: aspect.name,
})) || [],
disabled: mutation.isPending,
...form.getInputProps("aspectId"),
required: true,
},
formType === "detail"
? {
type: "text",
label: "Nama Sub Aspek",
readOnly: true,
value: filteredSubAspects.find(subAspect => subAspect.id === form.values.subAspectId)?.name || "",
}
: {
type: "select",
label: "Nama Sub Aspek",
placeholder: "Pilih Sub Aspek",
data: filteredSubAspects.map((subAspect) => ({
value: subAspect.id,
label: subAspect.name,
})),
disabled: mutation.isPending,
...form.getInputProps("subAspectId"),
required: true,
},
{
type: "textarea",
label: "Pertanyaan",
placeholder: "Tulis Pertanyaan",
...form.getInputProps("question"),
},
formType === "detail"
? {
type: "text",
label: "Dibutuhkan Upload File?",
readOnly: true,
value: form.values.needFile ? "Ya" : "Tidak",
}
: {
type: "select",
label: "Dibutuhkan Upload File?",
placeholder: "Pilih opsi",
data: [
{ value: "true", label: "Ya" },
{ value: "false", label: "Tidak" },
],
value: form.values.needFile ? "true" : "false",
onChange: (value) => form.setFieldValue("needFile", value === "true"),
disabled: mutation.isPending,
required: true,
},
],
})}
{/* Options */}
<Stack mt="sm">
{form.values.options.map((option, index) => (
<Group key={index} mb="sm">
<TextInput
label={`Jawaban ${index + 1}`}
placeholder="Jawaban"
readOnly={formType === "detail"}
{...form.getInputProps(`options.${index}.text`)}
required
/>
<NumberInput
label="Skor"
placeholder="Skor"
readOnly={formType === "detail"}
min={0}
max={999}
allowDecimal={false}
allowNegative={false}
{...form.getInputProps(`options.${index}.score`)}
required
/>
{formType !== "detail" && (
<ActionIcon
color="red"
onClick={() => handleRemoveOption(index)}
>
<TbTrash />
</ActionIcon>
)}
</Group>
))}
{formType !== "detail" && (
<Button
onClick={handleAddOption}
leftSection={<TbPlus />}
>
Tambah Jawaban
</Button>
)}
</Stack>
{/* 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,115 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions } from "@tanstack/react-query";
import { InferRequestType } from "hono";
interface Option {
questionId: string;
text: string;
score: number;
}
interface CreateQuestionPayload {
subAspectId: string; // Ensure this matches the correct ID type
question: string;
needFile: boolean;
options: Option[]; // Array of options (text and score)
}
interface UpdateQuestionPayload {
id: string; // The ID of the question to update
subAspectId: string; // Ensure this matches the correct ID type
question: string;
needFile: boolean;
options?: Option[]; // Optional array of options (text and score)
}
export const questionQueryOptions = (page: number, limit: number, q?: string) =>
queryOptions({
queryKey: ["questions", { page, limit, q }],
queryFn: () =>
fetchRPC(
client.questions.$get({
query: {
limit: String(limit),
page: String(page),
q,
},
})
),
});
export const getQuestionByIdQueryOptions = (questionId: string | undefined) =>
queryOptions({
queryKey: ["question", questionId],
queryFn: () =>
fetchRPC(
client.questions[":id"].$get({
param: {
id: questionId!,
},
query: {},
})
),
enabled: Boolean(questionId),
});
export const createQuestion = async (form: CreateQuestionPayload) => {
return await fetchRPC(
client.questions.$post({
json: {
question: form.question,
needFile: form.needFile,
subAspectId: form.subAspectId,
options: form.options.map((option) => ({
text: option.text,
score: option.score,
})),
},
})
);
};
export const updateQuestion = async (form: UpdateQuestionPayload) => {
return await fetchRPC(
client.questions[":id"].$patch({
param: {
id: form.id,
},
json: {
question: form.question,
needFile: form.needFile,
subAspectId: form.subAspectId,
options: form.options?.map((option: Option) => ({
text: option.text,
score: option.score,
})),
},
})
);
};
export const deleteQuestion = async (id: string) => {
return await fetchRPC(
client.questions[":id"].$delete({
param: { id },
query: {},
})
);
};
export const fetchAspects = async () => {
return await fetchRPC(
client.questions.aspects.$get({
query: {} // Provide an empty query if no parameters are needed
}) // Adjust this based on your API client structure
);
};
export const fetchSubAspects = async () => {
return await fetchRPC(
client.questions.subAspects.$get({
query: {} // Provide an empty query if no parameters are needed
})
);
};

View File

@ -0,0 +1,137 @@
import { assessmentRequestManagementQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries";
import PageTemplate from "@/components/PageTemplate";
import { createLazyFileRoute } from "@tanstack/react-router";
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
import { createColumnHelper } from "@tanstack/react-table";
import { Badge, Flex } from "@mantine/core";
import createActionButtons from "@/utils/createActionButton";
import { TbEye } from "react-icons/tb";
import AssessmentRequestManagementFormModal from "@/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentRequestManagements/")({
component: AssessmentRequestManagementsPage,
});
type DataType = ExtractQueryDataType<typeof assessmentRequestManagementQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
export default function AssessmentRequestManagementsPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const queryClient = useQueryClient();
const handleDetailClick = (id: string) => {
setSelectedId(id);
setModalOpen(true);
};
// Helper function to format the date
const formatDate = (dateString: string | null) => {
if (!dateString) {
return "Tanggal tidak tersedia";
}
const date = new Date(dateString);
return new Intl.DateTimeFormat("id-ID", {
hour12: true,
minute: "2-digit",
hour: "2-digit",
day: "2-digit",
month: "long",
year: "numeric",
}).format(date);
};
return (
<PageTemplate
title="Manajemen Permohonan Asesmen"
queryOptions={assessmentRequestManagementQueryOptions}
modals={[
<AssessmentRequestManagementFormModal
key="form-modal"
assessmentId={selectedId}
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
queryClient.invalidateQueries();
}}
/>,
]}
createButton={null}
columnDefs={[
columnHelper.display({
header: "#",
cell: (props) => props.row.index + 1,
}),
columnHelper.display({
header: "Tanggal",
cell: (props) => formatDate(props.row.original.tanggal),
}),
columnHelper.display({
header: "Nama Responden",
cell: (props) => props.row.original.namaResponden,
}),
columnHelper.display({
header: "Nama Perusahaan",
cell: (props) => props.row.original.namaPerusahaan,
}),
columnHelper.display({
header: "Status",
cell: (props) => {
const status = props.row.original.status;
let statusLabel;
let color;
switch (status) {
case "menunggu konfirmasi":
statusLabel = "Menunggu Konfirmasi";
color = "yellow";
break;
case "diterima":
statusLabel = "Diterima";
color = "green";
break;
case "ditolak":
statusLabel = "Ditolak";
color = "red";
break;
case "selesai":
statusLabel = "Selesai";
color = "blue";
break;
default:
statusLabel = "Tidak Diketahui";
color = "gray";
break;
}
return <Badge color={color}>{statusLabel}</Badge>;
},
}),
columnHelper.display({
header: "Aksi",
cell: (props) => (
<Flex gap="xs">
{createActionButtons([
{
label: "Detail",
permission: true,
action: () => handleDetailClick(props.row.original.idPermohonan),
color: "green",
icon: <TbEye />,
},
])}
</Flex>
),
}),
]}
/>
);
}

View File

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

View File

@ -0,0 +1,88 @@
import { questionQueryOptions } from "@/modules/questionsManagement/queries/questionQueries";
import PageTemplate from "@/components/PageTemplate";
import { createLazyFileRoute } from "@tanstack/react-router";
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 QuestionDeleteModal from "@/modules/questionsManagement/modals/QuestionDeleteModal";
import QuestionFormModal from "@/modules/questionsManagement/modals/QuestionFormModal";
export const Route = createLazyFileRoute("/_dashboardLayout/questions/")({
component: QuestionsPage,
});
type DataType = ExtractQueryDataType<typeof questionQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
export default function QuestionsPage() {
return (
<PageTemplate
title="Manajemen Pertanyaan"
queryOptions={questionQueryOptions}
modals={[<QuestionFormModal />, <QuestionDeleteModal />]}
columnDefs={[
columnHelper.display({
header: "#",
cell: (props) => props.row.index + 1,
}),
columnHelper.display({
header: "Nama Aspek",
cell: (props) => props.row.original.aspectName,
}),
columnHelper.display({
header: "Nama Sub Aspek",
cell: (props) => props.row.original.subAspectName,
}),
columnHelper.display({
header: "Pertanyaan",
cell: (props) => props.row.original.question,
}),
columnHelper.display({
header: "Hasil Rata Rata",
cell: (props) => props.row.original.averageScore !== null
? props.row.original.averageScore
: 0,
}),
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: "Delete",
permission: true,
action: `?delete=${props.row.original.id}`,
color: "red",
icon: <TbTrash />,
},
])}
</Flex>
),
}),
]}
/>
);
}

View File

@ -0,0 +1,18 @@
import { questionQueryOptions } from "@/modules/questionsManagement/queries/questionQueries";
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/questions/")({
validateSearch: searchParamSchema,
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(questionQueryOptions(0, 10));
},
});