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
142 changed files with 28640 additions and 1032 deletions

View File

@ -1,7 +1,14 @@
BASE_URL =
APP_PORT = 3000
DATABASE_URL =
ACCESS_TOKEN_SECRET =
REFRESH_TOKEN_SECRET =
RESET_PASSWORD_TOKEN_SECRET =
COOKIE_SECRET =
SMTP_USERNAME =
SMTP_PASSWORD =
SMTP_HOST =
SMTP_PORT =

2
apps/backend/files/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -19,6 +19,7 @@
"hono": "^4.4.6",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"nodemailer": "^6.9.14",
"postgres": "^3.4.4",
"sharp": "^0.33.4",
"zod": "^3.23.8"
@ -27,6 +28,7 @@
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.2",
"@types/nodemailer": "^6.4.15",
"drizzle-kit": "^0.22.7",
"pg": "^8.12.0",
"tsx": "^4.15.5",

View File

@ -4,11 +4,17 @@ import { z } from "zod";
dotenv.config();
const envSchema = z.object({
BASE_URL: z.string(),
APP_PORT: z.coerce.number().int(),
DATABASE_URL: z.string(),
ACCESS_TOKEN_SECRET: z.string(),
REFRESH_TOKEN_SECRET: z.string(),
RESET_PASSWORD_TOKEN_SECRET: z.string(),
COOKIE_SECRET: z.string(),
SMTP_USERNAME: z.string(),
SMTP_PASSWORD: z.string(),
SMTP_HOST: z.string(),
SMTP_PORT: z.coerce.number().int(),
});
const parsedEnv = envSchema.safeParse(process.env);

View File

@ -32,6 +32,102 @@ const permissionsData = [
{
code: "roles.delete",
},
{
code: "questions.readAll",
},
{
code: "questions.create",
},
{
code: "questions.update",
},
{
code: "questions.delete",
},
{
code: "questions.restore",
},
{
code :"assessmentRequestManagement.readAll",
},
{
code: "assessmentRequestManagement.update",
},
{
code :"assessmentRequestManagement.read",
},
{
code: "managementAspect.readAll",
},
{
code: "managementAspect.create",
},
{
code: "managementAspect.update",
},
{
code: "managementAspect.delete",
},
{
code: "managementAspect.restore",
},
{
code: "assessmentResult.readAll",
},
{
code: "assessmentResult.read",
},
{
code: "assessmentResult.readAllQuestions",
},
{
code: "assessmentResult.create",
},
{
code: "assessmentRequest.read",
},
{
code: "assessmentRequest.create",
},
{
code: "assessmentRequest.update",
},
{
code: "assessments.readAspect",
},
{
code: "assessments.readAllQuestions",
},
{
code: "assessments.readAnswers",
},
{
code: "assessments.toggleFlag",
},
{
code: "assessments.uploadFile",
},
{
code: "assessments.submitOption",
},
{
code: "assessments.submitValidation",
},
{
code: "assessments.submitAssessment",
},
{
code: "assessments.readAverageSubAspect",
},
{
code: "assessments.readAverageAspect",
},
{
code: "assessments.updateOption",
},
{
code: "assessmentResult.update",
}
] as const;
export type SpecificPermissionCode = (typeof permissionsData)[number]["code"];

View File

@ -17,10 +17,18 @@ const roleData: RoleData[] = [
name: "Super Admin",
permissions: permissionsData.map((permission) => permission.code),
},
{
code: "user",
description:
"User with standard access rights for general usage of the application.",
isActive: true,
name: "User",
permissions: permissionsData.map((permission) => permission.code),
},
];
// Manually specify the union of role codes
export type RoleCode = "super-admin" | "*";
export type RoleCode = "super-admin" | "user" | "*";
const exportedRoleData = roleData;

View File

@ -2,17 +2,43 @@ import { SidebarMenu } from "../types";
const sidebarMenus: SidebarMenu[] = [
{
label: "Dashboard",
icon: { tb: "TbLayoutDashboard" },
allowedPermissions: ["*"],
link: "/dashboard",
},
{
label: "Users",
icon: { tb: "TbUsers" },
label: "Manajemen Pengguna",
icon: { tb: "TbUser" },
allowedPermissions: ["permissions.read"],
link: "/users",
color: "red",
},
{
label: "Manajemen Aspek",
icon: { tb: "TbClipboardText" },
allowedPermissions: ["permissions.read"],
link: "/aspect",
color: "blue",
},
{
label: "Manajemen Pertanyaan",
icon: { tb: "TbMessage2Cog" },
allowedPermissions: ["permissions.read"],
link: "/questions",
},
{
label: "Permohonan Asesmen",
icon: { tb: "TbMessageQuestion" },
allowedPermissions: ["permissions.read"],
link: "/assessmentRequest",
color: "green",
},
{
label: "Manajemen Permohonan Asesmen",
icon: { tb: "TbUserQuestion" },
allowedPermissions: ["permissions.read"],
link: "/assessmentRequestManagements",
color: "orange",
},
{
label: "Manajemen Hasil Asesmen",
icon: { tb: "TbReport" },
allowedPermissions: ["permissions.read"],
link: "/assessmentResultsManagement",
},
];

View File

@ -0,0 +1,24 @@
import { createId } from "@paralleldrive/cuid2";
import {
boolean,
pgTable,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { options } from "./options";
import { answers } from "./answers";
export const answerRevisions = pgTable("answer_revisions", {
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
answerId: varchar("answerId", { length: 50 })
.references(() => answers.id),
newOptionId: varchar("newOptionId", { length: 50 })
.references(() => options.id),
revisedBy: varchar("revisedBy").notNull(),
newValidationInformation: text("newValidationInformation").notNull(),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
});

View File

@ -0,0 +1,29 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import {
boolean,
pgTable,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { options } from "./options";
import { assessments } from "./assessments";
import { questions } from "./questions";
export const answers = pgTable("answers", {
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
questionId: varchar("questionId", { length: 50 })
.references(() => questions.id),
optionId: varchar("optionId", { length: 50 })
.references(() => options.id),
assessmentId: varchar("assessmentId", { length: 50 })
.references(() => assessments.id),
isFlagged: boolean("isFlagged").default(false),
filename: varchar("filename"),
validationInformation: text("validationInformation").notNull(),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
});

View File

@ -0,0 +1,16 @@
import { createId } from "@paralleldrive/cuid2";
import {
pgTable,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
export const aspects = pgTable("aspects", {
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
name: varchar("name", { length: 255 }).notNull(),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
deletedAt: timestamp("deletedAt", { mode: "date" }),
});

View File

@ -0,0 +1,23 @@
import { createId } from "@paralleldrive/cuid2";
import { pgEnum, pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { respondents } from "./respondents";
import { users } from "./users";
export const statusEnum = pgEnum("status", ["menunggu konfirmasi", "diterima", "ditolak",
"dalam pengerjaan", "belum diverifikasi", "selesai"]);
export const assessments = pgTable("assessments", {
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
respondentId: varchar("respondentId").references(() => respondents.id),
status: statusEnum("status"),
reviewedBy: varchar("reviewedBy"),
reviewedAt: timestamp("reviewedAt", { mode: "date" }),
verifiedBy: varchar("verifiedBy"),
verifiedAt: timestamp("verifiedAt", { mode: "date" }),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
});
// Query Tools in PosgreSQL
// CREATE TYPE status AS ENUM ('menunggu konfirmasi', 'diterima', 'ditolak', 'dalam pengerjaan', 'belum diverifikasi', 'selesai');

View File

@ -0,0 +1,23 @@
import { createId } from "@paralleldrive/cuid2";
import { relations } from "drizzle-orm";
import {
integer,
pgTable,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { questions } from "./questions";
export const options = pgTable("options", {
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
questionId: varchar("questionId", { length: 50 })
.references(() => questions.id),
text: text("text").notNull(),
score: integer("score").notNull(),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
deletedAt: timestamp("deletedAt", { mode: "date" }),
});

View File

@ -5,13 +5,13 @@ import { permissionsToUsers } from "./permissionsToUsers";
import { permissionsToRoles } from "./permissionsToRoles";
export const permissionsSchema = pgTable("permissions", {
id: text("id")
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
code: varchar("code", { length: 50 }).notNull().unique(),
description: varchar("description", { length: 255 }),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
createdAt: timestamp("createdAt").defaultNow(),
updatedAt: timestamp("updatedAt").defaultNow(),
});
export const permissionsRelations = relations(

View File

@ -1,4 +1,4 @@
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
import { permissionsSchema } from "./permissions";
import { relations } from "drizzle-orm";
import { rolesSchema } from "./roles";
@ -6,10 +6,10 @@ import { rolesSchema } from "./roles";
export const permissionsToRoles = pgTable(
"permissions_to_roles",
{
roleId: text("roleId")
roleId: varchar("roleId", { length: 50 })
.notNull()
.references(() => rolesSchema.id),
permissionId: text("permissionId")
permissionId: varchar("permissionId", { length: 50 })
.notNull()
.references(() => permissionsSchema.id),
},

View File

@ -1,4 +1,4 @@
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
import { users } from "./users";
import { permissionsSchema } from "./permissions";
import { relations } from "drizzle-orm";
@ -6,10 +6,10 @@ import { relations } from "drizzle-orm";
export const permissionsToUsers = pgTable(
"permissions_to_users",
{
userId: text("userId")
userId: varchar("userId", { length: 50 })
.notNull()
.references(() => users.id),
permissionId: text("permissionId")
permissionId: varchar("permissionId", { length: 50 })
.notNull()
.references(() => permissionsSchema.id),
},

View File

@ -0,0 +1,21 @@
import { createId } from "@paralleldrive/cuid2";
import {
boolean,
pgTable,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { subAspects } from "./subAspects"
export const questions = pgTable("questions", {
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
subAspectId: varchar("subAspectId").references(() => subAspects.id),
question: text("question"),
needFile: boolean("needFile").default(false),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
deletedAt: timestamp("deletedAt", { mode: "date" }),
});

View File

@ -0,0 +1,26 @@
import { createId } from "@paralleldrive/cuid2";
import { pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
export const respondents = pgTable("respondents", {
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
userId: varchar('userId').references(() => users.id).unique(),
companyName: varchar("companyName").notNull(),
position: varchar("position").notNull(),
workExperience: varchar("workExperience").notNull(),
address: text("address").notNull(),
phoneNumber: varchar("phoneNumber", { length: 13 }).notNull(),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
deletedAt: timestamp("deletedAt", { mode: "date" }),
});
export const respondentsRelations = relations(respondents, ({ one }) => ({
user: one(users, {
fields: [respondents.userId],
references: [users.id],
}),
}));

View File

@ -4,14 +4,14 @@ import { pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core";
import { permissionsToRoles } from "./permissionsToRoles";
export const rolesSchema = pgTable("roles", {
id: text("id")
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
code: varchar("code", { length: 50 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
description: varchar("description", { length: 255 }),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
createdAt: timestamp("createdAt").defaultNow(),
updatedAt: timestamp("updatedAt").defaultNow(),
});
export const rolesRelations = relations(rolesSchema, ({ many }) => ({

View File

@ -1,4 +1,4 @@
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
import { users } from "./users";
import { relations } from "drizzle-orm";
import { rolesSchema } from "./roles";
@ -6,10 +6,10 @@ import { rolesSchema } from "./roles";
export const rolesToUsers = pgTable(
"roles_to_users",
{
userId: text("userId")
userId: varchar("userId", { length: 50 })
.notNull()
.references(() => users.id),
roleId: text("roleId")
roleId: varchar("roleId", { length: 50 })
.notNull()
.references(() => rolesSchema.id),
},

View File

@ -0,0 +1,14 @@
import { createId } from "@paralleldrive/cuid2";
import { pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
import { aspects } from "./aspects";
export const subAspects = pgTable("sub_aspects", {
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
aspectId: varchar("aspectId").references(() => aspects.id),
name: varchar("name", { length: 255 }).notNull(),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
deletedAt: timestamp("deletedAt", { mode: "date" }),
});

View File

@ -9,22 +9,28 @@ import {
} from "drizzle-orm/pg-core";
import { permissionsToUsers } from "./permissionsToUsers";
import { rolesToUsers } from "./rolesToUsers";
import { respondents } from "./respondents";
export const users = pgTable("users", {
id: text("id")
id: varchar("id", { length: 50 })
.primaryKey()
.$defaultFn(() => createId()),
name: varchar("name", { length: 255 }).notNull(),
username: varchar("username").notNull().unique(),
email: varchar("email"),
email: varchar("email").notNull().unique(),
password: text("password").notNull(),
isEnabled: boolean("is_enable").default(true),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow(),
deletedAt: timestamp("deleted_at", { mode: "date" }),
isEnabled: boolean("isEnabled").default(true),
resetPasswordToken: varchar("resetPasswordToken"),
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
deletedAt: timestamp("deletedAt", { mode: "date" }),
});
export const usersRelations = relations(users, ({ many }) => ({
export const usersRelations = relations(users, ({ many, one}) => ({
permissionsToUsers: many(permissionsToUsers),
rolesToUsers: many(rolesToUsers),
respondent: one(respondents, {
fields: [users.id],
references: [respondents.userId],
}),
}));

View File

@ -2,6 +2,13 @@ import db from ".";
import permissionSeeder from "./seeds/permissionSeeder";
import roleSeeder from "./seeds/rolesSeeder";
import userSeeder from "./seeds/userSeeder";
import aspectsSeeder from "./seeds/aspectsSeeder";
import subAspectsSeeder from "./seeds/subAspectsSeeder";
import answersSeeder from "./seeds/answersSeeder";
import assessmentsSeeder from "./seeds/assessmentsSeeder";
import optionsSeeder from "./seeds/optionsSeeder";
import questionSeeder from "./seeds/questionSeeder";
import respondentSeeder from "./seeds/respondentsSeeder";
(async () => {
console.time("Done seeding");
@ -9,6 +16,13 @@ import userSeeder from "./seeds/userSeeder";
await permissionSeeder();
await roleSeeder();
await userSeeder();
await respondentSeeder();
await aspectsSeeder();
await subAspectsSeeder();
await questionSeeder();
await optionsSeeder();
await assessmentsSeeder();
await answersSeeder();
})().then(() => {
console.log("\n");
console.timeEnd("Done seeding");

View File

@ -0,0 +1,62 @@
import { createId } from "@paralleldrive/cuid2";
import db from "..";
import { answers } from "../schema/answers";
import { options } from "../schema/options";
import { assessments } from "../schema/assessments";
type answers = {
id: string;
optionId: string;
assessmentId: string | null;
isFlagged: boolean;
filename: string;
validationInformation: string;
createdAt: Date;
updatedAt: Date;
};
const answersSeeder = async () => {
const optionsData = await db.select().from(options);
const assessmentData = await db.select().from(assessments);
const answersData: answers[] = [];
optionsData.forEach(option => {
// assessmentData.forEach(assessment => {
answersData.push({
id: createId(),
optionId: option.id,
assessmentId: null,
isFlagged: false,
filename: "answer.pdf",
validationInformation: "Sudah teruji dengan baik",
createdAt: new Date(),
updatedAt: new Date(),
});
// });
});
console.log("Seeding answers...");
for (let submitAnswers of answersData) {
try {
const insertedAnswers = (
await db
.insert(answers)
.values(submitAnswers)
.onConflictDoNothing()
.returning()
)[0];
if (insertedAnswers) {
console.log(`Answers created`);
} else {
console.log(`Answers already exists`);
}
} catch (error) {
console.error(`Error inserting answers :`, error);
}
}
};
export default answersSeeder;

View File

@ -0,0 +1,63 @@
import { aspects } from "../schema/aspects";
import db from "..";
import { eq } from "drizzle-orm";
const aspectsSeeder = async () => {
const aspectsData: (typeof aspects.$inferInsert)[] = [
{
name: "Identifikasi",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
{
name: "Proteksi",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
{
name: "Deteksi",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
{
name: "Penanggulangan dan Pemulihan",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
];
console.log("Seeding aspects...");
for (let aspect of aspectsData) {
// Check if the aspect already exists
const existingAspect = await db
.select()
.from(aspects)
.where(eq(aspects.name, aspect.name))
.limit(1);
if (existingAspect.length === 0) {
// If the aspect does not exist, insert it
const insertedAspect = (
await db
.insert(aspects)
.values(aspect)
.onConflictDoNothing()
.returning()
)[0];
if (insertedAspect) {
console.log(`Aspect ${aspect.name} created`);
}
} else {
console.log(`Aspect ${aspect.name} already exists`);
}
}
};
export default aspectsSeeder;

View File

@ -0,0 +1,103 @@
import { createId } from "@paralleldrive/cuid2";
import db from "..";
import { assessments } from "../schema/assessments";
import { respondents } from "../schema/respondents";
import { users } from "../schema/users";
type AssessmentData = {
id: string;
status: "menunggu konfirmasi" | "diterima" | "ditolak" | "dalam pengerjaan" | "belum diverifikasi" | "selesai";
reviewedAt: Date;
reviewedBy: string;
verifiedBy: string;
verifiedAt: Date;
createdAt: Date,
respondentId: string;
};
const assessmentsSeeder = async () => {
const respondentsData = await db.select().from(respondents);
const assessmentsData: AssessmentData[] = [];
respondentsData.forEach((respondent) => {
assessmentsData.push({
id: createId(),
status: "diterima",
reviewedAt: new Date(),
reviewedBy: "Reviewer B",
verifiedBy: "Reviewer A",
verifiedAt: new Date(),
createdAt: new Date(),
respondentId: respondent.id,
});
assessmentsData.push({
id: createId(),
status: "ditolak",
reviewedAt: new Date(),
reviewedBy: "Reviewer C",
verifiedBy: "Reviewer D",
verifiedAt: new Date(),
createdAt: new Date(),
respondentId: respondent.id,
});
assessmentsData.push({
id: createId(),
status: "menunggu konfirmasi",
reviewedAt: new Date(),
reviewedBy: "Reviewer W",
verifiedBy: "Reviewer S",
verifiedAt: new Date(),
createdAt: new Date(),
respondentId: respondent.id,
});
assessmentsData.push({
id: createId(),
status: "selesai",
reviewedAt: new Date(),
reviewedBy: "Reviewer F",
verifiedBy: "Reviewer G",
verifiedAt: new Date(),
createdAt: new Date(),
respondentId: respondent.id,
});
assessmentsData.push({
id: createId(),
status: "selesai",
reviewedAt: new Date(),
reviewedBy: "Reviewer I",
verifiedBy: "Reviewer L",
verifiedAt: new Date(),
createdAt: new Date(),
respondentId: respondent.id,
});
});
console.log("Seeding assessments...");
for (let assessment of assessmentsData) {
try {
const insertedAssessment = (
await db
.insert(assessments)
.values(assessment)
.onConflictDoNothing()
.returning()
)[0];
if (insertedAssessment) {
console.log(`Assessment for respondent ${assessment.respondentId} created`);
} else {
console.log(`Assessment for respondent ${assessment.respondentId} already exists`);
}
} catch (error) {
console.error(`Error inserting assessment for respondent ${assessment.respondentId}:, error`);
}
}
};
export default assessmentsSeeder;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,110 @@
import db from "..";
import { eq, and } from "drizzle-orm";
import { users } from "../schema/users";
import { respondents } from "../schema/respondents";
const respondentSeeder = async () => {
const respondentsData: (typeof respondents.$inferInsert & { userName: string })[] = [
{
userName: "Respondent User 2",
companyName: "Company C",
position: "Employee",
workExperience: "4 years",
address: "Address D",
phoneNumber: "1234567890123",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
{
userName: "Respondent User 3",
companyName: "Company DD",
position: "Manager Account",
workExperience: "6 years",
address: "Address HRT",
phoneNumber: "1234567890123",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
{
userName: "Respondent User 4",
companyName: "Company FS",
position: "Developer",
workExperience: "8 years",
address: "Address HFD",
phoneNumber: "1234567890123",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
{
userName: "Respondent User 5",
companyName: "Company DA",
position: "Start",
workExperience: "3 years",
address: "Address JWO",
phoneNumber: "1234567890123",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
},
];
console.log("Seeding users and respondents...");
const memoizedUserIds: Map<string, string> = new Map();
for (let respondent of respondentsData) {
if (!memoizedUserIds.has(respondent.userName)) {
const user = (
await db
.select({ id: users.id })
.from(users)
.where(eq(users.name, respondent.userName))
)[0];
if (!user) {
throw new Error(`User ${respondent.userName} does not exist in the database`);
}
memoizedUserIds.set(respondent.userName, user.id);
}
const UserId = memoizedUserIds.get(respondent.userName)!;
const existingRespondent = await db
.select()
.from(respondents)
.where(and(eq(respondents.companyName, respondent.userName), eq(respondents.userId, UserId)))
.limit(1);
if (existingRespondent.length === 0) {
const insertedRespondent = (
await db
.insert(respondents)
.values({
companyName: respondent.companyName,
position: respondent.position,
workExperience: respondent.workExperience,
address: respondent.address,
phoneNumber: respondent.phoneNumber,
createdAt: respondent.createdAt,
updatedAt: respondent.updatedAt,
deletedAt: respondent.deletedAt,
userId: UserId,
})
.onConflictDoNothing()
.returning()
)[0];
if (insertedRespondent) {
console.log(`Respondent ${respondent.companyName} created and linked to respondent ${respondent.userName}`);
}
} else {
console.log(`Respondent ${respondent.companyName} already exists`);
}
}
};
export default respondentSeeder;

View File

@ -0,0 +1,204 @@
import { subAspects } from "../schema/subAspects";
import db from "..";
import { aspects } from "../schema/aspects";
import { eq, and } from "drizzle-orm";
const subAspectSeeder = async () => {
const subAspectsData: (typeof subAspects.$inferInsert & {
aspectName: string;
})[] = [
/////// Aspect 1 identifikasi
{
name: "Mengidentifikasi Peran dan tanggung jawab organisasi",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Identifikasi",
},
{
name: "Menyusun strategi, kebijakan, dan prosedur Pelindungan IIV",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Identifikasi",
},
{
name: "Menilai dan mengelola risiko Keamanan Siber",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Identifikasi",
},
{
name: "Mengelola aset informasi",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Identifikasi",
},
{
name: "Mengelola aset informasi",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Identifikasi",
},
{
name: "Mengelola risiko rantai pasok",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Identifikasi",
},
/////// Aspect 2 Proteksi
{
name: "Mengelola identitas, autentikasi, dan kendali akses",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Proteksi",
},
{
name: "Melindungi aset fisik",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Proteksi",
},
{
name: "Melindungi data",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Proteksi",
},
{
name: "Melindungi aplikasi",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Proteksi",
},
{
name: "Melindungi jaringan",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Proteksi",
},
{
name: "Melindungi sumber daya manusia",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Proteksi",
},
/////// Aspect 3 Deteksi
{
name: "Mengelola deteksi Peristiwa Siber",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Deteksi",
},
{
name: "Menganalisis anomali dan Peristiwa Siber",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Deteksi",
},
{
name: "Memantau Peristiwa Siber berkelanjutan",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Deteksi",
},
/////// Aspect 4 Gulih
{
name: "Menyusun perencanaan penanggulangan dan pemulihan Insiden Siber",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Penanggulangan dan Pemulihan",
},
{
name: "Menganalisis dan melaporkan Insiden Siber",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Penanggulangan dan Pemulihan",
},
{
name: "Melaksanakan penanggulangan dan pemulihan Insiden Siber",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Penanggulangan dan Pemulihan",
},
{
name: "Meningkatkan keamanan setelah terjadinya Insiden Siber",
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
aspectName: "Penanggulangan dan Pemulihan",
},
];
console.log("Seeding subAspects...");
const memoizedAspectIds: Map<string, string> = new Map();
for (let subAspect of subAspectsData) {
// Check if aspect ID is already memoized
if (!memoizedAspectIds.has(subAspect.aspectName)) {
const aspect = (
await db
.select({ id: aspects.id })
.from(aspects)
.where(eq(aspects.name, subAspect.aspectName))
)[0];
if (!aspect) {
throw new Error(`Aspect ${subAspect.aspectName} does not exist in the database`);
}
memoizedAspectIds.set(subAspect.aspectName, aspect.id);
}
const aspectId = memoizedAspectIds.get(subAspect.aspectName)!;
// Check if the subAspect already exists
const existingSubAspect = await db
.select()
.from(subAspects)
.where(and(eq(subAspects.name, subAspect.name), eq(subAspects.aspectId, aspectId)))
.limit(1);
if (existingSubAspect.length === 0) {
// If the subAspect does not exist, insert it
const insertedSubAspect = (
await db
.insert(subAspects)
.values({
name: subAspect.name,
createdAt: subAspect.createdAt,
updatedAt: subAspect.updatedAt,
deletedAt: subAspect.deletedAt,
aspectId: aspectId
})
.onConflictDoNothing()
.returning()
)[0];
if (insertedSubAspect) {
console.log(`SubAspect ${subAspect.name} created and linked to aspect ${subAspect.aspectName}`);
}
} else {
console.log(`SubAspect ${subAspect.name} already exists`);
}
}
};
export default subAspectSeeder;

View File

@ -11,8 +11,37 @@ const userSeeder = async () => {
name: "Super Admin",
password: await hashPassword("123456"),
username: "superadmin",
email: "admin@admin.com",
roles: ["super-admin"],
},
{
name: "Respondent User 2",
password: await hashPassword("123456"),
username: "respondentUser2",
email: "respondentUser2@gmail.com",
roles: ["user"],
},
{
name: "Respondent User 3",
password: await hashPassword("123456"),
username: "respondentUser3",
email: "respondentUser3@gmail.com",
roles: ["user"],
},
{
name: "Respondent User 4",
password: await hashPassword("123456"),
username: "respondentUser4",
email: "respondentUser4@gmail.com",
roles: ["user"],
},
{
name: "Respondent User 5",
password: await hashPassword("123456"),
username: "respondentUser5",
email: "respondentUser5@gmail.com",
roles: ["user"],
},
];
console.log("Seeding users...");

View File

@ -3,6 +3,8 @@ import { configDotenv } from "dotenv";
import { Hono } from "hono";
import authRoutes from "./routes/auth/route";
import usersRoute from "./routes/users/route";
import managementAspectsRoute from "./routes/managementAspect/route";
import respondentsRoute from "./routes/register/route";
import { verifyAccessToken } from "./utils/authUtils";
import permissionRoutes from "./routes/permissions/route";
import { cors } from "hono/cors";
@ -15,6 +17,12 @@ import DashboardError from "./errors/DashboardError";
import HonoEnv from "./types/HonoEnv";
import devRoutes from "./routes/dev/route";
import appEnv from "./appEnv";
import questionsRoute from "./routes/questions/route";
import assessmentResultRoute from "./routes/assessmentResult/route";
import assessmentRequestRoute from "./routes/assessmentRequest/route";
import forgotPasswordRoutes from "./routes/forgotPassword/route";
import assessmentsRoute from "./routes/assessments/route";
import assessmentsRequestManagementRoutes from "./routes/assessmentRequestManagement/route";
configDotenv();
@ -78,11 +86,20 @@ const routes = app
.route("/dashboard", dashboardRoutes)
.route("/roles", rolesRoute)
.route("/dev", devRoutes)
.route("/questions", questionsRoute)
.route("/management-aspect", managementAspectsRoute)
.route("/register", respondentsRoute)
.route("/assessmentResult", assessmentResultRoute)
.route("/assessmentRequest", assessmentRequestRoute)
.route("/forgot-password", forgotPasswordRoutes)
.route("/assessments", assessmentsRoute)
.route("/assessmentRequestManagement",assessmentsRequestManagementRoutes)
.onError((err, c) => {
if (err instanceof DashboardError) {
return c.json(
{
message: err.message,
errorCode: err.errorCode,
formErrors: err.formErrors,
},

View File

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

View File

@ -0,0 +1,278 @@
import { eq, sql, ilike, and, desc} from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
import db from "../../drizzle";
import { respondents } from "../../drizzle/schema/respondents";
import { assessments } from "../../drizzle/schema/assessments";
import { users } from "../../drizzle/schema/users";
import HonoEnv from "../../types/HonoEnv";
import authInfo from "../../middlewares/authInfo";
import { forbidden, notFound } from "../../errors/DashboardError";
import checkPermission from "../../middlewares/checkPermission";
import requestValidator from "../../utils/requestValidator";
import { HTTPException } from "hono/http-exception";
import { createId } from "@paralleldrive/cuid2";
import { questions } from "../../drizzle/schema/questions";
import { subAspects } from "../../drizzle/schema/subAspects";
import { aspects } from "../../drizzle/schema/aspects";
import { answers } from "../../drizzle/schema/answers";
import { options } from "../../drizzle/schema/options";
const assessmentRequestRoute = new Hono<HonoEnv>()
.use(authInfo)
// Get assessment request by user ID
.get(
"/",
checkPermission("assessmentRequest.read"),
requestValidator(
"query",
z.object({
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(10),
q: z.string().optional(),
})
),
async (c) => {
const 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() !== ""
?sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`
: undefined
)
)
const totalCountResult = await totalCountQuery;
const totalItems = totalCountResult[0]?.count || 0;
// Query to get assessment data with pagination
const queryResult = await db
.select({
userId: users.id,
name: users.name,
assessmentId: assessments.id,
tanggal: assessments.createdAt,
status: assessments.status,
respondentId: respondents.id,
})
.from(users)
.leftJoin(respondents, eq(users.id, respondents.userId))
.leftJoin(assessments, eq(respondents.id, assessments.respondentId))
.where(
and(
eq(users.id, userId),
q && q.trim() !== ""
// ? ilike(sql`${assessments.status}::text`, `%${q}%`) // Cast status to text for ilike
?sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`
: undefined
)
)
.orderBy(desc(assessments.createdAt))
.offset(page * limit)
.limit(limit);
if (!queryResult[0]) throw notFound();
return c.json({
data: queryResult,
_metadata: {
currentPage: page,
totalPages: Math.ceil(totalItems / limit),
totalItems,
perPage: limit,
},
});
}
)
// Post assessment request by user ID
.post(
"/",
checkPermission("assessmentRequest.create"),
requestValidator(
"json",
z.object({
respondentId: z.string().min(1), // Ensure respondentId has at least one character
})
),
async (c) => {
const { respondentId } = c.req.valid("json");
const currentUser = c.get("currentUser");
const userId = currentUser?.id; // Get userId from currentUser stored in context
if (!userId) {
return c.text("User not authenticated", 401);
}
// Validate if respondent with respondentId exists
const respondent = await db
.select()
.from(respondents)
.where(and(eq(respondents.id, respondentId), eq(respondents.userId, userId)));
if (!respondent.length) {
throw new HTTPException(404, { message: "Respondent not found or unauthorized." });
}
// Check if there is an assessment request with status "in progress"
const existingAssessment = await db
.select()
.from(assessments)
.where(
and(
eq(assessments.respondentId, respondentId),
eq(assessments.status, "dalam pengerjaan")
)
);
if (!existingAssessment.length) {
const newAssessment = await db
.insert(assessments)
.values({
id: createId(),
respondentId,
status: "menunggu konfirmasi",
reviewedAt: null,
reviewedBy: null,
verifiedBy: null,
verifiedAt: null,
createdAt: new Date(),
})
.returning();
return c.json({ message: "Successfully submitted the assessment request", data: newAssessment }, 201);
}
}
)
/*Update assessment status when the user clicks the start assessment button
and Post all answers for assessment by ID */
.patch(
"/:assessmentId",
checkPermission("assessmentRequest.update"),
requestValidator(
"json",
z.object({
status: z.enum([
"menunggu konfirmasi",
"diterima",
"ditolak",
"dalam pengerjaan",
"belum diverifikasi",
"selesai",
]).optional(),
})
),
async (c) => {
const currentUser = c.get("currentUser");
const userId = currentUser?.id;
const { assessmentId } = c.req.param();
const { status } = c.req.valid("json");
if (!userId) {
throw forbidden({
message: "User not authenticated",
});
}
// Check if the assessment with the given assessmentId exists and belongs to the user
const existingAssessment = await db
.select()
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.where(and(eq(assessments.id, assessmentId), eq(respondents.userId, userId)));
if (!existingAssessment.length) {
throw notFound({
message: "Assessment not found or unauthorized.",
});
}
// Update assessment status to "dalam pengerjaan"
const updatedAssessment = await db
.update(assessments)
.set({
status: status || "dalam pengerjaan",
})
.where(eq(assessments.id, assessmentId))
.returning();
// Get all questions with options related to the assessment by aspect and sub-aspect
const questionsWithOptions = await db
.select({
questionId: questions.id,
})
.from(questions)
.leftJoin(options, eq(questions.id, options.questionId))
.where(sql`${options.id} IS NOT NULL`) // Filter only questions with options
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
.groupBy(questions.id);
if (!questionsWithOptions.length) {
throw notFound({
message: "No questions found for this assessment.",
});
}
// Check answers that already have optionId
const existingAnswers = await db
.select({
questionId: answers.questionId,
})
.from(answers)
.where(
and(eq(answers.assessmentId, assessmentId), sql`${answers.optionId} IS NOT NULL`)
);
const existingAnswerIds = new Set(existingAnswers.map((answer) => answer.questionId));
// Create a list of new answers only for questions that have options and do not have existing answers
const answerRecords = questionsWithOptions
.filter((q) => !existingAnswerIds.has(q.questionId))
.map((q) => ({
id: createId(),
questionId: q.questionId,
optionId: null,
validationInformation: "",
assessmentId,
}));
// Insert new answers with null values
if (answerRecords.length > 0) {
await db.insert(answers).values(answerRecords);
}
return c.json({
message: "Assessment status updated started successfully",
data: {
updatedAssessment,
answerRecords,}
});
}
)
export default assessmentRequestRoute;

View File

@ -0,0 +1,217 @@
import { and, eq, ilike, or, sql, asc, inArray } 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({
respondentId: z.string().min(1),
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "dalam pengerjaan", "belum diverifikasi", "selesai"]),
reviewedBy: z.string().min(1),
verifiedBy: z.string().min(1),
verifiedAt: z.string().optional(),
});
export const assessmentUpdateSchema = assessmentFormSchema.extend({
verifiedAt: z.string().optional().or(z.literal("")),
});
const assessmentsRequestManagementRoutes = new Hono<HonoEnv>()
.use(authInfo)
/**
* Get All Assessments (With Metadata)
*
* Query params:
* - withMetadata: boolean
*/
.get(
"/",
checkPermission("assessmentRequestManagement.readAll"),
requestValidator(
"query",
z.object({
withMetadata: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(10),
q: z.string().default(""),
})
),
async (c) => {
const { page, limit, q } = c.req.valid("query");
const validStatuses = [
"dalam pengerjaan",
"menunggu konfirmasi",
"diterima",
"ditolak",
] as ("menunggu konfirmasi" | "diterima" | "ditolak" | "dalam pengerjaan")[];
// Query untuk menghitung total jumlah item (totalCountQuery)
const assessmentCountQuery = await db
.select({
count: sql<number>`count(*)`,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(
and(
inArray(assessments.status, validStatuses),
q
? 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(
and(
inArray(assessments.status, validStatuses),
q
? or(
ilike(users.name, `%${q}%`),
ilike(respondents.companyName, `%${q}%`),
sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`,
eq(assessments.id, q)
)
: undefined
)
)
.orderBy(
sql`
CASE
WHEN ${assessments.status} = 'menunggu konfirmasi' THEN 1
WHEN ${assessments.status} = 'dalam pengerjaan' THEN 2
WHEN ${assessments.status} = 'diterima' THEN 3
WHEN ${assessments.status} = 'ditolak' THEN 4
ELSE 5
END
`,
asc(assessments.createdAt)
)
.offset(page * limit)
.limit(limit);
return c.json({
data: result.map((d) => ({
idPermohonan: d.idPermohonan,
namaResponden: d.namaResponden,
namaPerusahaan: d.namaPerusahaan,
status: d.status,
tanggal: d.tanggal,
})),
_metadata: {
currentPage: page,
totalPages: Math.ceil(totalItems / limit),
totalItems,
perPage: limit,
},
});
}
)
// Get assessment by id
.get(
"/:id",
checkPermission("assessmentRequestManagement.read"),
async (c) => {
const assessmentId = c.req.param("id");
const queryResult = await db
.select({
tanggal: assessments.createdAt,
nama: users.name,
posisi: respondents.position,
pengalamanKerja: respondents.workExperience,
email: users.email,
namaPerusahaan: respondents.companyName,
alamat: respondents.address,
nomorTelepon: respondents.phoneNumber,
username: users.username,
status: assessments.status,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(eq(assessments.id, assessmentId));
if (!queryResult.length)
throw new HTTPException(404, {
message: "The assessment does not exist",
});
const assessmentData = queryResult[0];
return c.json(assessmentData);
}
)
.patch(
"/:id",
checkPermission("assessmentRequestManagement.update"),
requestValidator(
"json",
z.object({
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "dalam pengerjaan", "belum diverifikasi", "selesai"]),
})
),
async (c) => {
const assessmentId = c.req.param("id");
const { status } = c.req.valid("json");
const userName = c.var.currentUser?.name;
const assessment = await db
.select()
.from(assessments)
.where(and(eq(assessments.id, assessmentId),));
if (!assessment[0]) throw new HTTPException(404, {
message: "Assessment tidak ditemukan.",
});
const currentDate = new Date();
await db
.update(assessments)
.set({
status,
reviewedBy: userName,
reviewedAt: currentDate,
})
.where(eq(assessments.id, assessmentId));
return c.json({
message: "Status assessment berhasil diperbarui.",
});
}
)
export default assessmentsRequestManagementRoutes;

View File

@ -0,0 +1,620 @@
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
import db from "../../drizzle";
import { assessments, statusEnum } from "../../drizzle/schema/assessments";
import { respondents } from "../../drizzle/schema/respondents";
import { users } from "../../drizzle/schema/users";
import { aspects } from "../../drizzle/schema/aspects";
import { subAspects } from "../../drizzle/schema/subAspects";
import { questions } from "../../drizzle/schema/questions";
import { options } from "../../drizzle/schema/options";
import { answers } from "../../drizzle/schema/answers";
import { answerRevisions } from "../../drizzle/schema/answerRevisions";
import HonoEnv from "../../types/HonoEnv";
import authInfo from "../../middlewares/authInfo";
import checkPermission from "../../middlewares/checkPermission";
import requestValidator from "../../utils/requestValidator";
import { notFound } from "../../errors/DashboardError";
// optionFormSchema: untuk /submitOption
export const optionFormSchema = z.object({
optionId: z.string().min(1),
assessmentId: z.string().min(1),
questionId: z.string().min(1),
isFlagged: z.boolean().optional().default(false),
filename: z.string().optional(),
});
// validationFormSchema: untuk /submitValidation
export const validationFormSchema = z.object({
assessmentId: z.string().min(1),
questionId: z.string().min(1),
newValidationInformation: z.string().min(1, "Validation information is required"),
});
const assessmentRoute = new Hono<HonoEnv>()
.use(authInfo)
// Get All List of Assessment Results
.get(
"/",
checkPermission("assessmentResult.readAll"),
requestValidator(
"query",
z.object({
withMetadata: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(40),
q: z.string().default(""),
})
),
async (c) => {
const { page, limit, q } = c.req.valid("query");
const totalItems = await db
.select({
count: sql<number>`COUNT(*)`,
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(
and(
or(
q ? or(
ilike(users.name, q),
ilike(respondents.companyName, q)
) : undefined,
),
or(
eq(assessments.status, 'belum diverifikasi'),
eq(assessments.status, 'selesai')
)
)
);
const result = await db
.select({
id: assessments.id,
respondentName: users.name,
companyName: respondents.companyName,
statusAssessments: assessments.status,
statusVerification: sql<string>`
CASE
WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi'
ELSE 'belum diverifikasi'
END`
.as("statusVerification"),
assessmentsResult: sql<number>`
(SELECT ROUND(AVG(${options.score}), 2)
FROM ${answers}
JOIN ${options} ON ${options.id} = ${answers.optionId}
JOIN ${questions} ON ${questions.id} = ${options.questionId}
JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId}
JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId}
WHERE ${answers.assessmentId} = ${assessments.id})`
.as("assessmentsResult"),
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(
and(
or(
q ? or(
ilike(users.name, q),
ilike(respondents.companyName, q),
) : undefined,
),
or(
eq(assessments.status, 'belum diverifikasi'),
eq(assessments.status, 'selesai')
)
)
)
.orderBy(
sql`CASE
WHEN ${assessments.status} = 'belum diverifikasi' THEN 1
WHEN ${assessments.status} = 'selesai' THEN 2
ELSE 3
END`
)
.offset(page * limit)
.limit(limit);
const totalCountResult = await totalItems;
const totalCount = totalCountResult[0]?.count || 0;
return c.json({
data: result,
_metadata: {
currentPage: page,
totalPages: Math.ceil(totalCount / limit),
totalItems: totalCount,
perPage: limit,
},
});
}
)
// Get Assessment Result by ID
.get(
"/:id",
checkPermission("assessmentResult.read"),
async (c) => {
const assessmentId = c.req.param("id");
const result = await db
.select({
respondentName: users.name,
position: respondents.position,
workExperience: respondents.workExperience,
email: users.email,
companyName: respondents.companyName,
address: respondents.address,
phoneNumber: respondents.phoneNumber,
username: users.username,
assessmentDate: assessments.createdAt,
statusAssessment: assessments.status,
statusVerification: sql<string>`
CASE
WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi'
ELSE 'belum diverifikasi'
END`
.as("statusVerification"),
assessmentsResult: sql<number>`
(SELECT ROUND(AVG(${options.score}), 2)
FROM ${answers}
JOIN ${options} ON ${options.id} = ${answers.optionId}
JOIN ${questions} ON ${questions.id} = ${options.questionId}
JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId}
JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId}
WHERE ${answers.assessmentId} = ${assessments.id})`
.as("assessmentsResult"),
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(eq(assessments.id, assessmentId));
if (!result.length) {
throw notFound({
message: "Assessment not found",
});
}
return c.json(result[0]);
}
)
.get(
"/verified/:id",
checkPermission("assessmentResult.read"),
async (c) => {
const assessmentId = c.req.param("id");
const result = await db
.select({
respondentName: users.name,
position: respondents.position,
workExperience: respondents.workExperience,
email: users.email,
companyName: respondents.companyName,
address: respondents.address,
phoneNumber: respondents.phoneNumber,
username: users.username,
assessmentDate: assessments.createdAt,
statusAssessment: assessments.status,
statusVerification: sql<string>`
CASE
WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi'
ELSE 'belum diverifikasi'
END`.as("statusVerification"),
verifiedAssessmentsResult: sql<number>`
(SELECT ROUND(AVG(${options.score}), 2)
FROM ${answerRevisions}
JOIN ${answers} ON ${answers.id} = ${answerRevisions.answerId}
JOIN ${options} ON ${options.id} = ${answerRevisions.newOptionId}
JOIN ${questions} ON ${questions.id} = ${options.questionId}
JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId}
JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId}
WHERE ${answers.assessmentId} = ${assessments.id})`.as("verifiedAssessmentsResult"),
})
.from(assessments)
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
.leftJoin(users, eq(respondents.userId, users.id))
.where(eq(assessments.id, assessmentId));
if (!result.length) {
throw notFound({
message: "Assessment not found",
});
}
return c.json(result[0]);
}
)
// Get all Questions and Options that relate to Sub Aspects and Aspects based on Assessment ID
.get(
"getAllQuestion/:id",
checkPermission("assessmentResult.readAllQuestions"),
async (c) => {
const assessmentId = c.req.param("id");
if (!assessmentId) {
throw notFound({
message: "Assessment ID is missing",
});
}
// Total count of options related to the assessment
const totalCountQuery = sql<number>`
SELECT count(*)
FROM ${options}
LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id}
LEFT JOIN ${subAspects} ON ${questions.subAspectId} = ${subAspects.id}
LEFT JOIN ${aspects} ON ${subAspects.aspectId} = ${aspects.id}
LEFT JOIN ${answers} ON ${options.id} = ${answers.optionId}
WHERE ${questions.deletedAt} IS NULL
AND ${answers.assessmentId} = ${assessmentId}
`;
// Query to get detailed information about options
const result = await db
.select({
optionId: options.id,
aspectsId: aspects.id,
aspectsName: aspects.name,
subAspectId: subAspects.id,
subAspectName: subAspects.name,
questionId: questions.id,
questionText: questions.question,
answerId: answers.id,
answerText: options.text,
answerScore: options.score,
})
.from(options)
.leftJoin(questions, eq(options.questionId, questions.id))
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
.leftJoin(answers, eq(options.id, answers.optionId))
.where(sql`${questions.deletedAt} IS NULL AND ${answers.assessmentId} = ${assessmentId}`);
// Execute the total count query
const totalCountResult = await db.execute(totalCountQuery);
if (result.length === 0) {
throw notFound({
message: "Data does not exist",
});
}
return c.json({
data: result,
totalCount: totalCountResult[0]?.count || 0
});
}
)
.get(
'/average-score/sub-aspects/assessments/:assessmentId',
checkPermission("assessments.readAverageSubAspect"),
async (c) => {
const { assessmentId } = c.req.param();
const averageScores = await db
.select({
aspectId: subAspects.aspectId,
subAspectId: subAspects.id,
subAspectName: subAspects.name,
average: sql`AVG(options.score)`
})
.from(answerRevisions)
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
.innerJoin(questions, eq(options.questionId, questions.id))
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
.where(eq(assessments.id, assessmentId))
.groupBy(subAspects.id);
return c.json({
assessmentId,
subAspects: averageScores.map(score => ({
subAspectId: score.subAspectId,
subAspectName: score.subAspectName,
averageScore: score.average,
aspectId: score.aspectId
}))
});
}
)
.get(
'/average-score/aspects/assessments/:assessmentId',
checkPermission("assessments.readAverageAspect"),
async (c) => {
const { assessmentId } = c.req.param();
// Query untuk mendapatkan average score per aspect
const aspectScores = await db
.select({
aspectId: aspects.id,
aspectName: aspects.name,
averageScore: sql`AVG(options.score)`,
})
.from(answerRevisions)
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
.innerJoin(questions, eq(options.questionId, questions.id))
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.innerJoin(aspects, eq(subAspects.aspectId, aspects.id))
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
.where(eq(assessments.id, assessmentId))
.groupBy(aspects.id);
// Query untuk mendapatkan average score per sub-aspect
const subAspectScores = await db
.select({
aspectId: subAspects.aspectId,
subAspectId: subAspects.id,
subAspectName: subAspects.name,
averageScore: sql`AVG(options.score)`,
})
.from(answerRevisions)
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
.innerJoin(questions, eq(options.questionId, questions.id))
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
.where(eq(assessments.id, assessmentId))
.groupBy(subAspects.id);
// Menggabungkan sub-aspects ke dalam masing-masing aspect
const aspectsWithSubAspects = aspectScores.map((aspect) => ({
aspectId: aspect.aspectId,
aspectName: aspect.aspectName,
averageScore: aspect.averageScore,
subAspects: subAspectScores
.filter((sub) => sub.aspectId === aspect.aspectId)
.map((sub) => ({
subAspectId: sub.subAspectId,
subAspectName: sub.subAspectName,
averageScore: sub.averageScore,
})),
}));
return c.json({
assessmentId,
aspects: aspectsWithSubAspects,
});
}
)
// Get all Answers Data by Assessment Id
.get(
"/getAnswers/:id",
checkPermission("assessments.readAnswers"),
async (c) => {
const assessmentId = c.req.param("id").toString();
// Query to count total answers for the specific assessmentId
const totalCountQuery = sql<number>`(SELECT count(*)
FROM ${answerRevisions}
JOIN ${answers} ON ${answers.id} = ${answerRevisions.answerId}
JOIN ${assessments} ON ${answers.assessmentId} = ${assessments.id}
WHERE ${assessments.id} = ${assessmentId})`;
// Query to retrieve answers for the specific assessmentId
const result = await db
.select({
id: answerRevisions.id,
answerId: answerRevisions.answerId,
newOptionId: answerRevisions.newOptionId,
newValidationInformation: answerRevisions.newValidationInformation,
questionId: options.questionId,
fullCount: totalCountQuery,
})
.from(answerRevisions)
.leftJoin(answers, eq(answers.id, answerRevisions.answerId))
.leftJoin(options, eq(answerRevisions.newOptionId, options.id))
.leftJoin(assessments, eq(answers.assessmentId, assessments.id))
.where(eq(assessments.id, assessmentId),
)
return c.json({
data: result.map((d) => ({ ...d, fullCount: undefined })),
});
}
)
// POST Endpoint for creating multiple answer revisions based on assessmentId
.post(
"/answer-revisions",
checkPermission("assessmentResult.create"),
requestValidator(
"json",
z.object({
assessmentId: z.string(),
revisedBy: z.string(), // assuming this will come from the session or auth context
})
),
async (c) => {
const { assessmentId, revisedBy } = c.req.valid("json");
// Fetch answers related to the given assessmentId
const existingAnswers = await db
.select()
.from(answers)
.where(eq(answers.assessmentId, assessmentId));
if (!existingAnswers.length) {
throw notFound({
message: "No answers found for the given assessment ID",
});
}
// Fetch already existing revisions for the given answer IDs
const existingRevisions = await db
.select({ answerId: answerRevisions.answerId })
.from(answerRevisions)
.where(
or(
...existingAnswers.map((answer) =>
eq(answerRevisions.answerId, answer.id)
)
)
);
// Create a Set of existing revision IDs for quick lookup
const existingRevisionIds = new Set(
existingRevisions.map(revision => revision.answerId)
);
// Prepare revisions to be inserted, excluding those that already exist
const revisions = existingAnswers
.filter(answer => !existingRevisionIds.has(answer.id)) // Filter out existing revisions
.map(answer => ({
answerId: answer.id,
newOptionId: answer.optionId, // Assuming you want to keep the existing optionId
newValidationInformation: answer.validationInformation, // Keep the existing validation information
revisedBy: revisedBy
}));
if (revisions.length === 0) {
return c.json({
message: "No new revisions to create, as all answers are already revised.",
});
}
// Insert all new revisions in a single operation
const newRevisions = await db
.insert(answerRevisions)
.values(revisions)
.returning();
return c.json(
{
message: "Answer revisions created successfully",
data: newRevisions,
},
201
);
}
)
.patch(
"/:id",
checkPermission("assessmentResult.update"),
async (c) => {
const assessmentId = c.req.param("id");
if (!assessmentId) {
throw notFound({
message: "Assessment tidak ada",
});
}
const assessment = await db
.select()
.from(assessments)
.where(and(eq(assessments.id, assessmentId)));
if (!assessment[0]) throw notFound();
await db
.update(assessments)
.set({
verifiedAt: new Date(),
})
.where(eq(assessments.id, assessmentId));
console.log("Verified Success");
return c.json({
message: "Assessment berhasil diverifikasi",
data: assessment,
});
}
)
.patch(
"/submitAssessmentRevision/:id",
checkPermission("assessments.submitAssessment"),
async (c) => {
const assessmentId = c.req.param("id");
const status = "selesai";
const assessment = await db
.select()
.from(assessments)
.where(and(eq(assessments.id, assessmentId),));
if (!assessment[0]) {
throw notFound({
message: "Assessment not found.",
});
}
await db
.update(assessments)
.set({
status,
})
.where(eq(assessments.id, assessmentId));
return c.json({
message: "Status assessment berhasil diperbarui.",
});
}
)
.post(
"/updateValidation",
checkPermission("assessments.submitValidation"),
requestValidator("json", validationFormSchema),
async (c) => {
const validationData = c.req.valid("json");
// Cek apakah jawaban ada berdasarkan assessmentId dan questionId
const [targetAnswer] = await db
.select({ id: answers.id })
.from(answers)
.leftJoin(options, eq(answers.optionId, options.id))
.where(
sql`answers."assessmentId" = ${validationData.assessmentId}
AND options."questionId" = ${validationData.questionId}`
)
.limit(1);
if (!targetAnswer) {
return c.json(
{ message: "Answer not found for given assessmentId and questionId" },
404
);
}
// Dapatkan tanggal dan waktu saat ini
const currentDate = new Date();
// Update dengan melakukan JOIN yang sama
const [updatedRevision] = await db
.update(answerRevisions)
.set({
newValidationInformation: validationData.newValidationInformation,
})
.where(sql`"answerId" = ${targetAnswer.id}`)
.returning();
return c.json(
{
message: "Revision updated successfully",
revision: updatedRevision, // Revisi yang baru saja diperbarui
},
200
);
}
)
export default assessmentRoute;

View File

@ -0,0 +1,779 @@
import { and, eq, ilike, isNull, inArray, or, sql, is } from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
import db from "../../drizzle";
import { answers } from "../../drizzle/schema/answers";
import { options } from "../../drizzle/schema/options";
import { questions } from "../../drizzle/schema/questions";
import { subAspects } from "../../drizzle/schema/subAspects";
import { aspects } from "../../drizzle/schema/aspects";
import { assessments } from "../../drizzle/schema/assessments";
import HonoEnv from "../../types/HonoEnv";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
import checkPermission from "../../middlewares/checkPermission";
import path from "path";
import fs from 'fs';
import { notFound } from "../../errors/DashboardError";
import { answerRevisions } from "../../drizzle/schema/answerRevisions";
export const answerFormSchema = z.object({
optionId: z.string().min(1),
assessmentId: z.string().min(1),
isFlagged: z.boolean().optional().default(false),
filename: z.string().optional(),
validationInformation: z.string().min(1),
});
// optionFormSchema: untuk /submitOption
export const optionFormSchema = z.object({
optionId: z.string().min(1),
assessmentId: z.string().min(1),
questionId: z.string().min(1),
isFlagged: z.boolean().optional().default(false),
filename: z.string().optional(),
});
// newOptionFormSchema: untuk /updateOption
export const newOptionFormSchema = z.object({
newOptionId: z.string().min(1),
assessmentId: z.string().min(1),
questionId: z.string().min(1),
});
// validationFormSchema: untuk /submitValidation
export const validationFormSchema = z.object({
assessmentId: z.string().min(1),
questionId: z.string().min(1),
validationInformation: z.string().min(1, "Validation information is required"),
});
// newValidationFormSchema: untuk /updateValidation
export const newValidationFormSchema = z.object({
assessmentId: z.string().min(1),
questionId: z.string().min(1),
newValidationInformation: z.string().min(1, "Validation information is required"),
});
// validationFormSchema: untuk /submitValidation
export const flagFormSchema = z.object({
assessmentId: z.string().min(1),
questionId: z.string().min(1),
isFlagged: z.boolean().optional().default(false),
});
export const answerUpdateSchema = answerFormSchema.partial();
// Helper untuk menyimpan file
async function saveFile(filePath: string, fileBuffer: Buffer): Promise<void> {
await fs.promises.writeFile(filePath, fileBuffer);
}
// Cari answer berdasarkan assessmentId dan questionId
async function findAnswerId(assessmentId: string, questionId: string): Promise<string | null> {
const result = await db
.select({ answerId: answers.id })
.from(answers)
.leftJoin(options, eq(answers.optionId, options.id))
.where(
and(
eq(answers.assessmentId, assessmentId),
eq(options.questionId, questionId)
)
)
.limit(1);
return result.length > 0 ? result[0].answerId : null;
}
// Update filename di tabel answers
async function updateFilename(answerId: string, filename: string): Promise<void> {
// Dapatkan tanggal dan waktu saat ini
const currentDate = new Date();
await db
.update(answers)
.set({
filename,
updatedAt: currentDate,
})
.where(eq(answers.id, answerId));
}
const assessmentsRoute = new Hono<HonoEnv>()
.use(authInfo)
// Get all aspects
.get(
"/aspect",
checkPermission("assessments.readAspect"),
requestValidator(
"query",
z.object({
includeTrashed: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
withMetadata: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(10),
q: z.string().default(""),
})
),
async (c) => {
const { includeTrashed, page, limit, q } = c.req.valid("query");
const totalCountQuery = includeTrashed
? sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})`
: sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`;
const aspectIdsQuery = await db
.select({
id: aspects.id,
})
.from(aspects)
.where(
and(
includeTrashed ? undefined : isNull(aspects.deletedAt),
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
)
)
.offset(page * limit)
.limit(limit);
const aspectIds = aspectIdsQuery.map(a => a.id);
if (aspectIds.length === 0) {
return c.json({
data: [],
_metadata: {
currentPage: page,
totalPages: 0,
totalItems: 0,
perPage: limit,
},
});
}
// Main query to get aspects, sub-aspects, and number of questions
const result = await db
.select({
id: aspects.id,
name: aspects.name,
createdAt: aspects.createdAt,
updatedAt: aspects.updatedAt,
...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}),
subAspectId: subAspects.id,
subAspectName: subAspects.name,
// Increase the number of questions related to sub aspects
questionCount: sql<number>`(
SELECT count(*)
FROM ${questions}
WHERE ${questions.subAspectId} = ${subAspects.id}
)`.as('questionCount'),
fullCount: totalCountQuery,
})
.from(aspects)
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
.where(inArray(aspects.id, aspectIds));
// Grouping sub aspects by aspect ID
const groupedResult = result.reduce((acc, curr) => {
const aspectId = curr.id;
if (!acc[aspectId]) {
acc[aspectId] = {
id: curr.id,
name: curr.name,
createdAt: curr.createdAt ? new Date(curr.createdAt).toISOString() : null,
updatedAt: curr.updatedAt ? new Date(curr.updatedAt).toISOString() : null,
subAspects: curr.subAspectName
? [{ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount }]
: [],
};
} else {
if (curr.subAspectName) {
const exists = acc[aspectId].subAspects.some(sub => sub.id === curr.subAspectId);
if (!exists) {
acc[aspectId].subAspects.push({
id: curr.subAspectId!,
name: curr.subAspectName,
questionCount: curr.questionCount,
});
}
}
}
return acc;
}, {} as Record<string, {
id: string;
name: string;
createdAt: string | null;
updatedAt: string | null;
subAspects: { id: string; name: string; questionCount: number }[];
}>);
const groupedArray = Object.values(groupedResult);
return c.json({
data: groupedArray,
_metadata: {
currentPage: page,
totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit),
totalItems: Number(result[0]?.fullCount) ?? 0,
perPage: limit,
},
});
}
)
// Get all Questions and Options that relate to Sub Aspects and Aspects
.get(
"/getAllQuestions",
checkPermission("assessments.readAllQuestions"),
async (c) => {
// Definisikan tipe untuk hasil query dan izinkan nilai null
type QuestionWithOptions = {
aspectsId: string | null;
aspectsName: string | null;
subAspectId: string | null;
subAspectName: string | null;
questionId: string | null;
questionText: string | null;
optionId: string;
optionText: string;
needFile: boolean | null;
optionScore: number;
fullCount?: number;
};
const totalCountQuery =
sql<number>`(SELECT count(*)
FROM ${options}
LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id}
LEFT JOIN ${subAspects} ON ${questions.subAspectId} = ${subAspects.id}
LEFT JOIN ${aspects} ON ${subAspects.aspectId} = ${aspects.id}
WHERE ${questions.deletedAt} IS NULL
)`;
// Sesuaikan tipe hasil query
const result: QuestionWithOptions[] = await db
.select({
aspectsId: aspects.id,
aspectsName: aspects.name,
subAspectId: subAspects.id,
subAspectName: subAspects.name,
questionId: questions.id,
questionText: questions.question,
optionId: options.id,
optionText: options.text,
needFile: questions.needFile,
optionScore: options.score,
fullCount: totalCountQuery,
})
.from(options)
.leftJoin(questions, eq(options.questionId, questions.id))
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
.where(sql`${questions.deletedAt} IS NULL`);
// Definisikan tipe untuk hasil pengelompokan
type GroupedQuestion = {
questionId: string | null;
questionText: string | null;
needFile: boolean | null;
aspectsId: string | null;
aspectsName: string | null;
subAspectId: string | null;
subAspectName: string | null;
options: {
optionId: string;
optionText: string;
optionScore: number;
}[];
};
// Mengelompokkan berdasarkan questionId
const groupedResult: GroupedQuestion[] = result.reduce((acc, current) => {
const { questionId, questionText, needFile, aspectsId, aspectsName, subAspectId, subAspectName, optionId, optionText, optionScore } = current;
// Cek apakah questionId sudah ada dalam accumulator
const existingQuestion = acc.find(q => q.questionId === questionId);
if (existingQuestion) {
// Tambahkan opsi baru ke array options dari pertanyaan yang ada
existingQuestion.options.push({
optionId,
optionText,
optionScore
});
} else {
// Jika pertanyaan belum ada, tambahkan objek baru
acc.push({
questionId,
questionText,
needFile,
aspectsId,
aspectsName,
subAspectId,
subAspectName,
options: [
{
optionId,
optionText,
optionScore
}
]
});
}
return acc;
}, [] as GroupedQuestion[]); // Pastikan tipe untuk accumulator didefinisikan
return c.json({
data: groupedResult,
});
}
)
// Get all Answers Data by Assessment Id
.get(
"/getAnswers",
checkPermission("assessments.readAnswers"),
requestValidator(
"query",
z.object({
assessmentId: z.string(), // Require assessmentId as a query parameter
})
),
async (c) => {
const { assessmentId } = c.req.valid("query");
// Query to count total answers for the specific assessmentId
const totalCountQuery =
sql<number>`(SELECT count(*)
FROM ${answers}
WHERE ${answers.assessmentId} = ${assessmentId})`;
// Query to retrieve answers for the specific assessmentId
const result = await db
.select({
id: answers.id,
assessmentId: answers.assessmentId,
questionId: options.questionId,
optionId: answers.optionId,
isFlagged: answers.isFlagged,
filename: answers.filename,
validationInformation: answers.validationInformation,
fullCount: totalCountQuery,
})
.from(answers)
.leftJoin(options, eq(answers.optionId, options.id))
.where(
and(
eq(answers.assessmentId, assessmentId), // Filter by assessmentId
)
)
return c.json({
data: result.map((d) => ({ ...d, fullCount: undefined })),
});
}
)
// Toggles the isFlagged field between true and false
.patch(
"/toggleFlag",
checkPermission("assessments.toggleFlag"),
requestValidator("json", flagFormSchema),
async (c) => {
const flagData = c.req.valid("json");
// Update jawaban yang ada berdasarkan assessmentId dan questionId
const answer = await db
.update(answers)
.set({
isFlagged: flagData.isFlagged, // Ubah ke pilihan baru
})
.where(
and(
eq(answers.assessmentId, flagData.assessmentId),
eq(answers.questionId, flagData.questionId)
)
)
.returning();
return c.json(
{
message: "Flag changed successfully",
answer: answer[0],
},
200
);
}
)
// Upload filename to the table answers and save the file on the local storage
.post(
"/uploadFile",
checkPermission("assessments.uploadFile"),
async (c) => {
const contentType = c.req.header('content-type');
if (!contentType || !contentType.includes('multipart/form-data')) {
return c.json({ message: "Invalid Content-Type" }, 400);
}
const boundary = contentType.split('boundary=')[1];
if (!boundary) {
return c.json({ message: "Boundary not found" }, 400);
}
const body = await c.req.arrayBuffer();
const bodyString = Buffer.from(body).toString();
const parts = bodyString.split(`--${boundary}`);
let fileUrl: string | null = null;
for (const part of parts) {
if (part.includes('Content-Disposition: form-data;')) {
const match = /filename="(.+?)"/.exec(part);
if (match) {
const fileName = match[1];
const fileContentStart = part.indexOf('\r\n\r\n') + 4;
const fileContentEnd = part.lastIndexOf('\r\n');
const fileBuffer = Buffer.from(part.slice(fileContentStart, fileContentEnd), 'binary');
const filePath = path.join('files', `${Date.now()}-${fileName}`);
await saveFile(filePath, fileBuffer);
const assessmentId = c.req.query('assessmentId');
const questionId = c.req.query('questionId');
if (!assessmentId || !questionId) {
return c.json({ message: "assessmentId and questionId are required" }, 400);
}
const answerId = await findAnswerId(assessmentId, questionId);
if (!answerId) {
return c.json({ message: 'Answer not found' }, 404);
}
await updateFilename(answerId, path.basename(filePath));
fileUrl = `/files/${path.basename(filePath)}`;
}
}
}
if (!fileUrl) {
return c.json({ message: 'No file uploaded' }, 400);
}
return c.json({ success: true, imageUrl: fileUrl });
}
)
.post(
"/submitOption",
checkPermission("assessments.submitOption"),
requestValidator("json", optionFormSchema),
async (c) => {
const optionData = c.req.valid("json");
// Update jawaban yang ada berdasarkan assessmentId dan questionId
const answer = await db
.update(answers)
.set({
optionId: optionData.optionId, // Ubah ke pilihan baru
})
.where(
and(
eq(answers.assessmentId, optionData.assessmentId),
eq(answers.questionId, optionData.questionId)
)
)
.returning();
return c.json(
{
message: "Option submitted successfully",
answer: answer[0],
},
200
);
}
)
.post(
"/submitValidation",
checkPermission("assessments.submitValidation"),
requestValidator("json", validationFormSchema),
async (c) => {
const validationData = c.req.valid("json");
// Cek apakah jawaban ada berdasarkan assessmentId dan questionId
const existingAnswer = await db
.select()
.from(answers)
.leftJoin(options, eq(answers.optionId, options.id))
.leftJoin(questions, eq(options.questionId, questions.id))
.where(
sql`answers."assessmentId" = ${validationData.assessmentId}
AND questions.id = ${validationData.questionId}`
)
.limit(1);
if (existingAnswer.length === 0) {
return c.json(
{
message: "No existing answer found for the given assessmentId and questionId.",
},
404
);
}
// Dapatkan tanggal dan waktu saat ini
const currentDate = new Date();
// Update dengan melakukan JOIN yang sama
const updatedAnswer = await db
.update(answers)
.set({
validationInformation: validationData.validationInformation,
updatedAt: currentDate,
})
.where(
sql`answers."assessmentId" = ${validationData.assessmentId}
AND answers."optionId" IN (
SELECT id FROM options WHERE "questionId" = ${validationData.questionId}
)`
)
.returning();
return c.json(
{
message: "Validation information updated successfully",
answer: updatedAnswer[0],
},
200
);
}
)
.patch(
"/submitAssessment/:id",
checkPermission("assessments.submitAssessment"),
async (c) => {
const assessmentId = c.req.param("id");
const status = "belum diverifikasi";
const assessment = await db
.select()
.from(assessments)
.where(and(eq(assessments.id, assessmentId),));
if (!assessment[0]) {
throw notFound({
message: "Assessment not found.",
});
}
await db
.update(assessments)
.set({
status,
})
.where(eq(assessments.id, assessmentId));
return c.json({
message: "Status assessment berhasil diperbarui.",
});
}
)
// Get data for All Sub Aspects average score By Assessment Id
.get(
'/average-score/sub-aspects/assessments/:assessmentId',
checkPermission("assessments.readAverageSubAspect"),
async (c) => {
const { assessmentId } = c.req.param();
const averageScores = await db
.select({
aspectId: subAspects.aspectId,
subAspectId: subAspects.id,
subAspectName: subAspects.name,
average: sql`AVG(options.score)`
})
.from(answers)
.innerJoin(options, eq(answers.optionId, options.id))
.innerJoin(questions, eq(options.questionId, questions.id))
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
.where(eq(assessments.id, assessmentId))
.groupBy(subAspects.id);
return c.json({
assessmentId,
subAspects: averageScores.map(score => ({
subAspectId: score.subAspectId,
subAspectName: score.subAspectName,
averageScore: score.average,
aspectId: score.aspectId
}))
});
}
)
// Get data for Aspects average score and all related Sub Aspects average score By Assessment Id
.get(
'/average-score/aspects/assessments/:assessmentId',
checkPermission("assessments.readAverageAspect"),
async (c) => {
const { assessmentId } = c.req.param();
// Query untuk mendapatkan average score per aspect
const aspectScores = await db
.select({
aspectId: aspects.id,
aspectName: aspects.name,
averageScore: sql`AVG(options.score)`,
})
.from(answers)
.innerJoin(options, eq(answers.optionId, options.id))
.innerJoin(questions, eq(options.questionId, questions.id))
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.innerJoin(aspects, eq(subAspects.aspectId, aspects.id))
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
.where(eq(assessments.id, assessmentId))
.groupBy(aspects.id);
// Query untuk mendapatkan average score per sub-aspect
const subAspectScores = await db
.select({
aspectId: subAspects.aspectId,
subAspectId: subAspects.id,
subAspectName: subAspects.name,
averageScore: sql`AVG(options.score)`,
})
.from(answers)
.innerJoin(options, eq(answers.optionId, options.id))
.innerJoin(questions, eq(options.questionId, questions.id))
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
.where(eq(assessments.id, assessmentId))
.groupBy(subAspects.id);
// Menggabungkan sub-aspects ke dalam masing-masing aspect
const aspectsWithSubAspects = aspectScores.map((aspect) => ({
aspectId: aspect.aspectId,
aspectName: aspect.aspectName,
averageScore: aspect.averageScore,
subAspects: subAspectScores
.filter((sub) => sub.aspectId === aspect.aspectId)
.map((sub) => ({
subAspectId: sub.subAspectId,
subAspectName: sub.subAspectName,
averageScore: sub.averageScore,
})),
}));
return c.json({
assessmentId,
aspects: aspectsWithSubAspects,
});
}
)
.patch(
"/updateOption",
checkPermission("assessments.updateOption"),
requestValidator("json", newOptionFormSchema),
async (c) => {
const optionData = c.req.valid("json");
// Temukan answerId yang sesuai berdasarkan assessmentId dan questionId
const [targetAnswer] = await db
.select({ id: answers.id })
.from(answers)
.leftJoin(options, eq(answers.optionId, options.id))
.where(
sql`answers."assessmentId" = ${optionData.assessmentId}
AND options."questionId" = ${optionData.questionId}`
)
.limit(1);
if (!targetAnswer) {
return c.json(
{ message: "Answer not found for given assessmentId and questionId" },
404
);
}
// Lakukan update pada answer_revisions menggunakan answerId yang ditemukan
const [updatedRevision] = await db
.update(answerRevisions)
.set({
newOptionId: optionData.newOptionId,
})
.where(sql`"answerId" = ${targetAnswer.id}`)
.returning();
return c.json(
{
message: "Revision updated successfully",
revision: updatedRevision, // Revisi yang baru saja diperbarui
},
200
);
}
)
.patch(
"/updateOption",
checkPermission("assessments.updateOption"),
requestValidator("json", newOptionFormSchema),
async (c) => {
const optionData = c.req.valid("json");
// Temukan answerId yang sesuai berdasarkan assessmentId dan questionId
const [targetAnswer] = await db
.select({ id: answers.id })
.from(answers)
.leftJoin(options, eq(answers.optionId, options.id))
.where(
sql`answers."assessmentId" = ${optionData.assessmentId}
AND options."questionId" = ${optionData.questionId}`
)
.limit(1);
if (!targetAnswer) {
return c.json(
{ message: "Answer not found for given assessmentId and questionId" },
404
);
}
// Lakukan update pada answer_revisions menggunakan answerId yang ditemukan
const [updatedRevision] = await db
.update(answerRevisions)
.set({
newOptionId: optionData.newOptionId,
})
.where(sql`"answerId" = ${targetAnswer.id}`)
.returning();
return c.json(
{
message: "Revision updated successfully",
revision: updatedRevision, // Revisi yang baru saja diperbarui
},
200
);
}
)
export default assessmentsRoute;

View File

@ -134,6 +134,7 @@ const authRoutes = new Hono<HonoEnv>()
user: {
id: user[0].users.id,
name: user[0].users.name,
role: user[0].roles?.code,
permissions: Array.from(permissions),
},
});

View File

@ -0,0 +1,111 @@
import { zValidator } from "@hono/zod-validator";
import HonoEnv from "../../types/HonoEnv";
import { z } from "zod";
import { and, eq, isNull } from "drizzle-orm";
import { Hono } from "hono";
import db from "../../drizzle";
import { users } from "../../drizzle/schema/users";
import { notFound, unauthorized } from "../../errors/DashboardError";
import { generateResetPasswordToken, verifyResetPasswordToken } from "../../utils/authUtils";
import { sendResetPasswordEmail } from "../../utils/mailerUtils";
import { hashPassword } from "../../utils/passwordUtils";
const forgotPasswordRoutes = new Hono<HonoEnv>()
/**
* Forgot Password
*
* Checking emails in the database, generating tokens, and sending emails occurs.
*/
.post(
'/',
zValidator(
'json',
z.object({
email: z.string().email(),
})
),
async (c) => {
const { email } = c.req.valid('json');
const user = await db
.select()
.from(users)
.where(
and(
isNull(users.deletedAt),
eq(users.email, email)
)
);
if (!user.length) throw notFound();
// Generate reset password token
const resetPasswordToken = await generateResetPasswordToken({
uid: user[0].id,
});
await db
.update(users)
.set({
resetPasswordToken: resetPasswordToken
})
.where(eq(users.email, email));
// Send email with reset password token
await sendResetPasswordEmail(email, resetPasswordToken);
return c.json({
message: 'Email has been sent successfully',
});
}
)
/**
* Reset Password
*/
.patch(
'/verify',
zValidator(
'json',
z.object({
password: z.string(),
confirm_password: z.string()
})
),
async (c) => {
const formData = c.req.valid('json');
const token = c.req.query('token')
// Token validation
if (!token) {
return c.json({ message: 'Token is required' }, 400);
}
// Password validation
if (formData.password !== formData.confirm_password) {
return c.json({ message: 'Passwords do not match' }, 400);
}
const decoded = await verifyResetPasswordToken(token);
if (!decoded) {
return c.json({ message: 'Invalid or expired token' }, 401);
}
if (!decoded) throw unauthorized();
// Hash the password
const hashedPassword = await hashPassword(formData.password);
await db
.update(users)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(users.id, decoded.uid));
return c.json({
message: 'Password has been reset successfully'
});
});
export default forgotPasswordRoutes;

View File

@ -0,0 +1,530 @@
import { and, eq, ilike, isNull, or, sql, inArray } from "drizzle-orm";
import { Hono } from "hono";
import { questions } from "../../drizzle/schema/questions";
import { z } from "zod";
import db from "../../drizzle";
import { aspects } from "../../drizzle/schema/aspects";
import { subAspects } from "../../drizzle/schema/subAspects";
import HonoEnv from "../../types/HonoEnv";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
import checkPermission from "../../middlewares/checkPermission";
import { forbidden } from "../../errors/DashboardError";
import { notFound } from "../../errors/DashboardError";
// Schema for creating and updating aspects
export const aspectFormSchema = z.object({
name: z.string().min(1).max(50),
subAspects: z
.string()
.refine(
(data) => {
try {
const parsed = JSON.parse(data);
return Array.isArray(parsed);
} catch {
return false;
}
},
{
message: "Sub Aspects must be an array",
}
)
.optional(),
});
// Schema for creating and updating subAspects
export const subAspectFormSchema = z.object({
id: z.string(),
name: z.string().min(1).max(50),
aspectId: z.string().optional(),
});
export const aspectUpdateSchema = z.object({
name: z.string(),
subAspects: z.array(subAspectFormSchema),
});
export const subAspectUpdateSchema = subAspectFormSchema.extend({});
const managementAspectRoute = new Hono<HonoEnv>()
.use(authInfo)
/**
* Get All Aspects (With Metadata)
*
* Query params:
* - includeTrashed: boolean (default: false)
* - withMetadata: boolean
*/
// Get all aspects
.get(
"/",
checkPermission("managementAspect.readAll"),
requestValidator(
"query",
z.object({
includeTrashed: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
withMetadata: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(10),
q: z.string().default(""),
})
),
async (c) => {
const { includeTrashed, page, limit, q } = c.req.valid("query");
const aspectCountQuery = await db
.select({
count: sql<number>`count(*)`,
})
.from(aspects)
.where(
and(
includeTrashed ? undefined : isNull(aspects.deletedAt),
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
)
);
const totalItems = Number(aspectCountQuery[0]?.count) || 0;
const aspectIdsQuery = await db
.select({
id: aspects.id,
})
.from(aspects)
.where(
and(
includeTrashed ? undefined : isNull(aspects.deletedAt),
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
)
)
.orderBy(aspects.name)
.offset(page * limit)
.limit(limit);
const aspectIds = aspectIdsQuery.map(a => a.id);
if (aspectIds.length === 0) {
return c.json({
data: [],
_metadata: {
currentPage: page,
totalPages: 0,
totalItems: 0,
perPage: limit,
},
});
}
// Main query to get aspects, sub-aspects, and number of questions
const result = await db
.select({
id: aspects.id,
name: aspects.name,
createdAt: aspects.createdAt,
updatedAt: aspects.updatedAt,
...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}),
subAspectId: subAspects.id,
subAspectName: subAspects.name,
// Increase the number of questions related to sub aspects
questionCount: sql<number>`(
SELECT count(*)
FROM ${questions}
WHERE ${questions.subAspectId} = ${subAspects.id}
)`.as('questionCount'),
})
.from(aspects)
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
.where(inArray(aspects.id, aspectIds))
.orderBy(aspects.name);
// Grouping sub aspects by aspect ID
const groupedResult = result.reduce((acc, curr) => {
const aspectId = curr.id;
if (!acc[aspectId]) {
acc[aspectId] = {
id: curr.id,
name: curr.name,
createdAt: curr.createdAt ? new Date(curr.createdAt).toISOString() : null,
updatedAt: curr.updatedAt ? new Date(curr.updatedAt).toISOString() : null,
subAspects: curr.subAspectName
? [{ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount }]
: [],
};
} else {
if (curr.subAspectName) {
const exists = acc[aspectId].subAspects.some(sub => sub.id === curr.subAspectId);
if (!exists) {
acc[aspectId].subAspects.push({
id: curr.subAspectId!,
name: curr.subAspectName,
questionCount: curr.questionCount,
});
}
}
}
return acc;
}, {} as Record<string, {
id: string;
name: string;
createdAt: string | null;
updatedAt: string | null;
subAspects: { id: string; name: string; questionCount: number }[];
}>);
const groupedArray = Object.values(groupedResult);
return c.json({
data: groupedArray,
_metadata: {
currentPage: page,
totalPages: Math.ceil(totalItems / limit),
totalItems,
perPage: limit,
},
});
}
)
// Get aspect by id
.get(
"/:id",
checkPermission("managementAspect.readAll"),
requestValidator(
"query",
z.object({
includeTrashed: z.string().default("false"),
})
),
async (c) => {
const aspectId = c.req.param("id");
if (!aspectId)
throw notFound({
message: "Missing id",
});
const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true";
const queryResult = await db
.select({
id: aspects.id,
name: aspects.name,
createdAt: aspects.createdAt,
updatedAt: aspects.updatedAt,
...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}),
subAspect: {
name: subAspects.name,
id: subAspects.id,
questionCount: sql`COUNT(${questions.id})`.as("questionCount"),
},
})
.from(aspects)
.leftJoin(subAspects, eq(aspects.id, subAspects.aspectId))
.leftJoin(questions, eq(subAspects.id, questions.subAspectId))
.where(and(eq(aspects.id, aspectId), !includeTrashed ? isNull(aspects.deletedAt) : undefined))
.groupBy(aspects.id, aspects.name, aspects.createdAt, aspects.updatedAt, subAspects.id, subAspects.name);
if (!queryResult.length)
throw notFound({
message: "The aspect does not exist",
});
const subAspectsList = queryResult.reduce((prev, curr) => {
if (!curr.subAspect) return prev;
prev.set(curr.subAspect.id, {
name: curr.subAspect.name,
questionCount: Number(curr.subAspect.questionCount),
});
return prev;
}, new Map<string, { name: string; questionCount: number }>());
const aspectData = {
...queryResult[0],
subAspect: undefined,
subAspects: Array.from(subAspectsList, ([id, { name, questionCount }]) => ({ id, name, questionCount })),
};
return c.json(aspectData);
}
)
// Create aspect
.post("/",
checkPermission("managementAspect.create"),
requestValidator("json", aspectFormSchema),
async (c) => {
const aspectData = c.req.valid("json");
// Check if aspect name already exists and is not deleted
const existingAspect = await db
.select()
.from(aspects)
.where(
and(
eq(aspects.name, aspectData.name),
isNull(aspects.deletedAt)
)
);
if (existingAspect.length > 0) {
// Return an error if the aspect name already exists
return c.json(
{ message: "Aspect name already exists" },
400 // Bad Request
);
}
// If it doesn't exist, create a new aspect
const aspect = await db
.insert(aspects)
.values({
name: aspectData.name,
})
.returning();
const aspectId = aspect[0].id;
// If there is sub aspect data, parse and insert into the database.
if (aspectData.subAspects) {
const subAspectsArray = JSON.parse(aspectData.subAspects) as string[];
// Create a Set to check for duplicates
const uniqueSubAspects = new Set<string>();
// Filter out duplicates
const filteredSubAspects = subAspectsArray.filter((subAspect) => {
if (uniqueSubAspects.has(subAspect)) {
return false; // Skip duplicates
}
uniqueSubAspects.add(subAspect);
return true; // Keep unique sub-aspects
});
// Check if there are any unique sub aspects to insert
if (filteredSubAspects.length) {
// Insert new sub aspects into the database
await db.insert(subAspects).values(
filteredSubAspects.map((subAspect) => ({
aspectId,
name: subAspect,
}))
);
}
}
return c.json(
{
message: "Aspect and sub aspects created successfully",
},
201
);
}
)
// Update aspect
.patch(
"/:id",
checkPermission("managementAspect.update"),
requestValidator("json", aspectUpdateSchema),
async (c) => {
const aspectId = c.req.param("id");
const aspectData = c.req.valid("json");
// Check if new aspect name already exists
const existingAspect = await db
.select()
.from(aspects)
.where(
and(
eq(aspects.name, aspectData.name),
isNull(aspects.deletedAt),
sql`${aspects.id} <> ${aspectId}`
)
);
if (existingAspect.length > 0) {
throw notFound({
message: "Aspect name already exists",
});
}
// Check if the aspect in question exists
const aspect = await db
.select()
.from(aspects)
.where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt)));
if (!aspect[0]) throw notFound();
// Update aspect name
await db
.update(aspects)
.set({
name: aspectData.name,
updatedAt: new Date(),
})
.where(eq(aspects.id, aspectId));
// Get new sub aspect data from request
const newSubAspects = aspectData.subAspects || [];
// Take the existing sub aspects
const currentSubAspects = await db
.select({ id: subAspects.id, name: subAspects.name })
.from(subAspects)
.where(eq(subAspects.aspectId, aspectId));
const currentSubAspectMap = new Map(currentSubAspects.map(sub => [sub.id, sub.name]));
// Sub aspects to be removed
const subAspectsToDelete = currentSubAspects
.filter(sub => !newSubAspects.some(newSub => newSub.id === sub.id))
.map(sub => sub.id);
// Delete sub aspects that do not exist in the new data
if (subAspectsToDelete.length) {
await db
.delete(subAspects)
.where(
and(
eq(subAspects.aspectId, aspectId),
inArray(subAspects.id, subAspectsToDelete)
)
);
}
// Create a Set to check for duplicate sub-aspects
const uniqueSubAspectNames = new Set(currentSubAspects.map(sub => sub.name));
// Update or add new sub aspects
for (const subAspect of newSubAspects) {
const existingSubAspect = currentSubAspectMap.has(subAspect.id);
// Check for duplicate sub-aspect names
if (uniqueSubAspectNames.has(subAspect.name) && !existingSubAspect) {
throw notFound({
message: `Sub aspect name "${subAspect.name}" already exists for this aspect.`,
});
}
if (existingSubAspect) {
// Update if sub aspect already exists
await db
.update(subAspects)
.set({
name: subAspect.name,
updatedAt: new Date(),
})
.where(
and(
eq(subAspects.id, subAspect.id),
eq(subAspects.aspectId, aspectId)
)
);
} else {
// Add if new sub aspect
await db
.insert(subAspects)
.values({
aspectId,
name: subAspect.name,
createdAt: new Date(),
});
}
// Add the name to the Set after processing
uniqueSubAspectNames.add(subAspect.name);
}
return c.json({
message: "Aspect and sub aspects updated successfully",
});
}
)
// Delete aspect
.delete(
"/:id",
checkPermission("managementAspect.delete"),
async (c) => {
const aspectId = c.req.param("id");
// Check if aspect exists before deleting
const aspect = await db
.select()
.from(aspects)
.where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt)));
if (!aspect[0])
throw notFound({
message: "The aspect is not found",
});
// Update deletedAt column on aspect (soft delete)
await db
.update(aspects)
.set({
deletedAt: new Date(),
})
.where(eq(aspects.id, aspectId));
// Soft delete related sub aspects (update deletedAt on the sub-aspect)
await db
.update(subAspects)
.set({
deletedAt: new Date(),
})
.where(eq(subAspects.aspectId, aspectId));
return c.json({
message: "Aspect and sub aspects soft deleted successfully",
});
}
)
// Undo delete
.patch(
"/restore/:id",
checkPermission("managementAspect.restore"),
async (c) => {
const aspectId = c.req.param("id");
// Check if the desired aspects are present
const aspect = (await db.select().from(aspects).where(eq(aspects.id, aspectId)))[0];
if (!aspect) {
throw notFound({
message: "The aspect is not found",
});
}
// Make sure the aspect has been deleted (there is deletedAt)
if (!aspect.deletedAt) {
throw notFound({
message: "The aspect is not deleted",
});
}
// Restore aspects (remove deletedAt mark)
await db.update(aspects).set({ deletedAt: null }).where(eq(aspects.id, aspectId));
// Restore all related sub aspects that have also been deleted (if any)
await db.update(subAspects).set({ deletedAt: null }).where(eq(subAspects.aspectId, aspectId));
return c.json({
message: "Aspect and sub aspects restored successfully",
});
}
)
export default managementAspectRoute;

View File

@ -0,0 +1,519 @@
import { and, eq, ne, ilike, isNull, or, sql } from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
import { HTTPException } from "hono/http-exception";
import db from "../../drizzle";
import { questions } from "../../drizzle/schema/questions";
import HonoEnv from "../../types/HonoEnv";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
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).max(999),
});
// Schema for creating and updating questions
export const questionFormSchema = z.object({
subAspectId: z.string().min(1).max(255),
question: z.string().min(1).max(510),
needFile: z.boolean().default(false),
options: z.array(optionFormSchema).optional(), // Allow options to be included
});
export const questionUpdateSchema = questionFormSchema.extend({
question: z.string().min(1).max(510).or(z.literal("")),
subAspectId: z.string().min(1).max(255).or(z.literal("")),
needFile: z.boolean().default(false).or(z.boolean()),
});
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)
*
* Query params:
* - includeTrashed: boolean (default: false)
* - withMetadata: boolean
*/
.get(
"/",
checkPermission("questions.readAll"),
requestValidator(
"query",
z.object({
includeTrashed: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
withMetadata: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(30),
q: z.string().default(""),
})
),
async (c) => {
const { includeTrashed, page, limit, q } = c.req.valid("query");
const totalCountQuery = includeTrashed
? sql<number>`(SELECT count(*) FROM ${questions})`
: sql<number>`(SELECT count(*) FROM ${questions} WHERE ${questions.deletedAt} IS NULL)`;
const result = await db
.select({
id: questions.id,
question: questions.question,
needFile: questions.needFile,
aspectName: aspects.name,
subAspectName: subAspects.name,
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)
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
.where(
and(
includeTrashed ? undefined : isNull(questions.deletedAt),
q
? or(
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);
return c.json({
data: result.map((d) => ({ ...d, fullCount: undefined })),
_metadata: {
currentPage: page,
totalPages: Math.ceil(
(Number(result[0]?.fullCount) ?? 0) / limit
),
totalItems: Number(result[0]?.fullCount) ?? 0,
perPage: limit,
},
});
}
)
/**
* Get Question by ID
*
* Query params:
* - id: string
* - includeTrashed: boolean (default: false)
*/
.get(
"/:id",
checkPermission("questions.readAll"),
requestValidator(
"query",
z.object({
includeTrashed: z.string().default("false"),
})
),
async (c) => {
const questionId = c.req.param("id");
if (!questionId)
throw notFound({
message: "Missing id",
});
const includeTrashed =
c.req.query("includeTrashed")?.toLowerCase() === "true";
const queryResult = await db
.select({
id: questions.id,
question: questions.question,
needFile: questions.needFile,
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);
}
)
/**
* Create Question
*
* JSON:
* - questionFormSchema: object
*/
.post(
"/",
checkPermission("questions.create"),
requestValidator("json", questionFormSchema),
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);
}
// Cek apakah question dengan subAspectId yang sama sudah ada
const duplicateQuestion = await db
.select()
.from(questions)
.where(
and(
eq(questions.subAspectId, questionData.subAspectId),
eq(questions.question, questionData.question)
)
);
if (duplicateQuestion.length > 0) {
return c.json(
{ message: "Pertanyaan dengan sub-aspek yang sama sudah ada" },
409
);
}
// Insert question data into the questions table
const question = await db
.insert(questions)
.values({
question: questionData.question,
needFile: questionData.needFile,
subAspectId: questionData.subAspectId,
})
.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 and options created successfully",
data: question[0],
},
201
);
}
)
/**
* Update Question
*
* JSON:
* - questionUpdateSchema: object
*/
.patch(
"/:id",
checkPermission("questions.update"),
requestValidator("json", questionUpdateSchema),
async (c) => {
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)
.where(and(eq(questions.id, questionId), isNull(questions.deletedAt)));
if (!question[0]) throw notFound();
// Check if the combination of subAspectId and question already exists (except for this question)
const duplicateQuestion = await db
.select()
.from(questions)
.where(
and(
eq(questions.subAspectId, questionData.subAspectId),
eq(questions.question, questionData.question),
ne(questions.id, questionId), // Ignore questions that are being updated
isNull(questions.deletedAt)
)
);
if (duplicateQuestion.length > 0) {
return c.json(
{ message: "Pertanyaan dengan sub-aspek yang sama sudah ada" },
409
);
}
// Update question data
await db
.update(questions)
.set({
...questionData,
updatedAt: new Date(),
})
.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 and options updated successfully",
});
}
)
/**
* Delete Question
*
* Query params:
* - id: string
* - skipTrash: string (default: false)
*/
.delete(
"/:id",
checkPermission("questions.delete"),
requestValidator(
"query",
z.object({
skipTrash: z.string().default("false"),
})
),
async (c) => {
const questionId = c.req.param("id");
const skipTrash =
c.req.valid("query").skipTrash.toLowerCase() === "true";
const question = await db
.select()
.from(questions)
.where(
and(
eq(questions.id, questionId),
skipTrash ? undefined : isNull(questions.deletedAt)
)
);
if (!question[0]) throw notFound();
if (skipTrash) {
await db.delete(questions).where(eq(questions.id, questionId));
} else {
await db
.update(questions)
.set({
deletedAt: new Date(),
})
.where(and(eq(questions.id, questionId), isNull(questions.deletedAt)));
}
return c.json({
message: "Question deleted successfully",
});
}
)
/**
* Restore Question
*
* Query params:
* - id: string
*/
.patch("/restore/:id",
checkPermission("questions.restore"),
async (c) => {
const questionId = c.req.param("id");
const question = (
await db.select().from(questions).where(eq(questions.id, questionId))
)[0];
if (!question) throw notFound();
if (!question.deletedAt) {
throw new HTTPException(400, {
message: "The question is not deleted",
});
}
await db
.update(questions)
.set({ deletedAt: null })
.where(eq(questions.id, questionId));
return c.json({
message: "Question restored successfully",
});
});
export default questionsRoute;

View File

@ -0,0 +1,131 @@
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import db from "../../drizzle";
import { respondents } from "../../drizzle/schema/respondents";
import { users } from "../../drizzle/schema/users";
import { rolesSchema } from "../../drizzle/schema/roles";
import { rolesToUsers } from "../../drizzle/schema/rolesToUsers";
import { hashPassword } from "../../utils/passwordUtils";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
import { or, eq } from "drizzle-orm";
import { z } from "zod";
import HonoEnv from "../../types/HonoEnv";
import { notFound } from "../../errors/DashboardError";
const registerFormSchema = z.object({
name: z.string().min(1).max(255),
username: z.string().min(1).max(255),
email: z.string().email(),
password: z.string().min(6),
companyName: z.string().min(1).max(255),
position: z.string().min(1).max(255),
workExperience: z.string().min(1).max(255),
address: z.string().min(1),
phoneNumber: z.string().min(1).max(13),
isEnabled: z.string().default("false"),
});
const respondentsRoute = new Hono<HonoEnv>()
.use(authInfo)
//post user
.post("/", requestValidator("json", registerFormSchema), async (c) => {
const formData = c.req.valid("json");
// Check if the provided email or username is already exists in database
const conditions = [];
if (formData.email) {
conditions.push(eq(users.email, formData.email));
}
conditions.push(eq(users.username, formData.username));
const existingUser = await db
.select()
.from(users)
.where(
or(
eq(users.email, formData.email),
eq(users.username, formData.username)
)
);
const existingRespondent = await db
.select()
.from(respondents)
.where(eq(respondents.phoneNumber, formData.phoneNumber));
if (existingUser.length > 0) {
throw new HTTPException(400, {
message: "Email or username has been registered",
});
}
if (existingRespondent.length > 0) {
throw new HTTPException(400, {
message: "Phone number has been registered",
});
}
// Hash the password
const hashedPassword = await hashPassword(formData.password);
// Start a transaction
const result = await db.transaction(async (trx) => {
// Create user
const [newUser] = await trx
.insert(users)
.values({
name: formData.name,
username: formData.username,
email: formData.email,
password: hashedPassword,
isEnabled: formData.isEnabled?.toLowerCase() === "true" || true,
})
.returning()
.catch(() => {
throw new HTTPException(500, { message: "Error creating user" });
});
// Create respondent
await trx
.insert(respondents)
.values({
companyName: formData.companyName,
position: formData.position,
workExperience: formData.workExperience,
address: formData.address,
phoneNumber: formData.phoneNumber,
userId: newUser.id,
})
.catch(() => {
throw new HTTPException(500, {
message: "Error creating respondent",
});
});
// Automatically assign "user" role to the new user
const [role] = await trx
.select()
.from(rolesSchema)
.where(eq(rolesSchema.code, "user"))
.limit(1);
if (!role) throw notFound();
await trx.insert(rolesToUsers).values({
userId: newUser.id,
roleId: role.id,
});
return newUser;
});
return c.json(
{
message: "User created successfully",
},
201
);
});
export default respondentsRoute;

View File

@ -1,4 +1,4 @@
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
import { and, eq, ilike, isNull, or, sql, not, inArray } from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
@ -12,30 +12,21 @@ import HonoEnv from "../../types/HonoEnv";
import requestValidator from "../../utils/requestValidator";
import authInfo from "../../middlewares/authInfo";
import checkPermission from "../../middlewares/checkPermission";
import { respondents } from "../../drizzle/schema/respondents";
import { forbidden, notFound } from "../../errors/DashboardError";
export const userFormSchema = z.object({
name: z.string().min(1).max(255),
username: z.string().min(1).max(255),
email: z.string().email().optional().or(z.literal("")),
password: z.string().min(6),
name: z.string().min(1, "Nama wajib diisi").max(255),
username: z.string().min(1, "Username wajib diisi").max(255),
email: z.string().min(1, "Email wajib diisi").max(255),
password: z.string().min(6, "Password wajib diisi"),
companyName: z.string().min(1, "Nama Perusahaan wajib diisi").max(255),
position: z.string().min(1, "Jabatan wajib diisi").max(255),
workExperience: z.string().min(1, "Pengalaman Kerja wajib diisi").max(255),
address: z.string().min(1, "Alamat wajib diisi"),
phoneNumber: z.string().min(1, "Nomor Telepon wajib diisi").max(13),
isEnabled: z.string().default("false"),
roles: z
.string()
.refine(
(data) => {
console.log(data);
try {
const parsed = JSON.parse(data);
return Array.isArray(parsed);
} catch {
return false;
}
},
{
message: "Roles must be an array",
}
)
.optional(),
roles: z.array(z.string().min(1, "Role wajib diisi")),
});
export const userUpdateSchema = userFormSchema.extend({
@ -51,74 +42,168 @@ const usersRoute = new Hono<HonoEnv>()
* - includeTrashed: boolean (default: false)\
* - withMetadata: boolean
*/
// Get all users with search
.get(
"/",
checkPermission("users.readAll"),
requestValidator(
"query",
z.object({
includeTrashed: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
withMetadata: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(1),
q: z.string().default(""),
})
"query",
z.object({
includeTrashed: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
withMetadata: z
.string()
.optional()
.transform((v) => v?.toLowerCase() === "true"),
page: z.coerce.number().int().min(0).default(0),
limit: z.coerce.number().int().min(1).max(1000).default(10),
q: z.string().default(""), // Keyword search
})
),
async (c) => {
const { includeTrashed, page, limit, q } = c.req.valid("query");
const { includeTrashed, page, limit, q } = c.req.valid("query");
const totalCountQuery = includeTrashed
? sql<number>`(SELECT count(*) FROM ${users})`
: sql<number>`(SELECT count(*) FROM ${users} WHERE ${users.deletedAt} IS NULL)`;
const result = await db
.select({
id: users.id,
name: users.name,
email: users.email,
username: users.username,
isEnabled: users.isEnabled,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
...(includeTrashed ? { deletedAt: users.deletedAt } : {}),
fullCount: totalCountQuery,
})
.from(users)
.where(
and(
includeTrashed ? undefined : isNull(users.deletedAt),
q
? or(
ilike(users.name, q),
ilike(users.username, q),
ilike(users.email, q),
eq(users.id, q)
)
: undefined
// Query to count total data without duplicates
const totalCountQuery = db
.select({
count: sql<number>`count(distinct ${users.id})`,
})
.from(users)
.leftJoin(respondents, eq(users.id, respondents.userId))
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
.where(
and(
includeTrashed ? undefined : isNull(users.deletedAt),
q
? or(
ilike(users.name, `%${q}%`), // Search by name
ilike(users.username, `%${q}%`), // Search by username
ilike(users.email, `%${q}%`), // Search by email
ilike(respondents.companyName, `%${q}%`), // Search by companyName (from respondents)
ilike(rolesSchema.name, `%${q}%`) // Search by role name (from rolesSchema)
)
)
.offset(page * limit)
.limit(limit);
: undefined
)
);
return c.json({
data: result.map((d) => ({ ...d, fullCount: undefined })),
_metadata: {
currentPage: page,
totalPages: Math.ceil(
(Number(result[0]?.fullCount) ?? 0) / limit
),
totalItems: Number(result[0]?.fullCount) ?? 0,
perPage: limit,
},
});
// Get the total count result from the query
const totalCountResult = await totalCountQuery;
const totalCount = totalCountResult[0]?.count || 0;
// Query to get unique user IDs based on pagination (Sub Query)
const userIdsQuery = db
.select({
id: users.id,
})
.from(users)
.leftJoin(respondents, eq(users.id, respondents.userId))
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
.where(
and(
includeTrashed ? undefined : isNull(users.deletedAt),
q
? or(
ilike(users.name, `%${q}%`), // Search by name
ilike(users.username, `%${q}%`), // Search by username
ilike(users.email, `%${q}%`),
ilike(respondents.companyName, `%${q}%`),
ilike(rolesSchema.name, `%${q}%`)
)
: undefined
)
)
.groupBy(users.id) // Group by user ID to avoid the effect of duplicate data
.offset(page * limit)
.limit(limit);
// Main Query
const result = await db
.select({
id: users.id,
name: users.name,
email: users.email,
username: users.username,
isEnabled: users.isEnabled,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
...(includeTrashed ? { deletedAt: users.deletedAt } : {}),
company: respondents.companyName,
role: {
name: rolesSchema.name,
id: rolesSchema.id,
},
})
.from(users)
.leftJoin(respondents, eq(users.id, respondents.userId))
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
.where(inArray(users.id, userIdsQuery)) // Only take data based on IDs from subquery
.orderBy(users.createdAt);
// Group roles for each user to avoid duplication
const userMap = new Map<
string,
{
id: string;
name: string;
email: string | null;
username: string;
isEnabled: boolean;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
company: string | null;
roles: { id: string; name: string }[];
}
>();
result.forEach((item) => {
if (!userMap.has(item.id)) {
userMap.set(item.id, {
id: item.id,
name: item.name,
email: item.email ?? null,
username: item.username,
isEnabled: item.isEnabled ?? false,
createdAt: item.createdAt ?? new Date(),
updatedAt: item.updatedAt ?? new Date(),
deletedAt: item.deletedAt ?? undefined,
company: item.company,
roles: item.role
? [{ id: item.role.id, name: item.role.name }]
: [],
});
} else {
const existingUser = userMap.get(item.id);
if (item.role) {
existingUser?.roles.push({
id: item.role.id,
name: item.role.name,
});
}
}
});
// Return user data without duplicates, with roles array
const groupedData = Array.from(userMap.values());
return c.json({
data: groupedData,
_metadata: {
currentPage: page,
totalPages: Math.ceil(totalCount / limit),
totalItems: totalCount,
perPage: limit,
},
});
}
)
)
//get user by id
.get(
"/:id",
@ -139,7 +224,12 @@ const usersRoute = new Hono<HonoEnv>()
.select({
id: users.id,
name: users.name,
position: respondents.position,
workExperience: respondents.workExperience,
email: users.email,
companyName: respondents.companyName,
address: respondents.address,
phoneNumber: respondents.phoneNumber,
username: users.username,
isEnabled: users.isEnabled,
createdAt: users.createdAt,
@ -151,6 +241,7 @@ const usersRoute = new Hono<HonoEnv>()
},
})
.from(users)
.leftJoin(respondents, eq(users.id, respondents.userId))
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
.where(
@ -161,9 +252,9 @@ const usersRoute = new Hono<HonoEnv>()
);
if (!queryResult.length)
throw new HTTPException(404, {
message: "The user does not exists",
});
throw notFound({
message : "The user does not exists",
})
const roles = queryResult.reduce((prev, curr) => {
if (!curr.role) return prev;
@ -180,38 +271,121 @@ const usersRoute = new Hono<HonoEnv>()
return c.json(userData);
}
)
//create user
.post(
"/",
checkPermission("users.create"),
requestValidator("form", userFormSchema),
requestValidator("json", userFormSchema),
async (c) => {
const userData = c.req.valid("form");
const userData = c.req.valid("json");
const user = await db
.insert(users)
.values({
name: userData.name,
username: userData.username,
email: userData.email,
password: await hashPassword(userData.password),
isEnabled: userData.isEnabled.toLowerCase() === "true",
})
.returning();
if (userData.roles) {
const roles = JSON.parse(userData.roles) as string[];
console.log(roles);
if (roles.length) {
await db.insert(rolesToUsers).values(
roles.map((role) => ({
userId: user[0].id,
roleId: role,
}))
);
}
// Check if the provided email or username is already exists in database
const conditions = [];
if (userData.email) {
conditions.push(eq(users.email, userData.email));
}
conditions.push(eq(users.username, userData.username));
const existingUser = await db
.select()
.from(users)
.where(
or(
eq(users.email, userData.email),
eq(users.username, userData.username)
)
);
const existingRespondent = await db
.select()
.from(respondents)
.where(eq(respondents.phoneNumber, userData.phoneNumber));
if (existingUser.length > 0) {
throw forbidden({
message: "Email or username has been registered",
})
}
if (existingRespondent.length > 0) {
throw forbidden({
message: "Phone number has been registered",
})
}
// Hash the password
const hashedPassword = await hashPassword(userData.password);
// Start a transaction
const result = await db.transaction(async (trx) => {
// Create user
const [newUser] = await trx
.insert(users)
.values({
name: userData.name,
username: userData.username,
email: userData.email,
password: hashedPassword,
isEnabled: userData.isEnabled?.toLowerCase() === "true" || true,
})
.returning()
.catch(() => {
throw forbidden({
message: "Error creating user",
})
});
// Create respondent
const [newRespondent] = await trx
.insert(respondents)
.values({
companyName: userData.companyName,
position: userData.position,
workExperience: userData.workExperience + " Tahun",
address: userData.address,
phoneNumber: userData.phoneNumber,
userId: newUser.id,
})
.returning()
.catch((err) => {
throw new HTTPException(500, {
message: "Error creating respondent: " + err.message,
});
});
// Add other roles if provided
if (userData.roles && userData.roles.length > 0) {
const roles = userData.roles;
for (let roleId of roles) {
const role = (
await trx
.select()
.from(rolesSchema)
.where(eq(rolesSchema.id, roleId))
.limit(1)
)[0];
if (role) {
await trx.insert(rolesToUsers).values({
userId: newUser.id,
roleId: role.id,
});
} else {
throw new HTTPException(404, {
message: `Role ${roleId} does not exists`,
});
}
}
} else {
throw forbidden({
message: "Harap pilih minimal satu role",
});
}
return newUser;
});
return c.json(
{
@ -226,10 +400,32 @@ const usersRoute = new Hono<HonoEnv>()
.patch(
"/:id",
checkPermission("users.update"),
requestValidator("form", userUpdateSchema),
requestValidator("json", userUpdateSchema),
async (c) => {
const userId = c.req.param("id");
const userData = c.req.valid("form");
const userData = c.req.valid("json");
// Check if the provided email or username is already exists in the database (excluding the current user)
if (userData.email || userData.username) {
const existingUser = await db
.select()
.from(users)
.where(
and(
or(
eq(users.email, userData.email),
eq(users.username, userData.username)
),
not(eq(users.id, userId))
)
);
if (existingUser.length > 0) {
throw forbidden({
message: "Email or username has been registered by another user",
})
}
}
const user = await db
.select()
@ -238,17 +434,69 @@ const usersRoute = new Hono<HonoEnv>()
if (!user[0]) return c.notFound();
await db
.update(users)
.set({
...userData,
...(userData.password
? { password: await hashPassword(userData.password) }
: {}),
updatedAt: new Date(),
isEnabled: userData.isEnabled.toLowerCase() === "true",
})
.where(eq(users.id, userId));
// Start transaction to update both user and respondent
await db.transaction(async (trx) => {
// Update user
await trx
.update(users)
.set({
name: userData.name,
username: userData.username,
email: userData.email,
updatedAt: new Date(),
isEnabled: userData.isEnabled.toLowerCase() === "true",
})
.where(eq(users.id, userId));
// Update respondent data if provided
if (userData.companyName || userData.position || userData.workExperience || userData.address || userData.phoneNumber) {
await trx
.update(respondents)
.set({
...(userData.companyName ? {companyName: userData.companyName} : {}),
...(userData.position ? {position: userData.position} : {}),
...(userData.workExperience ? {workExperience: userData.workExperience} : {}),
...(userData.address ? {address: userData.address} : {}),
...(userData.phoneNumber ? {phoneNumber: userData.phoneNumber} : {}),
updatedAt: new Date(),
})
.where(eq(respondents.userId, userId));
}
// Update roles if provided
if (userData.roles && userData.roles.length > 0) {
const roles = userData.roles;
// Remove existing roles for the user
await trx.delete(rolesToUsers).where(eq(rolesToUsers.userId, userId));
// Assign new roles
for (let roleId of roles) {
const role = (
await trx
.select()
.from(rolesSchema)
.where(eq(rolesSchema.id, roleId))
.limit(1)
)[0];
if (role) {
await trx.insert(rolesToUsers).values({
userId: userId,
roleId: role.id,
});
} else {
throw new HTTPException(404, {
message: `Role ${roleId} does not exist`,
});
}
}
} else {
throw forbidden({
message: "Harap pilih minimal satu role",
});
}
});
return c.json({
message: "User updated successfully",
@ -273,6 +521,7 @@ const usersRoute = new Hono<HonoEnv>()
const skipTrash =
c.req.valid("form").skipTrash.toLowerCase() === "true";
// Check if the user exists
const user = await db
.select()
.from(users)
@ -283,17 +532,20 @@ const usersRoute = new Hono<HonoEnv>()
)
);
// Throw error if the user does not exist
if (!user[0])
throw new HTTPException(404, {
throw notFound ({
message: "The user is not found",
});
// Throw error if the user is trying to delete themselves
if (user[0].id === currentUserId) {
throw new HTTPException(400, {
throw forbidden ({
message: "You cannot delete yourself",
});
}
// Delete or soft delete user
if (skipTrash) {
await db.delete(users).where(eq(users.id, userId));
} else {
@ -311,28 +563,34 @@ const usersRoute = new Hono<HonoEnv>()
)
//undo delete
.patch("/restore/:id", checkPermission("users.restore"), async (c) => {
const userId = c.req.param("id");
.patch(
"/restore/:id",
checkPermission("users.restore"),
async (c) => {
const userId = c.req.param("id");
const user = (
await db.select().from(users).where(eq(users.id, userId))
)[0];
// Check if the user exists
const user = (
await db.select().from(users).where(eq(users.id, userId))
)[0];
if (!user) return c.notFound();
if (!user) return c.notFound();
if (!user.deletedAt) {
throw new HTTPException(400, {
message: "The user is not deleted",
// Throw error if the user is not deleted
if (!user.deletedAt) {
throw forbidden({
message: "The user is not deleted",
});
}
// Restore user
await db
.update(users)
.set({ deletedAt: null })
.where(eq(users.id, userId));
return c.json({
message: "User restored successfully",
});
}
await db
.update(users)
.set({ deletedAt: null })
.where(eq(users.id, userId));
return c.json({
message: "User restored successfully",
});
});
export default usersRoute;

View File

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

View File

@ -4,6 +4,7 @@ import appEnv from "../appEnv";
// Environment variables for secrets, defaulting to a random secret if not set.
const accessTokenSecret = appEnv.ACCESS_TOKEN_SECRET;
const refreshTokenSecret = appEnv.REFRESH_TOKEN_SECRET;
const resetPasswordTokenSecret = appEnv.RESET_PASSWORD_TOKEN_SECRET;
// Algorithm to be used for JWT encoding.
const algorithm: jwt.Algorithm = "HS256";
@ -11,6 +12,7 @@ const algorithm: jwt.Algorithm = "HS256";
// Expiry settings for tokens. 'null' signifies no expiry.
export const accessTokenExpiry: number | string | null = null;
export const refreshTokenExpiry: number | string | null = "30d";
export const resetPasswordTokenExpiry: number | string | null = null;
// Interfaces to describe the payload structure for access and refresh tokens.
interface AccessTokenPayload {
@ -21,6 +23,10 @@ interface RefreshTokenPayload {
uid: string;
}
interface ResetPasswordTokenPayload {
uid: string;
}
/**
* Generates a JSON Web Token (JWT) for access control using a specified payload.
*
@ -84,3 +90,35 @@ export const verifyRefreshToken = async (token: string) => {
return null;
}
};
/**
* Generates a JSON Web Token (JWT) for reset password using a specified payload.
*
* @param payload - The payload containing user-specific data for the token.
* @returns A promise that resolves to the generated JWT string.
*/
export const generateResetPasswordToken = async (payload: ResetPasswordTokenPayload) => {
const token = jwt.sign(payload, resetPasswordTokenSecret, {
algorithm,
...(resetPasswordTokenExpiry ? { expiresIn: resetPasswordTokenExpiry } : {}),
});
return token;
};
/**
* Verifies a given reset password token and decodes the payload if the token is valid.
*
* @param token - The JWT string to verify.
* @returns A promise that resolves to the decoded payload or null if verification fails.
*/
export const verifyResetPasswordToken = async (token: string) => {
try {
const payload = jwt.verify(
token,
resetPasswordTokenSecret
) as ResetPasswordTokenPayload;
return payload;
} catch {
return null;
}
};

View File

@ -0,0 +1,33 @@
import nodemailer from 'nodemailer';
import appEnv from '../appEnv';
/**
* Nodemailer configuration
*/
const transporter = nodemailer.createTransport({
host: appEnv.SMTP_HOST,
port: appEnv.SMTP_PORT,
secure: false,
auth: {
user: appEnv.SMTP_USERNAME,
pass: appEnv.SMTP_PASSWORD,
},
tls: {
rejectUnauthorized: false,
},
});
export async function sendResetPasswordEmail(to: string, token: string) {
const resetUrl = appEnv.BASE_URL + '/forgot-password/verify?token=' + token;
const info = await transporter.sendMail({
from: `"Your App" <${appEnv.SMTP_USERNAME}>`,
to,
subject: 'Password Reset Request',
text: `You requested a password reset. Click this link to reset your password: ${resetUrl}`,
html: `<p>You requested a password reset. Click this link to reset your password:<br><a href="${resetUrl}">${resetUrl}</a></p>`,
});
console.log('Email sent: %s', info.messageId);
return info;
}

View File

@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logos/amati-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
<title>Amati</title>
</head>
<body>
<div id="root"></div>

View File

@ -10,11 +10,22 @@
},
"dependencies": {
"@emotion/react": "^11.11.4",
"@hookform/resolvers": "^3.9.0",
"@mantine/core": "^7.10.2",
"@mantine/dates": "^7.10.2",
"@mantine/form": "^7.10.2",
"@mantine/hooks": "^7.10.2",
"@mantine/notifications": "^7.10.2",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.45.0",
"@tanstack/react-router": "^1.38.1",
@ -24,11 +35,14 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"hono": "^4.4.6",
"html2pdf.js": "^0.10.2",
"lucide-react": "^0.414.0",
"mantine-form-zod-resolver": "^1.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"react-icons": "^5.2.1",
"recharts": "^2.13.0",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@ -1,96 +1,195 @@
import { useState } from "react";
import {
AppShell,
Avatar,
Burger,
Group,
Menu,
UnstyledButton,
Text,
rem,
} from "@mantine/core";
import logo from "@/assets/logos/logo.png";
import logo from "@/assets/logos/amati-logo.png";
import cx from "clsx";
import classNames from "./styles/appHeader.module.css";
import { TbChevronDown } from "react-icons/tb";
import { Link } from "@tanstack/react-router";
import { IoMdMenu } from "react-icons/io";
import { Link, useLocation } from "@tanstack/react-router";
import useAuth from "@/hooks/useAuth";
import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/components/ui/avatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shadcn/components/ui/dropdown-menu";
import { Button } from "@/shadcn/components/ui/button";
import { TbMenu2 } from "react-icons/tb";
// import getUserMenus from "../actions/getUserMenus";
// import { useAuth } from "@/modules/auth/contexts/AuthContext";
// import UserMenuItem from "./UserMenuItem";
// import { toggleLeftSidebar } from "../../src/routes/_assessmentLayout/assessment/index.lazy";
interface Props {
openNavbar: boolean;
toggle: () => void;
}
interface User {
id: string;
name: string;
permissions: string[];
role: string;
photoProfile?: string;
}
interface Props {
toggle: () => void;
}
// const mockUserData = {
// name: "Fulan bin Fulanah",
// email: "janspoon@fighter.dev",
// image: "https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-5.png",
// };
export default function AppHeader(props: Props) {
const [userMenuOpened, setUserMenuOpened] = useState(false);
interface Props {
toggle: () => void;
toggleLeftSidebar: () => void; // Add this prop
}
const { user } = useAuth();
export default function AppHeader({ toggle, toggleLeftSidebar }: Props) {
const [userMenuOpened, setUserMenuOpened] = useState(false);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
const { user }: { user: User | null } = useAuth();
const isSuperAdmin = user?.role === "super-admin";
// const userMenus = getUserMenus().map((item, i) => (
// <UserMenuItem item={item} key={i} />
// ));
// const toggleLeftSidebar = () => setIsLeftSidebarOpen(!isLeftSidebarOpen);
const { pathname } = useLocation();
const showAssessmentResultLinks = pathname === "/assessmentResult";
const showAssessmentLinks = pathname === "/assessment";
const showVerifyingAssessmentLinks = pathname === "/verifying";
const assessmentRequestsLinks = pathname === "/assessmentRequest";
const managementResultsLinks = pathname === "/assessmentResultsManagement";
const shouldShowButton = !(showAssessmentResultLinks || showAssessmentLinks || assessmentRequestsLinks || showVerifyingAssessmentLinks );
return (
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Burger
opened={props.openNavbar}
onClick={props.toggle}
hiddenFrom="sm"
size="sm"
/>
<img src={logo} alt="" className="h-8" />
<Menu
width={260}
position="bottom-end"
transitionProps={{ transition: "pop-top-right" }}
onOpen={() => setUserMenuOpened(true)}
onClose={() => setUserMenuOpened(false)}
withinPortal
<header className="fixed top-0 left-0 w-full h-16 bg-white z-50 border">
<div className="flex h-full justify-between w-full items-center">
<div className="flex items-center">
{shouldShowButton && (
<Button
onClick={toggle}
className="flex items-center px-5 py-5 lg:hidden bg-white text-black hover:bg-white hover:text-black focus:bg-white focus:text-black active:bg-white active:text-black"
>
<IoMdMenu />
</Button>
)}
{(showAssessmentLinks || showVerifyingAssessmentLinks || showAssessmentResultLinks) && (
<TbMenu2 onClick={toggleLeftSidebar} className="md:ml-0 ml-6 mt-8 w-6 h-fit pb-4 mb-4 cursor-pointer lg:hidden" />
)}
<img
src={logo}
alt="Logo"
className="w-44 h-fit px-8 py-5 object-cover hidden lg:block"
/>
</div>
{/* Conditional Navlinks */}
{!isSuperAdmin && (
<div className="flex space-x-4 justify-center w-full ml-14">
{showAssessmentResultLinks && (
<>
<Link
to="/assessmentRequest"
className={cx("text-sm font-medium", {
"text-blue-600":
assessmentRequestsLinks, // warna aktif
"text-gray-700":
!assessmentRequestsLinks, // warna default
})}
onClick={() => {
if (window.opener) {
window.close();
}
}}
>
Permohonan Asesmen
</Link>
<Link
to="/assessmentResult"
className={cx("text-sm font-medium", {
"text-blue-600":
showAssessmentResultLinks, // warna aktif
"text-gray-700":
!showAssessmentResultLinks, // warna default
})}
>
Hasil Asesmen
</Link>
</>
)}
{showAssessmentLinks && (
<>
<Link
to="/assessmentRequest"
className={cx("text-sm font-medium", {
"text-blue-600":
assessmentRequestsLinks, // warna aktif
"text-gray-700":
!assessmentRequestsLinks, // warna default
})}
onClick={() => {
if (window.opener) {
window.close();
}
}}
>
Permohonan Asesmen
</Link>
<Link
to="/assessment"
className={cx("text-sm font-medium", {
"text-blue-600": showAssessmentLinks, // warna aktif
"text-gray-700": !showAssessmentLinks, // warna default
})}
>
Asesmen
</Link>
</>
)}
</div>
)}
<DropdownMenu
modal={false}
open={userMenuOpened}
onOpenChange={setUserMenuOpened}
>
<Menu.Target>
<UnstyledButton
<DropdownMenuTrigger asChild className="flex">
<button
className={cx(classNames.user, {
[classNames.userActive]: userMenuOpened,
})}
>
<Group gap={7}>
<Avatar
// src={user?.photoProfile}
// alt={user?.name}
radius="xl"
size={30}
/>
<Text fw={500} size="sm" lh={1} mr={3}>
{/* {user?.name} */}
{user?.name ?? "Anonymous"}
</Text>
<TbChevronDown
style={{ width: rem(12), height: rem(12) }}
strokeWidth={1.5}
/>
</Group>
</UnstyledButton>
</Menu.Target>
<div className="flex items-center">
<Avatar>
{user?.photoProfile ? (
<AvatarImage src={user.photoProfile} />
) : (
<AvatarFallback>
{user?.name?.charAt(0) ?? "A"}
</AvatarFallback>
)}
</Avatar>
</div>
</button>
</DropdownMenuTrigger>
<Menu.Dropdown>
<Menu.Item component={Link} to="/logout">
Logout
</Menu.Item>
{/* {userMenus} */}
</Menu.Dropdown>
</Menu>
</Group>
</AppShell.Header>
<DropdownMenuContent
align="end"
className="transition-all duration-200 z-50 border bg-white w-64"
>
<DropdownMenuItem asChild>
<Link to="/logout">Logout</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@ -1,7 +1,11 @@
import { AppShell, ScrollArea } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import client from "../honoClient";
import MenuItem from "./NavbarMenuItem";
import { useState, useEffect } from "react";
import { useLocation } from "@tanstack/react-router";
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
import AppHeader from "./AppHeader";
import useAuth from "@/hooks/useAuth";
// import MenuItem from "./SidebarMenuItem";
// import { useAuth } from "@/modules/auth/contexts/AuthContext";
@ -13,7 +17,21 @@ import MenuItem from "./NavbarMenuItem";
* @returns A React element representing the application's navigation bar.
*/
export default function AppNavbar() {
// const {user} = useAuth();
const { user } = useAuth();
// const userRole = JSON.parse(localStorage.getItem('userRole') || '{}');
const { pathname } = useLocation();
const pathsThatCloseSidebar = ["/assessmentRequest", "/assessmentResult", "/assessment", "/verifying"];
const [isSidebarOpen, setSidebarOpen] = useState(true);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
const toggleSidebar = () => {
setSidebarOpen(!isSidebarOpen);
};
const toggleLeftSidebar = () => {
setIsLeftSidebarOpen(!isLeftSidebarOpen);
};
const { data } = useQuery({
queryKey: ["sidebarData"],
@ -21,24 +39,81 @@ export default function AppNavbar() {
const res = await client.dashboard.getSidebarItems.$get();
if (res.ok) {
const data = await res.json();
return data;
}
console.error("Error:", res.status, res.statusText);
//TODO: Handle error properly
throw new Error("Error fetching sidebar data");
},
});
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 768) { // Ganti 768 dengan breakpoint mobile Anda
setSidebarOpen(false);
} else {
setSidebarOpen(!pathsThatCloseSidebar.includes(pathname));
}
};
window.addEventListener('resize', handleResize);
handleResize(); // Initial check
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleMenuItemClick = () => {
if (window.innerWidth < 768) {
setSidebarOpen(false);
}
};
useEffect(() => {
if (pathname === "/assessment"){
setSidebarOpen(false);
}
})
// Filter sidebar menu items according to user role
const filteredData = data?.filter(menu => {
if (user?.role === "super-admin") {
return [
"/users",
"/aspect",
"/questions",
"/assessmentRequestManagements",
"/assessmentResultsManagement",
].includes(menu.link as string);
} else if (user?.role === "user") {
return ["/assessmentRequest"].includes(menu.link as string);
}
return false; // If role is not recognized, show nothing
});
return (
<AppShell.Navbar p="md">
<ScrollArea style={{ flex: "1" }}>
{data?.map((menu, i) => <MenuItem menu={menu} key={i} />)}
{/* {user?.sidebarMenus.map((menu, i) => (
<MenuItem menu={menu} key={i} />
)) ?? null} */}
</ScrollArea>
</AppShell.Navbar>
<>
<div>
{/* Header */}
<AppHeader toggle={toggleSidebar} openNavbar={isSidebarOpen} toggleLeftSidebar={toggleLeftSidebar} />
{/* Sidebar */}
{!pathsThatCloseSidebar.includes(pathname) && (
<div
className={`fixed lg:relative w-64 bg-white top-16 left-0 h-full z-40 px-3 py-4 transition-transform border-x
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}
>
<ScrollArea className="flex flex-1 h-full">
{filteredData?.map((menu, i) => (
<MenuItem
key={i}
menu={menu}
isActive={pathname === menu.link}
onClick={handleMenuItemClick}
/>
))}
</ScrollArea>
</div>
)}
</div>
</>
);
}

View File

@ -1,5 +1,13 @@
import { Table, Center, ScrollArea } from "@mantine/core";
import { Table as ReactTable, flexRender } from "@tanstack/react-table";
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/shadcn/components/ui/table";
import { flexRender, Table as ReactTable } from "@tanstack/react-table";
interface Props<TData> {
table: ReactTable<TData>;
@ -7,68 +15,57 @@ interface Props<TData> {
export default function DashboardTable<T>({ table }: Props<T>) {
return (
<ScrollArea.Autosize>
<Table
verticalSpacing="xs"
horizontalSpacing="xs"
striped
highlightOnHover
withColumnBorders
withRowBorders
>
{/* Thead */}
<Table.Thead>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Th
key={header.id}
style={{
maxWidth: `${header.column.columnDef.maxSize}px`,
width: `${header.getSize()}`,
}}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</Table.Th>
))}
</Table.Tr>
<div className="w-full max-w-full overflow-x-auto border rounded-lg">
<Table className="min-w-full divide-y divide-muted-foreground bg-white">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={`px-6 py-3 text-left text-sm font-medium text-muted-foreground ${header.column.columnDef.header === 'Status' ? 'text-center' : (header.column.columnDef.header === 'Aksi' && window.location.pathname === '/assessmentRequest') ? 'text-center' : ''}`}
style={{
maxWidth: `${header.column.columnDef.maxSize}px`,
width: `${header.getSize()}`,
}}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</Table.Thead>
</TableRow>
))}
</TableHeader>
{/* Tbody */}
<Table.Tbody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<Table.Tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Td
key={cell.id}
style={{
maxWidth: `${cell.column.columnDef.maxSize}px`,
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Td>
))}
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={table.getAllColumns().length}>
<Center>- No Data -</Center>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
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())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="px-6 py-4 text-center text-sm text-gray-500">
- No Data -
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</ScrollArea.Autosize>
</div>
);
}

View File

@ -1,5 +1,3 @@
import { Text } from "@mantine/core";
import classNames from "./styles/navbarChildMenu.module.css";
import { SidebarMenu } from "backend/types";
@ -22,13 +20,10 @@ export default function ChildMenu(props: Props) {
: `/${props.item.link}`;
return (
<Text<"a">
component="a"
className={classNames.link}
href={`${linkPath}`}
fw={props.active ? "bold" : "normal"}
<a href={`${linkPath}`}
className={`${props.active ? "font-bold" : "font-normal"} ${classNames.link} text-blue-600 hover:underline`}
>
{props.item.label}
</Text>
</a>
);
}

View File

@ -1,17 +1,6 @@
import { useState } from "react";
import {
Box,
Collapse,
Group,
ThemeIcon,
UnstyledButton,
rem,
} from "@mantine/core";
import { TbChevronRight } from "react-icons/tb";
import * as TbIcons from "react-icons/tb";
import classNames from "./styles/navbarMenuItem.module.css";
// import classNames from "./styles/navbarMenuItem.module.css";
// import dashboardConfig from "../dashboard.config";
// import { usePathname } from "next/navigation";
// import areURLsSame from "@/utils/areUrlSame";
@ -19,9 +8,14 @@ import classNames from "./styles/navbarMenuItem.module.css";
import { SidebarMenu } from "backend/types";
import ChildMenu from "./NavbarChildMenu";
import { Link } from "@tanstack/react-router";
import { Button } from "@/shadcn/components/ui/button";
import { ChevronRightIcon} from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
menu: SidebarMenu;
isActive: boolean;
onClick: (link: string) => void;
}
//TODO: Make bold and collapsed when the item is active
@ -34,7 +28,7 @@ interface Props {
* @param props.menu - The menu item data to display.
* @returns A React element representing an individual menu item.
*/
export default function MenuItem({ menu }: Props) {
export default function MenuItem({ menu, isActive, onClick }: Props) {
const hasChildren = Array.isArray(menu.children);
// const pathname = usePathname();
@ -50,6 +44,13 @@ export default function MenuItem({ menu }: Props) {
setOpened((prev) => !prev);
};
const handleClick = () => {
onClick(menu.link ?? "");
if (!hasChildren) {
toggleOpenMenu();
}
};
// Mapping children menu items if available
const subItems = (hasChildren ? menu.children! : []).map((child, index) => (
<ChildMenu key={index} item={child} active={false} />
@ -69,43 +70,41 @@ export default function MenuItem({ menu }: Props) {
return (
<>
{/* Main Menu Item */}
<UnstyledButton<typeof Link | "button">
onClick={toggleOpenMenu}
className={`${classNames.control} py-2`}
to={menu.link}
component={menu.link ? Link : "button"}
<Button
variant="ghost"
className={cn(
"w-full p-2 rounded-md justify-between focus:outline-none",
isActive ? "bg-[--primary-color] text-white" : "text-black"
)}
onClick={handleClick}
asChild
>
<Group justify="space-between" gap={0}>
{/* Icon and Label */}
<Box style={{ display: "flex", alignItems: "center" }}>
<ThemeIcon variant="light" size={30} color={menu.color}>
<Icon style={{ width: rem(18), height: rem(18) }} />
</ThemeIcon>
<Box ml="md" fw={500}>
{menu.label}
</Box>
</Box>
{/* Chevron Icon for collapsible items */}
<Link to={menu.link ?? "#"}>
<div className="flex items-center">
{/* Icon */}
<span className="mr-3">
<Icon className="w-4 h-4" />
</span>
{/* Label */}
<span className="text-xs font-normal whitespace-normal">{menu.label}</span>
</div>
{/* Chevron Icon */}
{hasChildren && (
<TbChevronRight
strokeWidth={1.5}
style={{
width: rem(16),
height: rem(16),
transform: opened
? "rotate(-90deg)"
: "rotate(90deg)",
}}
className={classNames.chevron}
<ChevronRightIcon
className={`w-4 h-4 transition-transform ${
opened ? "rotate-90" : "rotate-0"
}`}
/>
)}
</Group>
</UnstyledButton>
</Link>
</Button>
{/* Collapsible Sub-Menu */}
{hasChildren && <Collapse in={opened}>{subItems}</Collapse>}
{hasChildren && (
<div className={cn("transition-all", opened ? "block" : "hidden")}>
{subItems}
</div>
)}
</>
);
}

View File

@ -1,16 +1,4 @@
/* eslint-disable no-mixed-spaces-and-tabs */
import {
Button,
Card,
Flex,
Pagination,
Select,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Link } from "@tanstack/react-router";
import React, { ReactNode, useState } from "react";
import { TbPlus, TbSearch } from "react-icons/tb";
import DashboardTable from "./DashboardTable";
@ -25,7 +13,25 @@ import {
keepPreviousData,
useQuery,
} from "@tanstack/react-query";
import { useDebouncedCallback } from "@mantine/hooks";
import { useDebouncedValue } from "@mantine/hooks";
import { Button } from "@/shadcn/components/ui/button";
import { useLocation, useNavigate } from "@tanstack/react-router";
import { Card } from "@/shadcn/components/ui/card";
import { Input } from "@/shadcn/components/ui/input";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
} from "@/shadcn/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shadcn/components/ui/select";
import { HiChevronLeft, HiChevronRight } from "react-icons/hi";
type PaginatedResponse<T extends Record<string, unknown>> = {
data: Array<T>;
@ -70,24 +76,36 @@ const createCreateButton = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
property: Props<any, any, any>["createButton"] = true
) => {
const navigate = useNavigate();
const addQuery = () => {
navigate({ to: `${window.location.pathname}`, search: { create: true } });
}
if (property === true) {
const location = useLocation();
const isAssessmentRequestPage =
location.pathname === "/assessmentRequest";
return (
<Button
leftSection={<TbPlus />}
component={Link}
search={{ create: true }}
className="gap-2"
variant={isAssessmentRequestPage ? "request" : "outline"}
onClick={addQuery}
>
Create New
{isAssessmentRequestPage ? "Ajukan Permohonan" : "Tambah Data"}
<TbPlus />
</Button>
);
} else if (typeof property === "string") {
return (
<Button
leftSection={<TbPlus />}
component={Link}
search={{ create: true }}
className="gap-2"
variant={"outline"}
onClick={addQuery}
>
{property}
<TbPlus />
</Button>
);
} else {
@ -95,6 +113,109 @@ const createCreateButton = (
}
};
/**
* Pagination component for handling page navigation.
*
* @param props - The properties object.
* @returns The rendered Pagination component.
*/
const CustomPagination = ({
currentPage,
totalPages,
onChange,
}: {
currentPage: number;
totalPages: number;
onChange: (page: number) => void;
}) => {
const getPaginationItems = () => {
let items = [];
// Determine start and end pages
let startPage =
currentPage == totalPages && currentPage > 3 ?
Math.max(1, currentPage - 2) :
Math.max(1, currentPage - 1);
let endPage =
currentPage == 1 ?
Math.min(totalPages, currentPage + 2) :
Math.min(totalPages, currentPage + 1);
// Add ellipsis if needed
if (startPage > 2) {
items.push(<PaginationEllipsis key="start-ellipsis" />);
}
// Add page numbers
for (let i = startPage; i <= endPage; i++) {
items.push(
<Button className='cursor-pointer' key={i} onClick={() => onChange(i)} variant={currentPage == i ? "outline" : "ghost"}>
{i}
</Button>
);
}
// Add ellipsis after
if (endPage < totalPages - 1) {
items.push(<PaginationEllipsis key="end-ellipsis" />);
}
// Add last page
if (endPage < totalPages) {
items.push(
<Button className='cursor-pointer' key={totalPages} onClick={() => onChange(totalPages)} variant={"ghost"}>
{totalPages}
</Button>
);
}
if (currentPage > 2) {
items.unshift(
<Button className='cursor-pointer' key={1} onClick={() => onChange(1)} variant={"ghost"}>
1
</Button>
);
}
return items;
};
return (
<Pagination>
<PaginationContent className="flex flex-col items-center gap-4 md:flex-row">
<PaginationItem className="w-full md:w-auto">
<Button
onClick={() => onChange(Math.max(1, currentPage - 1))}
disabled={currentPage - 1 == 0 ? true : false}
className="w-full gap-2 md:w-auto"
variant={"ghost"}
>
<HiChevronLeft />
Sebelumnya
</Button>
</PaginationItem>
<div className="flex flex-wrap justify-center gap-2">
{getPaginationItems().map((item) => (
<PaginationItem key={item.key}>
{item}
</PaginationItem>
))}
</div>
<PaginationItem className="w-full md:w-auto">
<Button
onClick={() => onChange(Math.min(totalPages, currentPage + 1))}
disabled={currentPage == totalPages ? true : false}
className="w-full gap-2 md:w-auto"
variant={"ghost"}
>
Selanjutnya
<HiChevronRight />
</Button>
</PaginationItem>
</PaginationContent>
</Pagination>
);
};
/**
* PageTemplate component for displaying a paginated table with search and filter functionality.
@ -113,15 +234,15 @@ export default function PageTemplate<
q: "",
});
// const [deboucedSearchQuery] = useDebouncedValue(filterOptions.q, 500);
const [debouncedSearchQuery] = useDebouncedValue(filterOptions.q, 500);
const query = useQuery({
...(typeof props.queryOptions === "function"
? props.queryOptions(
filterOptions.page,
filterOptions.limit,
filterOptions.q
)
filterOptions.page,
filterOptions.limit,
debouncedSearchQuery
)
: props.queryOptions),
placeholderData: keepPreviousData,
});
@ -131,7 +252,11 @@ export default function PageTemplate<
columns: props.columnDefs,
getCoreRowModel: getCoreRowModel(),
defaultColumn: {
cell: (props) => <Text>{props.getValue() as ReactNode}</Text>,
cell: (props) => (
<span>
{props.getValue() as ReactNode}
</span>
),
},
});
@ -140,13 +265,13 @@ export default function PageTemplate<
*
* @param value - The new search query value.
*/
const handleSearchQueryChange = useDebouncedCallback((value: string) => {
const handleSearchQueryChange = (value: string) => {
setFilterOptions((prev) => ({
page: 0,
limit: prev.limit,
q: value,
}));
}, 500);
};
/**
* Handles the change in page number.
@ -155,33 +280,34 @@ export default function PageTemplate<
*/
const handlePageChange = (page: number) => {
setFilterOptions((prev) => ({
page: page - 1,
page: page - 1, // Adjust for zero-based index
limit: prev.limit,
q: prev.q,
}));
};
return (
<Stack>
<Title order={1}>{props.title}</Title>
<Card>
{/* Top Section */}
<Flex justify="flex-end">
{createCreateButton(props.createButton)}
</Flex>
<div className="flex flex-col space-y-4">
<p className="text-2xl font-bold">{props.title}</p>
<Card className="p-4 border-hidden">
{/* Table Functionality */}
<div className="flex flex-col">
{/* Search */}
<div className="flex pb-4">
<TextInput
leftSection={<TbSearch />}
value={filterOptions.q}
onChange={(e) =>
handleSearchQueryChange(e.target.value)
}
placeholder="Search..."
/>
{/* Search and Create Button */}
<div className="flex flex-col md:flex-row lg:flex-row pb-4 justify-between gap-4">
<div className="relative w-full">
<TbSearch className="absolute top-1/2 left-3 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input
id="search"
name="search"
className="w-full max-w-xs pl-10"
value={filterOptions.q}
onChange={(e) => handleSearchQueryChange(e.target.value)}
placeholder="Pencarian..."
/>
</div>
<div className="flex">
{createCreateButton(props.createButton)}
</div>
</div>
{/* Table */}
@ -189,41 +315,50 @@ export default function PageTemplate<
{/* Pagination */}
{query.data && (
<div className="pt-4 flex-wrap flex items-center gap-4">
<Select
label="Per Page"
data={["5", "10", "50", "100", "500", "1000"]}
allowDeselect={false}
defaultValue="10"
searchValue={filterOptions.limit.toString()}
onChange={(value) =>
setFilterOptions((prev) => ({
page: prev.page,
limit: parseInt(value ?? "10"),
q: prev.q,
}))
}
checkIconPosition="right"
className="w-20"
/>
<Pagination
value={filterOptions.page + 1}
total={query.data._metadata.totalPages}
<div className="pt-4 flex flex-col md:flex-row lg:flex-row items-center justify-between gap-2">
<div className="flex flex-row lg:flex-col items-center w-fit gap-2">
<span className="block text-sm font-medium text-muted-foreground whitespace-nowrap">Per Halaman</span>
<Select
onValueChange={(value) =>
setFilterOptions((prev) => ({
page: prev.page,
limit: parseInt(value ?? "10"),
q: prev.q,
}))
}
defaultValue="10"
>
<SelectTrigger className="w-fit p-4 gap-4">
<SelectValue placeholder="Per Page" />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="500">500</SelectItem>
<SelectItem value="1000">1000</SelectItem>
</SelectContent>
</Select>
</div>
<CustomPagination
currentPage={filterOptions.page + 1}
totalPages={query.data._metadata.totalPages}
onChange={handlePageChange}
/>
<Text c="dimmed" size="sm">
Showing {query.data.data.length} of{" "}
{query.data._metadata.totalItems}
</Text>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems}
</span>
</div>
</div>
)}
</div>
{/* The Modals */}
{props.modals?.map((modal, index) => (
<React.Fragment key={index}>{modal}</React.Fragment>
))}
</Card>
</Stack>
</div>
);
}

View File

@ -2,19 +2,20 @@ import { ReactNode } from "@tanstack/react-router";
import { createContext, useState } from "react";
interface AuthContextType {
user: {
id: string;
name: string;
permissions: string[];
} | null;
accessToken: string | null;
saveAuthData: (
userData: NonNullable<AuthContextType["user"]>,
accessToken?: NonNullable<AuthContextType["accessToken"]>
) => void;
clearAuthData: () => void;
checkPermission: (permission: string) => boolean;
isAuthenticated: boolean;
user: {
id: string;
name: string;
permissions: string[];
role: string;
} | null;
accessToken: string | null;
saveAuthData: (
userData: { id: string; name: string; permissions: string[]; role: string },
accessToken?: string
) => void;
clearAuthData: () => void;
checkPermission: (permission: string) => boolean;
isAuthenticated: boolean;
}
export const AuthContext = createContext<AuthContextType | undefined>(
@ -25,6 +26,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [userId, setUserId] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [permissions, setPermissions] = useState<string[] | null>(null);
const [role, setRole] = useState<string | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(
localStorage.getItem("accessToken")
);
@ -36,6 +38,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUserId(userData.id);
setUserName(userData.name);
setPermissions(userData.permissions);
setRole(userData.role);
if (accessToken) {
setAccessToken(accessToken);
localStorage.setItem("accessToken", accessToken);
@ -46,6 +49,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUserId(null);
setUserName(null);
setPermissions(null);
setRole(null);
setAccessToken(null);
localStorage.removeItem("accessToken");
};
@ -60,7 +64,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
<AuthContext.Provider
value={{
user: userId
? { id: userId, name: userName!, permissions: permissions! }
? { id: userId, name: userName!, permissions: permissions!, role: role! }
: null,
accessToken,
saveAuthData,

View File

@ -67,3 +67,13 @@
@apply bg-background text-foreground;
}
}
:root {
--primary-color: #2555FF;
--hover-primary-color: #0032e6;
--levelOne-color: #FF2F32;
--levelTwo-color: #DC6E4B;
--levelThree-color: #EBB426;
--levelFour-color: #41CB91;
--levelFive-color: #0C7C59;
}

View File

@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import "./styles/tailwind.css";
import "./styles/fonts/manrope.css";
import "./styles/fonts/inter.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>

View File

@ -0,0 +1,97 @@
import client from "@/honoClient";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shadcn/components/ui/alert-dialog";
import { Button } from "@/shadcn/components/ui/button";
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 (
<AlertDialog open={isModalOpen} onOpenChange={() => navigate({ search: {} })}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Konfirmasi Hapus</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin menghapus aspek{" "}
<strong>{aspectQuery.data?.name}</strong>? Tindakan ini tidak dapat diubah.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Batal
</AlertDialogCancel>
<Button
variant="default"
color="red"
onClick={() => mutation.mutate({ id: aspectId })}
disabled={mutation.isPending}
>
{mutation.isPending ? "Hapus..." : "Hapus Aspek"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,283 @@
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shadcn/components/ui/alert-dialog";
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
import { Button } from "@/shadcn/components/ui/button";
import { TextInput } from "@mantine/core";
import { Label } from "@/shadcn/components/ui/label";
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 { useEffect, useState } 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 isDialogOpen = Boolean(dataId || searchParams.create);
const formType = searchParams.detail ? "detail" : searchParams.edit ? "ubah" : "tambah";
// Fetch aspect data if ubahing 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: "ubah"; data: Parameters<typeof updateAspect>[0] }
| { action: "tambah"; data: Parameters<typeof createAspect>[0] }
) => {
return options.action === "ubah"
? 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 [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const values = form.values;
try {
// Start submit process
setIsSubmitting(true);
// Name field validation
if (values.name.trim() === "") {
form.setErrors({ name: "Nama aspek harus diisi" });
setIsSubmitting(false);
return;
}
let payload: CreateAspectPayload | EditAspectPayload;
if (formType === "tambah") {
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 === "ubah") {
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 === "tambah" ? "berhasil dibuat" : "berhasil diubah"}`,
});
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",
});
}
} finally {
setIsSubmitting(false);
}
};
return (
<AlertDialog open={isDialogOpen} onOpenChange={isOpen => !isOpen && navigate({ search: {} })}>
<AlertDialogContent className="max-h-[80vh] overflow-hidden">
<AlertDialogHeader>
<AlertDialogTitle>{modalTitle}</AlertDialogTitle>
<AlertDialogDescription>
{formType === "detail" ? "Detail dari aspek." : "Silakan isi data aspek di bawah ini."}
</AlertDialogDescription>
</AlertDialogHeader>
<form onSubmit={handleSubmit}>
<ScrollArea className="h-[300px] p-4">
<div className="grid gap-4">
<div className="mb-4">
<Label htmlFor="name" className="block text-left mb-1">Nama Aspek</Label>
<TextInput
id="name"
type="text"
{...form.getInputProps("name")}
disabled={formType === "detail" || isSubmitting}
error={form.errors.name}
/>
</div>
{form.values.subAspects.map((subAspect, index) => (
<div className="flex justify-between items-center mb-4" key={subAspect.id}>
<div className="flex-grow">
<Label htmlFor={`subAspect-${index}`} className="block text-left mb-1">
Sub Aspek {index + 1}
</Label>
<TextInput
id={`subAspect-${index}`}
value={subAspect.name}
onChange={(event) => {
if (formType !== "detail" && !mutation.isPending) {
const newSubAspects = [...form.values.subAspects];
newSubAspects[index] = { ...newSubAspects[index], name: event.target.value };
form.setValues({ subAspects: newSubAspects });
}
}}
disabled={formType === "detail" || isSubmitting}
/>
</div>
{formType === "detail" && (
<div className="ml-4 flex-shrink-0">
<Label className="block text-left mb-1">Jumlah Soal</Label>
<TextInput
value={subAspect.questionCount}
readOnly
className="w-full"
disabled
/>
</div>
)}
{formType !== "detail" && (
<Button
className="ml-4 mt-4"
variant="outline"
onClick={(e) => {
e.preventDefault();
const newSubAspects = form.values.subAspects.filter((_, i) => i !== index);
form.setValues({ subAspects: newSubAspects });
}}
disabled={isSubmitting}
>
Hapus
</Button>
)}
</div>
))}
{formType !== "detail" && (
<Button className="mb-4"
variant="outline"
onClick={(e) => {
e.preventDefault();
const newSubAspects = [
...form.values.subAspects,
{ id: createId(), name: "", questionCount: 0 }
];
form.setValues({ subAspects: newSubAspects });
}}
>
Tambah Sub Aspek
</Button>
)}
</div>
</ScrollArea>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>Tutup</AlertDialogCancel>
{formType !== "detail" && (
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Menyimpan..." : modalTitle}
</Button>
)}
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
);
}

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,98 @@
import React, { useRef } from 'react';
import { TbUpload } from 'react-icons/tb';
import { Text, Stack, Group, Flex } from '@mantine/core';
import FileSizeValidationModal from "@/modules/assessmentManagement/modals/FileSizeValidationModal";
interface Question {
questionId: string;
needFile: boolean;
// Add any other properties needed for question
}
interface FileUploadProps {
question: Question;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>, question: Question) => void;
handleRemoveFile: (question: Question) => void;
uploadedFiles: Record<string, File | null>;
dragActive: boolean;
handleDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
handleDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
handleDrop: (event: React.DragEvent<HTMLDivElement>, question: Question) => void;
modalOpenFileSize: boolean;
setModalOpenFileSize: React.Dispatch<React.SetStateAction<boolean>>;
exceededFileName: string;
handleClick: () => void;
}
const FileUpload: React.FC<FileUploadProps> = ({
question,
handleFileChange,
handleRemoveFile,
uploadedFiles,
dragActive,
handleDragOver,
handleDragLeave,
handleDrop,
modalOpenFileSize,
setModalOpenFileSize,
exceededFileName,
handleClick
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className="mx-11 sm:mx-8 md:mx-11">
{question.needFile && (
<div
className={`pt-5 pb-5 pr-5 pl-5 border-2 rounded-lg border-dashed ${dragActive ? "bg-gray-100" : "bg-transparent"} shadow-lg`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={(event) => handleDrop(event, question)}
onClick={() => {
handleClick();
fileInputRef.current?.click();
}}
>
<Flex align="center" justify="space-between" gap="sm">
{/* Upload Icon */}
<TbUpload size={24} className="text-gray-500 md:w-6 md:h-6" />
<div className="flex-grow text-right">
<Text className="font-bold text-xs sm:text-sm md:text-base">
Klik untuk unggah atau geser file disini
</Text>
<Text className="text-xs sm:text-sm text-gray-400">PNG, JPG, PDF</Text>
<Text className="text-xs sm:text-sm text-gray-400">(Max.File size : 64 MB)</Text>
</div>
</Flex>
<input
type="file"
ref={fileInputRef}
onChange={(event) => handleFileChange(event, question)}
style={{ display: "none" }}
accept="image/png, image/jpeg, application/pdf"
/>
</div>
)}
<div className="mx-2">
{uploadedFiles[question.questionId] && (
<Stack gap="sm" mt="sm">
<Text className="font-bold">File yang diunggah:</Text>
<Group align="center">
<Text>{uploadedFiles[question.questionId]?.name}</Text>
</Group>
</Stack>
)}
</div>
{/* File Size Validation Modal */}
<FileSizeValidationModal
opened={modalOpenFileSize}
onClose={() => setModalOpenFileSize(false)}
fileName={exceededFileName}
/>
</div>
);
};
export default FileUpload;

View File

@ -0,0 +1,48 @@
import { Button } from "@/shadcn/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shadcn/components/ui/alert-dialog";
interface FinishAssessmentModalProps {
assessmentId: string;
opened: boolean;
onClose: () => void;
onConfirm: (assessmentId: string) => void;
}
export default function FinishAssessmentModal({
assessmentId,
opened,
onClose,
onConfirm,
}: FinishAssessmentModalProps) {
return (
<AlertDialog open={opened} onOpenChange={onClose}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Konfirmasi Selesai Asesmen</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin mengakhiri assessment ini? Pastikan semua jawaban sudah lengkap sebelum melanjutkan. Jika Anda sudah siap, klik 'Ya' untuk menyelesaikan.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex justify-end gap-2">
<AlertDialogCancel asChild>
<Button variant="outline" onClick={onClose}>
Tidak
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={() => onConfirm(assessmentId)}>Ya</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,42 @@
import { Button } from "@/shadcn/components/ui/button";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shadcn/components/ui/alert-dialog";
interface FileSizeValidationModalProps {
opened: boolean;
onClose: () => void;
fileName?: string;
}
export default function FileSizeValidationModal({
opened,
onClose,
fileName,
}: FileSizeValidationModalProps) {
return (
<AlertDialog open={opened} onOpenChange={onClose}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Peringatan Ukuran File</AlertDialogTitle>
<AlertDialogDescription>
Ukuran file {fileName} melebihi batas maksimum 64 MB! Silakan pilih file yang lebih kecil.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex justify-end gap-2">
<AlertDialogCancel asChild>
<Button variant="outline" onClick={onClose}>
Tutup
</Button>
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,42 @@
import { Button } from "@/shadcn/components/ui/button";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shadcn/components/ui/alert-dialog";
interface ValidationModalProps {
opened: boolean;
onClose: () => void;
unansweredQuestions: number;
}
export default function ValidationModal({
opened,
onClose,
unansweredQuestions,
}: ValidationModalProps) {
return (
<AlertDialog open={opened} onOpenChange={onClose}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle>Peringatan</AlertDialogTitle>
<AlertDialogDescription>
Anda mempunyai {unansweredQuestions} pertanyaan yang belum terjawab! Pastikan semua jawaban sudah lengkap sebelum melanjutkan.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="flex justify-end gap-2">
<AlertDialogCancel asChild>
<Button variant="outline" onClick={onClose}>
Tutup
</Button>
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,175 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions, UseMutationOptions } from "@tanstack/react-query";
type SubmitOptionResponse = {
message: string;
answer: {
id: string;
createdAt: string | null;
validationInformation: string;
isFlagged: boolean | null;
};
};
export const fetchAspects = async () => {
return await fetchRPC(
client.assessments.aspect.$get({
query: {}
})
);
};
// Query untuk mendapatkan semua pertanyaan berdasarkan halaman dan limit
export const getQuestionsAllQueryOptions = (page: number, limit: number, q?: string) =>
queryOptions({
queryKey: ["assessment", { page, limit, q }],
queryFn: () =>
fetchRPC(
client.assessments.getAllQuestions.$get({
query: {
limit: String(limit),
page: String(page),
q: q || "",
},
})
),
});
// Query untuk mendapatkan jawaban berdasarkan assessment ID
export const getAnswersQueryOptions = (
assessmentId: string,
page: number,
limit: number,
q: string = "",
enabled: boolean = true
) => {
return queryOptions({
queryKey: ["assessment", { assessmentId, page, limit, q }],
queryFn: () =>
fetchRPC(
client.assessments.getAnswers.$get({
query: {
assessmentId,
},
})
).then((res) => {
return res;
}),
enabled,
});
};
// Query untuk toggle flag jawaban berdasarkan questionId
export const toggleFlagAnswer = async (form: {
assessmentId: string;
questionId: string;
isFlagged: boolean;
}): Promise<SubmitOptionResponse> => {
return await fetchRPC(
client.assessments.toggleFlag.$patch({
json: form,
})
);
};
// Query untuk mendapatkan rata-rata skor berdasarkan aspectId dan assessmentId
export const getAverageScoreQueryOptions = (assessmentId: string) =>
queryOptions({
queryKey: ["averageScore", { assessmentId }],
queryFn: () =>
fetchRPC(
client.assessments["average-score"].aspects.assessments[":assessmentId"].$get({
param: {
assessmentId,
},
})
),
});
export const submitOption = async (form: {
optionId: string;
assessmentId: string;
questionId: string;
isFlagged?: boolean;
filename?: string;
}): Promise<SubmitOptionResponse> => {
return await fetchRPC(
client.assessments.submitOption.$post({
json: form,
})
);
};
export const submitOptionMutationOptions: UseMutationOptions<
SubmitOptionResponse,
Error,
Parameters<typeof submitOption>[0]
> = {
mutationFn: submitOption,
};
export const submitValidationQuery = async (
form: {
assessmentId: string;
questionId: string;
validationInformation: string;
}
) => {
return await fetchRPC(
client.assessments.submitValidation.$post({
json: {
...form,
assessmentId: String(form.assessmentId),
questionId: String(form.questionId),
validationInformation: form.validationInformation,
},
})
);
};
export const submitValidationMutationOptions = () => ({
mutationFn: submitValidationQuery,
});
// Function to upload a file
const uploadFile = async (formData: FormData, assessmentId: string, questionId: string) => {
const token = localStorage.getItem('accessToken');
const response = await fetch(`${import.meta.env.VITE_BACKEND_BASE_URL}/assessments/uploadFile?assessmentId=${assessmentId}&questionId=${questionId}`, {
method: 'POST',
body: formData,
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Error uploading file');
}
const responseData = await response.json();
return responseData; // Return the JSON response with file URL
};
// Options for the mutation
export const uploadFileMutationOptions = (): UseMutationOptions<{ imageUrl: string }, Error, FormData> => ({
mutationFn: (formData: FormData) => {
const assessmentId = formData.get('assessmentId') as string;
const questionId = formData.get('questionId') as string;
return uploadFile(formData, assessmentId, questionId);
},
});
export const submitAssessment = async (assessmentId: string): Promise<{ message: string }> => {
return await fetchRPC(
client.assessments.submitAssessment[":id"].$patch({
param: { id: assessmentId },
})
);
};
export const submitAssessmentMutationOptions = (assessmentId: string) => ({
mutationFn: () => submitAssessment(assessmentId),
});

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,50 @@
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("Asesmen sedang berlangsung, Selesaikan terlebih dahulu.");
}
return await response.json();
};
// Query to update assessment status when user start assessment
export const updateAssessmentRequest = async ({
assessmentId,
status,
}: {
assessmentId: string;
status: "menunggu konfirmasi" | "diterima" | "ditolak" | "dalam pengerjaan" | "belum diverifikasi" | "selesai";
}) => {
const response = await client.assessmentRequest[":assessmentId"].$patch({
json: { status },
param: { assessmentId },
});
if (!response.ok) {
throw new Error("Gagal memperbarui status permohonan asesmen");
}
return await response.json();
};

View File

@ -0,0 +1,236 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shadcn/components/ui/dialog";
import { Button } from "@/shadcn/components/ui/button";
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
// 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" | "dalam pengerjaan" | "belum diverifikasi" | "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 (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Detail Permohonan Asesmen</DialogTitle>
</DialogHeader>
<DialogDescription>
<ScrollArea className="h-[400px] w-full rounded-md p-4">
{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"),
},
],
})}
</ScrollArea>
<div className="flex justify-end gap-4 mt-4">
<Button
onClick={onClose}
variant="outline"
disabled={mutation.isPending}
className="min-w-20"
>
Tutup
</Button>
{status !== "selesai" && (
<>
<Button
onClick={() => handleStatusChange("ditolak")}
variant="destructive"
disabled={mutation.isPending}
className="min-w-20"
>
Tolak
</Button>
<Button
onClick={() => handleStatusChange("diterima")}
variant="default"
disabled={mutation.isPending}
className="min-w-20"
>
Terima
</Button>
</>
)}
</div>
</DialogDescription>
</DialogContent>
</Dialog>
);
}

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" | "dalam pengerjaan" | "belum diverifikasi" | "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,59 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions } from "@tanstack/react-query";
export const getAllSubAspectsAverageScore = (assessmentId: string | undefined) =>
queryOptions({
queryKey: ["allSubAspectsAverage", assessmentId],
queryFn: () =>
fetchRPC(
client.assessments["average-score"]["sub-aspects"]["assessments"][":assessmentId"].$get({
param: {
assessmentId: assessmentId!,
},
})
),
enabled: Boolean(assessmentId),
});
export const getAllAspectsAverageScore = (assessmentId: string | undefined) =>
queryOptions({
queryKey: ["allAspectsAverage", assessmentId],
queryFn: () =>
fetchRPC(
client.assessments["average-score"]["aspects"]["assessments"][":assessmentId"].$get({
param: {
assessmentId: assessmentId!,
},
})
),
enabled: Boolean(assessmentId),
});
export const getAllVerifiedSubAspectsAverageScore = (assessmentId: string | undefined) =>
queryOptions({
queryKey: ["allVerifiedSubAspectsAverage", assessmentId],
queryFn: () =>
fetchRPC(
client.assessmentResult["average-score"]["sub-aspects"]["assessments"][":assessmentId"].$get({
param: {
assessmentId: assessmentId!,
},
})
),
enabled: Boolean(assessmentId),
});
export const getAllVerifiedAspectsAverageScore = (assessmentId: string | undefined) =>
queryOptions({
queryKey: ["allVerifiedAspectsAverage", assessmentId],
queryFn: () =>
fetchRPC(
client.assessmentResult["average-score"]["aspects"]["assessments"][":assessmentId"].$get({
param: {
assessmentId: assessmentId!,
},
})
),
enabled: Boolean(assessmentId),
});

View File

@ -0,0 +1,240 @@
import {
Button,
Flex,
Modal,
ScrollArea,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { useEffect } from "react";
import createInputComponents from "@/utils/createInputComponents";
import { getAssessmentResultByIdQueryOptions } from "../queries/assessmentResultsManagaementQueries";
/**
* Change this
*/
const routeApi = getRouteApi("/_dashboardLayout/assessmentResultsManagement/");
export default function UserFormModal() {
/**
* DON'T CHANGE FOLLOWING:
*/
const queryClient = useQueryClient();
const navigate = routeApi.useNavigate();
const searchParams = routeApi.useSearch();
const detailId = (searchParams as { detail?: string }).detail;
const isModalOpen = Boolean(detailId);
const formType = detailId ? "detail" : "tambah";
/**
* CHANGE FOLLOWING:
*/
const assessmentResultQuery = useQuery(getAssessmentResultByIdQueryOptions(detailId));
const modalTitle =
formType.charAt(0).toUpperCase() + formType.slice(1) + " Hasil Assessment";
const form = useForm({
initialValues: {
respondentName: "",
position: "",
workExperience: "",
email: "",
companyName: "",
address: "",
phoneNumber: "",
username: "",
assessmentDate: "",
statusAssessment: "",
assessmentsResult: "",
},
});
useEffect(() => {
const data = assessmentResultQuery.data;
if (!data) {
form.reset();
return;
}
// Function to format the date
const formatDate = (dateString: string) => {
const date = new Date(dateString);
// Format only the date, hour, and minute
return date.toLocaleString('id-ID', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
};
form.setValues({
respondentName: data.respondentName ?? "",
position: data.position ?? "",
workExperience: data.workExperience ?? "",
email: data.email ?? "",
companyName: data.companyName ?? "",
address: data.address ?? "",
phoneNumber: data.phoneNumber ?? "",
username: data.username ?? "",
assessmentDate: data.assessmentDate ? formatDate(data.assessmentDate) : "",
statusAssessment: data.statusAssessment ?? "",
assessmentsResult: String(data.assessmentsResult ?? ""),
});
form.setErrors({});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assessmentResultQuery.data]);
// const mutation = useMutation({
// mutationKey: ["assessmentResultMutation"],
// mutationFn: async (
// options:
// | { action: "ubah"; data: Parameters<typeof updateUser>[0] }
// | { action: "tambah"; data: Parameters<typeof createUser>[0] }
// ) => {
// console.log("called");
// return options.action === "ubah"
// ? await updateUser(options.data)
// : await createUser(options.data);
// },
// 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",
// });
// }
// },
// });
const handleSubmit = async (values: typeof form.values) => {
if (formType === "detail") {
({
action: formType,
data: {
respondentName: values.respondentName,
position: values.position,
workExperience: values.workExperience,
email: values.email,
companyName: values.companyName,
address: values.address,
phoneNumber: values.phoneNumber,
username: values.username,
assessmentDate: values.assessmentDate,
statusAssessment: values.statusAssessment,
assessmentsResult: values.assessmentsResult,
isEnabled: "true",
},
});
}
queryClient.invalidateQueries({ queryKey: ["users"] });
navigate({ search: {} });
};
/**
* YOU MIGHT NOT NEED FOLLOWING:
*/
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={modalTitle} //Uppercase first letter
scrollAreaComponent={ScrollArea.Autosize}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
{createInputComponents({
readonlyAll: formType === "detail",
inputs: [
{
type: "text",
label: "Nama Respondent",
...form.getInputProps("respondentName"),
},
{
type: "text",
label: "Jabatan",
...form.getInputProps("position"),
},
{
type: "text",
label: "Pengalaman Kerja",
...form.getInputProps("workExperience"),
},
{
type: "text",
label: "Email",
...form.getInputProps("email"),
},
{
type: "text",
label: "Instansi/Perusahaan",
...form.getInputProps("companyName"),
},
{
type: "text",
label: "Alamat",
...form.getInputProps("address"),
},
{
type: "text",
label: "Nomor Telepon",
...form.getInputProps("phoneNumber"),
},
{
type: "text",
label: "Username",
...form.getInputProps("username"),
},
{
type: "text",
label: "Tanggal Assessment",
...form.getInputProps("assessmentDate"),
},
{
type: "text",
label: "Status Assessment",
...form.getInputProps("statusAssessment"),
},
{
type: "text",
label: "Hasil Assessment",
...form.getInputProps("assessmentsResult"),
},
],
})}
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
>
Tutup
</Button>
</Flex>
</form>
</Modal>
);
}

View File

@ -0,0 +1,168 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions, useMutation } from "@tanstack/react-query";
export const assessmentResultsQueryOptions = (page: number, limit: number, q?: string) =>
queryOptions({
queryKey: ["assessmentResults", { page, limit, q }],
queryFn: () =>
fetchRPC(
client.assessmentResult.$get({
query: {
limit: String(limit),
page: String(page),
q: q || "",
},
})
),
});
export const getAssessmentResultByIdQueryOptions = (assessmentResultId: string | undefined) =>
queryOptions({
queryKey: ["assessmentResults", assessmentResultId],
queryFn: () =>
fetchRPC(
client.assessmentResult[":id"].$get({
param: {
id: assessmentResultId!,
},
})
),
enabled: Boolean(assessmentResultId),
});
export const getVerifiedAssessmentResultByIdQueryOptions = (assessmentResultId: string | undefined) =>
queryOptions({
queryKey: ["verifiedAssessmentResult", assessmentResultId],
queryFn: () =>
fetchRPC(
client.assessmentResult.verified[":id"].$get({
param: {
id: assessmentResultId!,
},
})
),
enabled: Boolean(assessmentResultId),
});
export const postAnswerRevisionQueryOptions = (
assessmentId: string,
revisedBy: string,
) =>
queryOptions({
queryKey: ["answerRevisions", assessmentId],
queryFn: () =>
fetchRPC(
client.assessmentResult["answer-revisions"].$post({
json: {
assessmentId,
revisedBy,
},
})
),
enabled: Boolean(assessmentId && revisedBy),
});
export const postAnswerRevisionMutation = () => {
return useMutation({
mutationFn: ({ assessmentId, revisedBy }: { assessmentId: string; revisedBy: string }) => {
return fetchRPC(
client.assessmentResult["answer-revisions"].$post({
json: {
assessmentId,
revisedBy,
},
})
);
},
onSuccess: () => {
console.log("Revision posted successfully.");
// Optionally, you could trigger a refetch of relevant data here
},
onError: (error: any) => {
console.error("Error posting revision:", error);
},
});
};
// Query untuk mendapatkan jawaban berdasarkan assessment ID
export const getAnswersRevisionQueryOptions = (
assessmentId: string,
) => {
return queryOptions({
queryKey: ["answerRevision", { assessmentId }],
queryFn: () =>
fetchRPC(
client.assessmentResult.getAnswers[":id"].$get({
param: { id: assessmentId },
})
),
});
};
export const updateValidationQueryOptions = (assessmentId: string, questionId: string, newValidationInformation: string) => {
return queryOptions({
queryKey: ["updateValidation", { assessmentId, questionId }],
queryFn: () =>
fetchRPC(
client.assessmentResult.updateValidation.$post({
json: {
assessmentId,
questionId,
newValidationInformation,
},
})
),
enabled: Boolean(assessmentId && questionId && newValidationInformation),
});
};
export const updateValidationQuery = async (
form: {
assessmentId: string;
questionId: string;
newValidationInformation: string;
}
) => {
return await fetchRPC(
client.assessmentResult.updateValidation.$post({
json: {
...form,
assessmentId: String(form.assessmentId),
questionId: String(form.questionId),
newValidationInformation: form.newValidationInformation,
},
})
);
};
export const updateOptionQuery = async (
form: {
assessmentId: string;
questionId: string;
optionId: string;
}
) => {
return await fetchRPC(
client.assessments.updateOption.$patch({
json: {
...form,
assessmentId: String(form.assessmentId),
questionId: String(form.questionId),
newOptionId: form.optionId,
},
})
);
};
export const submitAssessmentRevision = async (assessmentId: string): Promise<{ message: string }> => {
return await fetchRPC(
client.assessmentResult.submitAssessmentRevision[":id"].$patch({
param: { id: assessmentId },
})
);
};
export const submitAssessmentRevisionMutationOptions = (assessmentId: string) => ({
mutationFn: () => submitAssessmentRevision(assessmentId),
});

View File

@ -0,0 +1,98 @@
import client from "@/honoClient";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shadcn/components/ui/alert-dialog";
import { Button } from "@/shadcn/components/ui/button";
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 (
<AlertDialog open={isModalOpen} onOpenChange={() => navigate({ search: {} })}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Konfirmasi Hapus</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin menghapus pertanyaan ini? Tindakan ini tidak dapat diubah.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Batal
</AlertDialogCancel>
<Button
variant="destructive"
onClick={() => mutation.mutate({ id: questionId })}
disabled={mutation.isPending}
>
{mutation.isPending ? "Menghapus..." : "Hapus Pertanyaan"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -0,0 +1,348 @@
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 ? "ubah" : "tambah";
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: "ubah" | "tambah";
data: CreateQuestionPayload | UpdateQuestionPayload;
}
interface MutationResponse {
message: string;
}
const mutation = useMutation<MutationResponse, Error, MutationOptions>({
mutationKey: ["questionsMutation"],
mutationFn: async (options) => {
if (options.action === "ubah") {
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 === "tambah") {
await mutation.mutateAsync({ action: "tambah", data: payload });
notifications.show({
message: "Data pertanyaan berhasil dibuat!",
color: "green",
});
} else {
await mutation.mutateAsync({ action: "ubah", 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`)}
disabled={mutation.isPending}
required
/>
<NumberInput
label="Skor"
placeholder="Skor"
readOnly={formType === "detail"}
min={0}
max={999}
allowDecimal={false}
allowNegative={false}
{...form.getInputProps(`options.${index}.score`)}
disabled={mutation.isPending}
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

@ -1,5 +1,14 @@
import client from "@/honoClient";
import { Button, Flex, Modal, Text } from "@mantine/core";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shadcn/components/ui/alert-dialog";
import { Button } from "@/shadcn/components/ui/button";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi, useSearch } from "@tanstack/react-router";
import { deleteUser } from "../queries/userQueries";
@ -60,40 +69,34 @@ export default function UserDeleteModal() {
const isModalOpen = Boolean(searchParams.delete && userQuery.data);
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={`Delete confirmation`}
>
<Text size="sm">
Are you sure you want to delete user{" "}
<Text span fw={700}>
{userQuery.data?.name}
</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: userId })}
>
Delete User
</Button>
</Flex>
</Modal>
<AlertDialog open={isModalOpen} onOpenChange={() => navigate({ search: {} })}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Konfirmasi Hapus</AlertDialogTitle>
<AlertDialogDescription>
Apakah Anda yakin ingin menghapus pengguna{" "}
<strong>{userQuery.data?.name}</strong>?
<br />
Tindakan ini tidak dapat diubah.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Batal
</AlertDialogCancel>
<Button
variant="default"
color="red"
onClick={() => mutation.mutate({ id: userId })}
disabled={mutation.isPending}
>
{mutation.isPending ? "Hapus..." : "Hapus Pengguna"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@ -1,19 +1,16 @@
import client from "../../../honoClient";
import stringToColorHex from "@/utils/stringToColorHex";
import {
Avatar,
Button,
Center,
Flex,
Modal,
ScrollArea,
Stack,
} from "@mantine/core";
AvatarFallback,
AvatarImage
} from "@/shadcn/components/ui/avatar";
import { Modal, ScrollArea } 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 { createUser, updateUser } from "../queries/userQueries";
import { TbDeviceFloppy } from "react-icons/tb";
import client from "../../../honoClient";
import { getUserByIdQueryOptions } from "../queries/userQueries";
import { useEffect } from "react";
import { notifications } from "@mantine/notifications";
@ -42,7 +39,7 @@ export default function UserFormModal() {
const detailId = searchParams.detail;
const editId = searchParams.edit;
const formType = detailId ? "detail" : editId ? "edit" : "create";
const formType = detailId ? "detail" : editId ? "ubah" : "tambah";
/**
* CHANGE FOLLOWING:
@ -51,7 +48,7 @@ export default function UserFormModal() {
const userQuery = useQuery(getUserByIdQueryOptions(dataId));
const modalTitle =
formType.charAt(0).toUpperCase() + formType.slice(1) + " User";
formType.charAt(0).toUpperCase() + formType.slice(1) + " Pengguna";
const form = useForm({
initialValues: {
@ -62,6 +59,11 @@ export default function UserFormModal() {
photoProfileUrl: "",
password: "",
roles: [] as string[],
companyName: "",
position: "",
workExperience: "",
address: "",
phoneNumber: "",
},
});
@ -75,12 +77,17 @@ export default function UserFormModal() {
form.setValues({
id: data.id,
email: data.email ?? "",
email: data.email,
name: data.name,
photoProfileUrl: "",
username: data.username,
password: "",
roles: data.roles.map((v) => v.id), //only extract the id
companyName: data.companyName ?? "",
position: data.position ?? "",
workExperience: data.workExperience ?? "",
address: data.address ?? "",
phoneNumber: data.phoneNumber ?? "",
});
form.setErrors({});
@ -91,11 +98,11 @@ export default function UserFormModal() {
mutationKey: ["usersMutation"],
mutationFn: async (
options:
| { action: "edit"; data: Parameters<typeof updateUser>[0] }
| { action: "create"; data: Parameters<typeof createUser>[0] }
| { action: "ubah"; data: Parameters<typeof updateUser>[0] }
| { action: "tambah"; data: Parameters<typeof createUser>[0] }
) => {
console.log("called");
return options.action === "edit"
return options.action === "ubah"
? await updateUser(options.data)
: await createUser(options.data);
},
@ -120,16 +127,21 @@ export default function UserFormModal() {
if (formType === "detail") return;
//TODO: OPtimize this code
if (formType === "create") {
if (formType === "tambah") {
await mutation.mutateAsync({
action: formType,
data: {
email: values.email,
name: values.name,
password: values.password,
roles: JSON.stringify(values.roles),
roles: values.roles,
isEnabled: "true",
username: values.username,
companyName: values.companyName,
position: values.position,
workExperience: values.workExperience,
address: values.address,
phoneNumber: values.phoneNumber,
},
});
} else {
@ -140,15 +152,20 @@ export default function UserFormModal() {
email: values.email,
name: values.name,
password: values.password,
roles: JSON.stringify(values.roles),
roles: values.roles,
isEnabled: "true",
username: values.username,
companyName: values.companyName,
position: values.position,
workExperience: values.workExperience,
address: values.address,
phoneNumber: values.phoneNumber,
},
});
}
queryClient.invalidateQueries({ queryKey: ["users"] });
notifications.show({
message: `The ser is ${formType === "create" ? "created" : "edited"}`,
message: `Pengguna berhasil di${formType === "tambah" ? "tambahkan" : "perbarui"}`,
});
navigate({ search: {} });
@ -171,95 +188,113 @@ export default function UserFormModal() {
});
return (
<Modal
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={modalTitle} //Uppercase first letter
scrollAreaComponent={ScrollArea.Autosize}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<Stack mt="sm" gap="lg" px="lg">
{/* Avatar */}
<Center>
<Avatar
color={stringToColorHex(form.values.id ?? "")}
src={form.values.photoProfileUrl}
size={120}
>
{form.values.name?.[0]?.toUpperCase()}
</Avatar>
</Center>
</Stack>
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<div className="flex items-center justify-center my-2">
<div className="h-120 w-120 rounded-full overflow-hidden">
<Avatar className="h-[120px] w-[120px] rounded-full overflow-hidden">
<AvatarImage src={form.values.photoProfileUrl} alt={form.values.name} />
<AvatarFallback style={{ backgroundColor: stringToColorHex(form.values.id ?? ""), fontSize: "60px", color: "white", fontWeight: "bold" }}>
{form.values.name?.[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
</div>
</div>
{createInputComponents({
disableAll: mutation.isPending,
readonlyAll: formType === "detail",
inputs: [
{
type: "text",
readOnly: true,
variant: "filled",
...form.getInputProps("id"),
hidden: !form.values.id,
},
{
type: "text",
label: "Name",
...form.getInputProps("name"),
},
{
type: "text",
label: "Username",
...form.getInputProps("username"),
},
{
type: "text",
label: "Email",
...form.getInputProps("email"),
},
{
type: "password",
label: "Password",
hidden: formType !== "create",
...form.getInputProps("password"),
},
{
type: "multi-select",
label: "Roles",
value: form.values.roles,
onChange: (values) =>
form.setFieldValue("roles", values),
data: rolesQuery.data?.map((role) => ({
value: role.id,
label: role.name,
})),
error: form.errors.roles,
},
],
})}
<ScrollArea className="h-72 pr-4">
{createInputComponents({
disableAll: mutation.isPending,
readonlyAll: formType === "detail",
inputs: [
{
type: "text",
label: "Nama",
...form.getInputProps("name"),
},
{
type: "text",
label: "Jabatan",
...form.getInputProps("position"),
},
{
type: "text",
label: "Pengalaman Kerja",
...form.getInputProps("workExperience"),
},
{
type: "text",
label: "Email",
...form.getInputProps("email"),
},
{
type: "text",
label: "Instansi/Perusahaan",
...form.getInputProps("companyName"),
},
{
type: "text",
label: "Alamat",
...form.getInputProps("address"),
},
{
type: "text",
label: "Nomor Telepon",
...form.getInputProps("phoneNumber"),
},
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Close
</Button>
{formType !== "detail" && (
<Button
variant="filled"
leftSection={<TbDeviceFloppy size={20} />}
type="submit"
loading={mutation.isPending}
>
Save
</Button>
)}
</Flex>
</form>
{
type: "multi-select",
label: "Roles",
value: form.values.roles,
onChange: (values) =>
form.setFieldValue("roles", values),
data: rolesQuery.data?.map((role) => ({
value: role.id,
label: role.name,
})),
error: form.errors.roles,
},
{
type: "text",
label: "Username",
...form.getInputProps("username"),
},
{
type: "password",
label: "Password",
hidden: formType !== "tambah",
...form.getInputProps("password"),
},
],
})}
</ScrollArea>
{/* Buttons */}
<div className="flex justify-end align-center gap-1 mt-4">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Tutup
</Button>
{formType !== "detail" && (
<Button
variant="default"
type="submit"
disabled={mutation.isPending}
>
Simpan
</Button>
)}
</div>
</form>
</Modal>
);
}

View File

@ -34,26 +34,26 @@ export const getUserByIdQueryOptions = (userId: string | undefined) =>
});
export const createUser = async (
form: InferRequestType<typeof client.users.$post>["form"]
json: InferRequestType<typeof client.users.$post>["json"]
) => {
return await fetchRPC(
client.users.$post({
form,
json,
})
);
};
export const updateUser = async (
form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & {
json: InferRequestType<(typeof client.users)[":id"]["$patch"]>["json"] & {
id: string;
}
) => {
return await fetchRPC(
client.users[":id"].$patch({
param: {
id: form.id,
id: json.id,
},
form,
json,
})
);
};

View File

@ -1,5 +1,4 @@
import { createColumnHelper } from "@tanstack/react-table";
import { Badge, Flex, Group, Avatar, Text, Anchor } from "@mantine/core";
import { TbEye, TbPencil, TbTrash } from "react-icons/tb";
import { CrudPermission } from "@/types";
import stringToColorHex from "@/utils/stringToColorHex";
@ -7,6 +6,8 @@ import createActionButtons from "@/utils/createActionButton";
import client from "@/honoClient";
import { InferResponseType } from "hono";
import { Link } from "@tanstack/react-router";
import { Badge } from "@/shadcn/components/ui/badge";
import { Avatar } from "@/shadcn/components/ui/avatar";
interface ColumnOptions {
permissions: Partial<CrudPermission>;
@ -29,31 +30,28 @@ const createColumns = (options: ColumnOptions) => {
columnHelper.accessor("name", {
header: "Name",
cell: (props) => (
<Group>
<div className="items-center justify-center gap-2">
<Avatar
color={stringToColorHex(props.row.original.id)}
style={{ backgroundColor: stringToColorHex(props.row.original.id), width: '26px', height: '26px' }}
// src={props.row.original.photoUrl}
size={26}
>
{props.getValue()?.[0].toUpperCase()}
</Avatar>
<Text size="sm" fw={500}>
<span className="text-sm font-medium">
{props.getValue()}
</Text>
</Group>
</span>
</div>
),
}),
columnHelper.accessor("email", {
header: "Email",
cell: (props) => (
<Anchor
to={`mailto:${props.getValue()}`}
size="sm"
component={Link}
>
<Link
href={`mailto:${props.getValue()}`}
>
{props.getValue()}
</Anchor>
</Link>
),
}),
@ -66,7 +64,7 @@ const createColumns = (options: ColumnOptions) => {
id: "status",
header: "Status",
cell: (props) => (
<Badge color={props.row.original.isEnabled ? "green" : "gray"}>
<Badge variant={props.row.original.isEnabled ? "default" : "secondary"}>
{props.row.original.isEnabled ? "Active" : "Inactive"}
</Badge>
),
@ -80,7 +78,7 @@ const createColumns = (options: ColumnOptions) => {
className: "w-fit",
},
cell: (props) => (
<Flex gap="xs">
<div className="gap-8">
{createActionButtons([
{
label: "Detail",
@ -104,7 +102,7 @@ const createColumns = (options: ColumnOptions) => {
icon: <TbTrash />,
},
])}
</Flex>
</div>
),
}),
];

View File

@ -13,38 +13,93 @@ import { createFileRoute } from '@tanstack/react-router'
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as VerifyingLayoutImport } from './routes/_verifyingLayout'
import { Route as DashboardLayoutImport } from './routes/_dashboardLayout'
import { Route as AssessmentLayoutImport } from './routes/_assessmentLayout'
import { Route as LoginIndexImport } from './routes/login/index'
import { Route as VerifyingLayoutVerifyingIndexImport } from './routes/_verifyingLayout/verifying/index'
import { Route as DashboardLayoutUsersIndexImport } from './routes/_dashboardLayout/users/index'
import { Route as DashboardLayoutTimetableIndexImport } from './routes/_dashboardLayout/timetable/index'
import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboardLayout/dashboard/index'
import { Route as DashboardLayoutQuestionsIndexImport } from './routes/_dashboardLayout/questions/index'
import { Route as DashboardLayoutAssessmentResultsManagementIndexImport } from './routes/_dashboardLayout/assessmentResultsManagement/index'
import { Route as DashboardLayoutAssessmentResultIndexImport } from './routes/_dashboardLayout/assessmentResult/index'
import { Route as DashboardLayoutAssessmentRequestManagementsIndexImport } from './routes/_dashboardLayout/assessmentRequestManagements/index'
import { Route as DashboardLayoutAssessmentRequestIndexImport } from './routes/_dashboardLayout/assessmentRequest/index'
import { Route as DashboardLayoutAspectIndexImport } from './routes/_dashboardLayout/aspect/index'
import { Route as AssessmentLayoutAssessmentIndexImport } from './routes/_assessmentLayout/assessment/index'
// Create Virtual Routes
const IndexLazyImport = createFileRoute('/')()
const RegisterIndexLazyImport = createFileRoute('/register/')()
const LogoutIndexLazyImport = createFileRoute('/logout/')()
const LoginIndexLazyImport = createFileRoute('/login/')()
const ForgotPasswordIndexLazyImport = createFileRoute('/forgot-password/')()
const ForgotPasswordVerifyLazyImport = createFileRoute(
'/forgot-password/verify',
)()
// Create/Update Routes
const VerifyingLayoutRoute = VerifyingLayoutImport.update({
id: '/_verifyingLayout',
getParentRoute: () => rootRoute,
} as any)
const DashboardLayoutRoute = DashboardLayoutImport.update({
id: '/_dashboardLayout',
getParentRoute: () => rootRoute,
} as any)
const AssessmentLayoutRoute = AssessmentLayoutImport.update({
id: '/_assessmentLayout',
getParentRoute: () => rootRoute,
} as any)
const IndexLazyRoute = IndexLazyImport.update({
path: '/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
const RegisterIndexLazyRoute = RegisterIndexLazyImport.update({
path: '/register/',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/register/index.lazy').then((d) => d.Route),
)
const LogoutIndexLazyRoute = LogoutIndexLazyImport.update({
path: '/logout/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/logout/index.lazy').then((d) => d.Route))
const LoginIndexLazyRoute = LoginIndexLazyImport.update({
const ForgotPasswordIndexLazyRoute = ForgotPasswordIndexLazyImport.update({
path: '/forgot-password/',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/forgot-password/index.lazy').then((d) => d.Route),
)
const LoginIndexRoute = LoginIndexImport.update({
path: '/login/',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/login/index.lazy').then((d) => d.Route))
} as any)
const ForgotPasswordVerifyLazyRoute = ForgotPasswordVerifyLazyImport.update({
path: '/forgot-password/verify',
getParentRoute: () => rootRoute,
} as any).lazy(() =>
import('./routes/forgot-password/verify.lazy').then((d) => d.Route),
)
const VerifyingLayoutVerifyingIndexRoute =
VerifyingLayoutVerifyingIndexImport.update({
path: '/verifying/',
getParentRoute: () => VerifyingLayoutRoute,
} as any).lazy(() =>
import('./routes/_verifyingLayout/verifying/index.lazy').then(
(d) => d.Route,
),
)
const DashboardLayoutUsersIndexRoute = DashboardLayoutUsersIndexImport.update({
path: '/users/',
@ -59,11 +114,74 @@ const DashboardLayoutTimetableIndexRoute =
getParentRoute: () => DashboardLayoutRoute,
} as any)
const DashboardLayoutDashboardIndexRoute =
DashboardLayoutDashboardIndexImport.update({
path: '/dashboard/',
const DashboardLayoutQuestionsIndexRoute =
DashboardLayoutQuestionsIndexImport.update({
path: '/questions/',
getParentRoute: () => DashboardLayoutRoute,
} as any)
} as any).lazy(() =>
import('./routes/_dashboardLayout/questions/index.lazy').then(
(d) => d.Route,
),
)
const DashboardLayoutAssessmentResultsManagementIndexRoute =
DashboardLayoutAssessmentResultsManagementIndexImport.update({
path: '/assessmentResultsManagement/',
getParentRoute: () => DashboardLayoutRoute,
} as any).lazy(() =>
import(
'./routes/_dashboardLayout/assessmentResultsManagement/index.lazy'
).then((d) => d.Route),
)
const DashboardLayoutAssessmentResultIndexRoute =
DashboardLayoutAssessmentResultIndexImport.update({
path: '/assessmentResult/',
getParentRoute: () => DashboardLayoutRoute,
} as any).lazy(() =>
import('./routes/_dashboardLayout/assessmentResult/index.lazy').then(
(d) => d.Route,
),
)
const DashboardLayoutAssessmentRequestManagementsIndexRoute =
DashboardLayoutAssessmentRequestManagementsIndexImport.update({
path: '/assessmentRequestManagements/',
getParentRoute: () => DashboardLayoutRoute,
} as any).lazy(() =>
import(
'./routes/_dashboardLayout/assessmentRequestManagements/index.lazy'
).then((d) => d.Route),
)
const DashboardLayoutAssessmentRequestIndexRoute =
DashboardLayoutAssessmentRequestIndexImport.update({
path: '/assessmentRequest/',
getParentRoute: () => DashboardLayoutRoute,
} as any).lazy(() =>
import('./routes/_dashboardLayout/assessmentRequest/index.lazy').then(
(d) => d.Route,
),
)
const DashboardLayoutAspectIndexRoute = DashboardLayoutAspectIndexImport.update(
{
path: '/aspect/',
getParentRoute: () => DashboardLayoutRoute,
} as any,
).lazy(() =>
import('./routes/_dashboardLayout/aspect/index.lazy').then((d) => d.Route),
)
const AssessmentLayoutAssessmentIndexRoute =
AssessmentLayoutAssessmentIndexImport.update({
path: '/assessment/',
getParentRoute: () => AssessmentLayoutRoute,
} as any).lazy(() =>
import('./routes/_assessmentLayout/assessment/index.lazy').then(
(d) => d.Route,
),
)
// Populate the FileRoutesByPath interface
@ -76,6 +194,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexLazyImport
parentRoute: typeof rootRoute
}
'/_assessmentLayout': {
id: '/_assessmentLayout'
path: ''
fullPath: ''
preLoaderRoute: typeof AssessmentLayoutImport
parentRoute: typeof rootRoute
}
'/_dashboardLayout': {
id: '/_dashboardLayout'
path: ''
@ -83,11 +208,32 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardLayoutImport
parentRoute: typeof rootRoute
}
'/_verifyingLayout': {
id: '/_verifyingLayout'
path: ''
fullPath: ''
preLoaderRoute: typeof VerifyingLayoutImport
parentRoute: typeof rootRoute
}
'/forgot-password/verify': {
id: '/forgot-password/verify'
path: '/forgot-password/verify'
fullPath: '/forgot-password/verify'
preLoaderRoute: typeof ForgotPasswordVerifyLazyImport
parentRoute: typeof rootRoute
}
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginIndexLazyImport
preLoaderRoute: typeof LoginIndexImport
parentRoute: typeof rootRoute
}
'/forgot-password/': {
id: '/forgot-password/'
path: '/forgot-password'
fullPath: '/forgot-password'
preLoaderRoute: typeof ForgotPasswordIndexLazyImport
parentRoute: typeof rootRoute
}
'/logout/': {
@ -97,11 +243,60 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof LogoutIndexLazyImport
parentRoute: typeof rootRoute
}
'/_dashboardLayout/dashboard/': {
id: '/_dashboardLayout/dashboard/'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardLayoutDashboardIndexImport
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register'
preLoaderRoute: typeof RegisterIndexLazyImport
parentRoute: typeof rootRoute
}
'/_assessmentLayout/assessment/': {
id: '/_assessmentLayout/assessment/'
path: '/assessment'
fullPath: '/assessment'
preLoaderRoute: typeof AssessmentLayoutAssessmentIndexImport
parentRoute: typeof AssessmentLayoutImport
}
'/_dashboardLayout/aspect/': {
id: '/_dashboardLayout/aspect/'
path: '/aspect'
fullPath: '/aspect'
preLoaderRoute: typeof DashboardLayoutAspectIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/assessmentRequest/': {
id: '/_dashboardLayout/assessmentRequest/'
path: '/assessmentRequest'
fullPath: '/assessmentRequest'
preLoaderRoute: typeof DashboardLayoutAssessmentRequestIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/assessmentRequestManagements/': {
id: '/_dashboardLayout/assessmentRequestManagements/'
path: '/assessmentRequestManagements'
fullPath: '/assessmentRequestManagements'
preLoaderRoute: typeof DashboardLayoutAssessmentRequestManagementsIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/assessmentResult/': {
id: '/_dashboardLayout/assessmentResult/'
path: '/assessmentResult'
fullPath: '/assessmentResult'
preLoaderRoute: typeof DashboardLayoutAssessmentResultIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/assessmentResultsManagement/': {
id: '/_dashboardLayout/assessmentResultsManagement/'
path: '/assessmentResultsManagement'
fullPath: '/assessmentResultsManagement'
preLoaderRoute: typeof DashboardLayoutAssessmentResultsManagementIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/questions/': {
id: '/_dashboardLayout/questions/'
path: '/questions'
fullPath: '/questions'
preLoaderRoute: typeof DashboardLayoutQuestionsIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_dashboardLayout/timetable/': {
@ -118,6 +313,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardLayoutUsersIndexImport
parentRoute: typeof DashboardLayoutImport
}
'/_verifyingLayout/verifying/': {
id: '/_verifyingLayout/verifying/'
path: '/verifying'
fullPath: '/verifying'
preLoaderRoute: typeof VerifyingLayoutVerifyingIndexImport
parentRoute: typeof VerifyingLayoutImport
}
}
}
@ -125,13 +327,27 @@ declare module '@tanstack/react-router' {
export const routeTree = rootRoute.addChildren({
IndexLazyRoute,
AssessmentLayoutRoute: AssessmentLayoutRoute.addChildren({
AssessmentLayoutAssessmentIndexRoute,
}),
DashboardLayoutRoute: DashboardLayoutRoute.addChildren({
DashboardLayoutDashboardIndexRoute,
DashboardLayoutAspectIndexRoute,
DashboardLayoutAssessmentRequestIndexRoute,
DashboardLayoutAssessmentRequestManagementsIndexRoute,
DashboardLayoutAssessmentResultIndexRoute,
DashboardLayoutAssessmentResultsManagementIndexRoute,
DashboardLayoutQuestionsIndexRoute,
DashboardLayoutTimetableIndexRoute,
DashboardLayoutUsersIndexRoute,
}),
LoginIndexLazyRoute,
VerifyingLayoutRoute: VerifyingLayoutRoute.addChildren({
VerifyingLayoutVerifyingIndexRoute,
}),
ForgotPasswordVerifyLazyRoute,
LoginIndexRoute,
ForgotPasswordIndexLazyRoute,
LogoutIndexLazyRoute,
RegisterIndexLazyRoute,
})
/* prettier-ignore-end */
@ -143,30 +359,85 @@ export const routeTree = rootRoute.addChildren({
"filePath": "__root.tsx",
"children": [
"/",
"/_assessmentLayout",
"/_dashboardLayout",
"/_verifyingLayout",
"/forgot-password/verify",
"/login/",
"/logout/"
"/forgot-password/",
"/logout/",
"/register/"
]
},
"/": {
"filePath": "index.lazy.tsx"
},
"/_assessmentLayout": {
"filePath": "_assessmentLayout.tsx",
"children": [
"/_assessmentLayout/assessment/"
]
},
"/_dashboardLayout": {
"filePath": "_dashboardLayout.tsx",
"children": [
"/_dashboardLayout/dashboard/",
"/_dashboardLayout/aspect/",
"/_dashboardLayout/assessmentRequest/",
"/_dashboardLayout/assessmentRequestManagements/",
"/_dashboardLayout/assessmentResult/",
"/_dashboardLayout/assessmentResultsManagement/",
"/_dashboardLayout/questions/",
"/_dashboardLayout/timetable/",
"/_dashboardLayout/users/"
]
},
"/_verifyingLayout": {
"filePath": "_verifyingLayout.tsx",
"children": [
"/_verifyingLayout/verifying/"
]
},
"/forgot-password/verify": {
"filePath": "forgot-password/verify.lazy.tsx"
},
"/login/": {
"filePath": "login/index.lazy.tsx"
"filePath": "login/index.tsx"
},
"/forgot-password/": {
"filePath": "forgot-password/index.lazy.tsx"
},
"/logout/": {
"filePath": "logout/index.lazy.tsx"
},
"/_dashboardLayout/dashboard/": {
"filePath": "_dashboardLayout/dashboard/index.tsx",
"/register/": {
"filePath": "register/index.lazy.tsx"
},
"/_assessmentLayout/assessment/": {
"filePath": "_assessmentLayout/assessment/index.tsx",
"parent": "/_assessmentLayout"
},
"/_dashboardLayout/aspect/": {
"filePath": "_dashboardLayout/aspect/index.tsx",
"parent": "/_dashboardLayout"
},
"/_dashboardLayout/assessmentRequest/": {
"filePath": "_dashboardLayout/assessmentRequest/index.tsx",
"parent": "/_dashboardLayout"
},
"/_dashboardLayout/assessmentRequestManagements/": {
"filePath": "_dashboardLayout/assessmentRequestManagements/index.tsx",
"parent": "/_dashboardLayout"
},
"/_dashboardLayout/assessmentResult/": {
"filePath": "_dashboardLayout/assessmentResult/index.tsx",
"parent": "/_dashboardLayout"
},
"/_dashboardLayout/assessmentResultsManagement/": {
"filePath": "_dashboardLayout/assessmentResultsManagement/index.tsx",
"parent": "/_dashboardLayout"
},
"/_dashboardLayout/questions/": {
"filePath": "_dashboardLayout/questions/index.tsx",
"parent": "/_dashboardLayout"
},
"/_dashboardLayout/timetable/": {
@ -176,6 +447,10 @@ export const routeTree = rootRoute.addChildren({
"/_dashboardLayout/users/": {
"filePath": "_dashboardLayout/users/index.tsx",
"parent": "/_dashboardLayout"
},
"/_verifyingLayout/verifying/": {
"filePath": "_verifyingLayout/verifying/index.tsx",
"parent": "/_verifyingLayout"
}
}
}

View File

@ -8,9 +8,9 @@ interface RouteContext {
export const Route = createRootRouteWithContext<RouteContext>()({
component: () => (
<div className="font-manrope">
<div className="font-inter">
<Outlet />
<TanStackRouterDevtools />
{/* <TanStackRouterDevtools /> */}
</div>
),
});

View File

@ -0,0 +1,71 @@
import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router";
import AppHeader from "../components/AppHeader";
import AppNavbar from "../components/AppNavbar";
import useAuth from "@/hooks/useAuth";
import { useQuery } from "@tanstack/react-query";
import fetchRPC from "@/utils/fetchRPC";
import client from "@/honoClient";
import { useState } from "react";
export const Route = createFileRoute("/_assessmentLayout")({
component: AssessmentLayout,
// beforeLoad: ({ location }) => {
// if (true) {
// throw redirect({
// to: "/login",
// });
// }
// },
});
function AssessmentLayout() {
const { isAuthenticated, saveAuthData } = useAuth();
useQuery({
queryKey: ["my-profile"],
queryFn: async () => {
const response = await fetchRPC(client.auth["my-profile"].$get());
saveAuthData({
id: response.id,
name: response.name,
permissions: response.permissions,
role: response.roles[0],
});
return response;
},
enabled: isAuthenticated,
});
const [openNavbar, setNavbarOpen] = useState(true);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
const toggle = () => {
setNavbarOpen(!openNavbar);
};
const toggleLeftSidebar = () => {
setIsLeftSidebarOpen(!isLeftSidebarOpen);
};
return isAuthenticated ? (
<div className="flex flex-col w-full h-screen overflow-hidden">
{/* Header */}
<AppHeader toggle={toggle} openNavbar={openNavbar} toggleLeftSidebar={toggleLeftSidebar} />
{/* Main Content Area */}
<div className="flex h-full w-screen overflow-hidden">
{/* Sidebar */}
<AppNavbar />
{/* Main Content */}
<main className="relative w-full mt-16 bg-white overflow-auto">
<Outlet />
</main>
</div>
</div>
) : (
<Navigate to="/login" />
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
import { getQuestionsAllQueryOptions } from "@/modules/assessmentManagement/queries/assessmentQueries.ts";
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("/_assessmentLayout/assessment/")({
validateSearch: searchParamSchema,
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(getQuestionsAllQueryOptions(0, 10));
},
});

View File

@ -1,68 +1,71 @@
import { AppShell } from "@mantine/core";
import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router";
import { useDisclosure } from "@mantine/hooks";
import AppHeader from "../components/AppHeader";
import AppNavbar from "../components/AppNavbar";
import useAuth from "@/hooks/useAuth";
import { useQuery } from "@tanstack/react-query";
import fetchRPC from "@/utils/fetchRPC";
import client from "@/honoClient";
import { useState } from "react";
export const Route = createFileRoute("/_dashboardLayout")({
component: DashboardLayout,
component: DashboardLayout,
// beforeLoad: ({ location }) => {
// if (true) {
// throw redirect({
// to: "/login",
// });
// }
// },
// beforeLoad: ({ location }) => {
// if (true) {
// throw redirect({
// to: "/login",
// });
// }
// },
});
function DashboardLayout() {
const { isAuthenticated, saveAuthData } = useAuth();
const { isAuthenticated, saveAuthData } = useAuth();
useQuery({
queryKey: ["my-profile"],
queryFn: async () => {
const response = await fetchRPC(client.auth["my-profile"].$get());
useQuery({
queryKey: ["my-profile"],
queryFn: async () => {
const response = await fetchRPC(client.auth["my-profile"].$get());
saveAuthData({
id: response.id,
name: response.name,
permissions: response.permissions,
});
saveAuthData({
id: response.id,
name: response.name,
permissions: response.permissions,
role: response.roles[0],
});
return response;
},
enabled: isAuthenticated,
});
return response;
},
enabled: isAuthenticated,
});
const [openNavbar, { toggle }] = useDisclosure(false);
const [openNavbar, setNavbarOpen] = useState(true);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
const toggle = () => {
setNavbarOpen(!openNavbar);
};
return isAuthenticated ? (
<AppShell
padding="md"
header={{ height: 70 }}
navbar={{
width: 300,
breakpoint: "sm",
collapsed: { mobile: !openNavbar },
}}
>
<AppHeader openNavbar={openNavbar} toggle={toggle} />
const toggleLeftSidebar = () => {
setIsLeftSidebarOpen(!isLeftSidebarOpen);
};
<AppNavbar />
return isAuthenticated ? (
<div className="flex flex-col w-full h-screen overflow-hidden">
{/* Header */}
<AppHeader toggle={toggle} openNavbar={openNavbar} toggleLeftSidebar={toggleLeftSidebar} />
<AppShell.Main
className="bg-slate-100"
styles={{ main: { backgroundColor: "rgb(241 245 249)" } }}
>
<Outlet />
</AppShell.Main>
</AppShell>
) : (
<Navigate to="/login" />
);
{/* Main Content Area */}
<div className="flex h-full w-screen overflow-hidden">
{/* Sidebar */}
<AppNavbar />
{/* Main Content */}
<main className="relative w-full mt-16 p-6 bg-white overflow-auto">
<Outlet />
</main>
</div>
</div>
) : (
<Navigate to="/login" />
);
}

View File

@ -0,0 +1,99 @@
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";
import { Button } from "@/shadcn/components/ui/button";
export const Route = createLazyFileRoute("/_dashboardLayout/aspect/")({
component: AspectPage,
});
type DataType = ExtractQueryDataType<typeof aspectQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
export default function AspectPage() {
// Function to update URL query string without refreshing
const updateQueryString = (key: string, value: string) => {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set(key, value);
window.history.pushState({}, "", `?${searchParams.toString()}`);
};
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) => (
<div className="flex flex-row w-fit items-center rounded gap-2">
<Button
variant="ghost"
className="w-fit items-center hover:bg-gray-300 border"
onClick={() => updateQueryString("detail", props.row.original.id)}
>
<TbEye className="text-black" />
</Button>
<Button
variant="ghost"
className="w-fit items-center hover:bg-gray-300 border"
onClick={() => updateQueryString("edit", props.row.original.id)}
>
<TbPencil className="text-black" />
</Button>
<Button
variant="ghost"
className="w-fit items-center hover:bg-gray-300 border"
onClick={() => updateQueryString("delete", props.row.original.id)}
>
<TbTrash className="text-black" />
</Button>
</div>
),
}),
]}
/>
);
}

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,205 @@
import { useState } from "react";
import { assessmentRequestQueryOptions, updateAssessmentRequest } from "@/modules/assessmentRequest/queries/assessmentRequestQueries";
import PageTemplate from "@/components/PageTemplate";
import { createLazyFileRoute } from "@tanstack/react-router";
import FormModal from "@/modules/assessmentRequest/modals/CreateAssessmentRequestModal";
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
import { createColumnHelper } from "@tanstack/react-table";
import { Badge } from "@/shadcn/components/ui/badge";
import { Button } from "@/shadcn/components/ui/button";
import StartAssessmentModal from "@/modules/assessmentRequest/modals/ConfirmModal";
import { TbListCheck, TbClipboardText, TbChevronDown, TbChevronRight } from "react-icons/tb";
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/shadcn/components/ui/dropdown-menu";
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 start assessment and redirect to the assessment page in new tab
* This function will update the status of the assessment to "dalam pengerjaan"
* and then open the assessment page in a new tab
* @param {string} assessmentId ID of the assessment to be started
*/
const handleStartAssessment = async (assessmentId: string) => {
try {
await updateAssessmentRequest({
assessmentId,
status: "dalam pengerjaan",
});
// Redirect to the assessment page
const assessmentUrl = `/assessment?id=${assessmentId}`;
window.open(assessmentUrl, "_blank");
} catch (error) {
console.error("Gagal memulai asesmen:", error);
} finally {
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?id=${assessmentId}`;
window.open(resultUrl, "_blank");
};
return (
<>
<PageTemplate
title="Permohonan Asesmen"
queryOptions={assessmentRequestQueryOptions}
modals={[<FormModal />]}
columnDefs={[
columnHelper.display({
header: "No",
cell: (props) => (
<div className="ml-1">
{props.row.index + 1}
</div>
),
}),
columnHelper.display({
header: "Tanggal",
cell: (props) =>
props.row.original.tanggal
? new Intl.DateTimeFormat("id-ID", {
year: "numeric",
month: "long",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true,
})
.format(new Date(props.row.original.tanggal))
.replace(/\./g, ':')
.replace('pukul ', '')
: 'N/A',
}),
columnHelper.display({
header: "Status",
cell: (props) => {
const status = props.row.original.status;
switch (status) {
case "dalam pengerjaan":
return <div className="flex items-center justify-center">
<Badge variant={"inprogress"}>Dalam Pengerjaan</Badge>
</div>;
case "menunggu konfirmasi":
return <div className="flex items-center justify-center">
<Badge variant={"waiting"}>Menunggu Konfirmasi</Badge>
</div>;
case "diterima":
return <div className="flex items-center justify-center">
<Badge variant={"accepted"}>Diterima</Badge>
</div>;
case "ditolak":
return <div className="flex items-center justify-center">
<Badge variant={"rejected"}>Ditolak</Badge>
</div>;
case "belum diverifikasi":
return <div className="flex items-center justify-center">
<Badge variant={"unverified"}>Belum Diverifikasi</Badge>
</div>;
case "selesai":
return <div className="flex items-center justify-center">
<Badge variant={"completed"}>Selesai</Badge>
</div>;
default:
return <div className="flex items-center justify-center">
<Badge variant={"outline"}>Tidak diketahui</Badge>
</div>;
}
},
}),
columnHelper.display({
header: "Aksi",
cell: (props) => {
const status = props.row.original.status;
const assessmentId = props.row.original.assessmentId; // Retrieve the assessmentId from the data row
const [isOpen, setIsOpen] = useState(false);
return (
<div className="flex items-center justify-center">
<DropdownMenu onOpenChange={(open) => setIsOpen(open)}>
<DropdownMenuTrigger asChild>
<Button variant="secondary" className="pl-4 pr-2 rounded-lg bg-[--primary-color] text-white hover:bg-[--hover-primary-color] hover:text-white">
Pilihan
{isOpen ? <TbChevronDown className="ml-10" /> : <TbChevronRight className="ml-10" />}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
<DropdownMenuItem
onClick={() => {
if (status === "diterima") {
handleOpenModal(assessmentId ?? '');
} else if (status === "dalam pengerjaan") {
const newUrl = `/assessment?id=${assessmentId}`;
window.open(newUrl, "_blank");
}
}}
disabled={!(status === "diterima" || status === "dalam pengerjaan")}
>
<TbClipboardText className="mr-2" />
Mulai Asesmen
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => status === "selesai" || status === "belum diverifikasi" ? handleViewResult(assessmentId ?? '') : null}
disabled={!(status === "selesai" || status === "belum diverifikasi")}
>
<TbListCheck className="mr-2" />
Lihat Hasil
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</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/assessmentRequest/queries/assessmentRequestQueries"
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchParamSchema = z.object({
create: z.boolean().default(false).optional(),
edit: z.string().default("").optional(),
delete: z.string().default("").optional(),
detail: z.string().default("").optional(),
});
export const Route = createFileRoute("/_dashboardLayout/assessmentRequest/")({
validateSearch: searchParamSchema,
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(assessmentRequestQueryOptions(0, 10));
},
});

View File

@ -0,0 +1,130 @@
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 { Flex } from "@mantine/core";
import { Badge } from "@/shadcn/components/ui/badge";
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)
.replace(/\./g, ':')
.replace('pukul ', '');
};
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) => (
<div className="flex items-center justify-center text-center">
{(() => {
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 "dalam pengerjaan":
return <Badge variant={"inprogress"}>Dalam Pengerjaan</Badge>;
case "ditolak":
return <Badge variant={"rejected"}>Ditolak</Badge>;
default:
return <Badge variant={"outline"}>Tidak diketahui</Badge>;
}
})()}
</div>
),
}),
columnHelper.display({
header: "Aksi",
cell: (props) => (
<Flex gap="xs">
{createActionButtons([
{
label: "Detail",
permission: true,
action: () => handleDetailClick(props.row.original.idPermohonan),
color: "black",
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,831 @@
import { useEffect, useState } from "react";
import html2pdf from "html2pdf.js";
import useAuth from "@/hooks/useAuth";
import { createLazyFileRoute } from "@tanstack/react-router";
import { getAllAspectsAverageScore, getAllSubAspectsAverageScore, getAllVerifiedAspectsAverageScore, getAllVerifiedSubAspectsAverageScore } from "@/modules/assessmentResult/queries/assessmentResultQueries";
import { useQuery } from "@tanstack/react-query";
import { getAssessmentResultByIdQueryOptions, getVerifiedAssessmentResultByIdQueryOptions } from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
import { PieChart, Pie, Label, BarChart, Bar, CartesianGrid, XAxis, YAxis } from "recharts";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/shadcn/components/ui/card"
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/shadcn/components/ui/chart"
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
import { TbChevronDown, TbChevronLeft, TbChevronRight, TbChevronUp, TbFileTypePdf } from "react-icons/tb";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shadcn/components/ui/dropdown-menu";
import clsx from "clsx";
import React from "react";
import AppHeader from "@/components/AppHeader";
import { LeftSheet, LeftSheetContent } from "@/shadcn/components/ui/leftsheet";
import { ScrollArea } from "@mantine/core";
import data from "node_modules/backend/src/appEnv";
const getQueryParam = (param: string) => {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
};
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentResult/")({
component: AssessmentResultPage,
});
export default function AssessmentResultPage() {
const { user } = useAuth();
const isSuperAdmin = user?.role === "super-admin";
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); // Check for mobile screen
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [openNavbar, setOpenNavbar] = useState(false);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
const toggle = () => {
setOpenNavbar((prevState) => !prevState);
};
// Adjust layout on screen resize
window.addEventListener('resize', () => {
setIsMobile(window.innerWidth <= 768);
});
const toggleLeftSidebar = () => setIsLeftSidebarOpen(!isLeftSidebarOpen);
const [assessmentId, setAssessmentId] = useState<string | undefined>(undefined);
useEffect(() => {
const id = getQueryParam("id");
setAssessmentId(id ?? undefined);
}, []);
//fetch data from API
const { data: aspectsData } = useQuery(aspectQueryOptions(0, 10));
const { data: assessmentResult } = useQuery(getAssessmentResultByIdQueryOptions(assessmentId));
const { data: verifiedAssessmentResult } = useQuery(getVerifiedAssessmentResultByIdQueryOptions(assessmentId));
const { data: allAspectsScoreData } = useQuery(getAllAspectsAverageScore(assessmentId));
const { data: allSubAspectsScoreData } = useQuery(getAllSubAspectsAverageScore(assessmentId));
const { data: allVerifiedAspectsScoreData } = useQuery(getAllVerifiedAspectsAverageScore(assessmentId));
const { data: allVerifiedSubAspectsScoreData } = useQuery(getAllVerifiedSubAspectsAverageScore(assessmentId));
// Pastikan status tersedia
const assessmentStatus = assessmentResult?.statusAssessment;
const getAspectScore = (aspectId: string) => {
return allAspectsScoreData?.aspects?.find((score) => score.aspectId === aspectId)?.averageScore || undefined;
};
const getSubAspectScore = (subAspectId: string) => {
return allSubAspectsScoreData?.subAspects?.find((score) => score.subAspectId === subAspectId)?.averageScore || undefined;
};
const getVerifiedAspectScore = (aspectId: string, assessmentStatus: string) => {
if (assessmentStatus !== "selesai") return undefined;
return allVerifiedAspectsScoreData?.aspects?.find((score) => score.aspectId === aspectId)?.averageScore || undefined;
};
const getVerifiedSubAspectScore = (subAspectId: string, assessmentStatus: string) => {
if (assessmentStatus !== "selesai") return undefined;
return allVerifiedSubAspectsScoreData?.subAspects?.find((score) => score.subAspectId === subAspectId)?.averageScore || undefined;
};
const formatScore = (score: string | number | undefined) => {
if (score === null || score === undefined) return '0';
const parsedScore = typeof score === 'number' ? score : parseFloat(score || "NaN");
return !isNaN(parsedScore) ? parsedScore.toFixed(2) : '0';
};
// Total score
const totalScore = parseFloat(formatScore(assessmentResult?.assessmentsResult));
const totalVerifiedScore =
assessmentStatus === "selesai"
? parseFloat(formatScore(verifiedAssessmentResult?.verifiedAssessmentsResult))
: 0;
// Mengatur warna dan level maturitas berdasarkan skor
const getScoreStyleClass = (score: number | undefined, isBg: boolean = false) => {
if (score === undefined || score === null) return { color: 'grey' };
let colorVar = '--levelOne-color';
let descLevel = '1';
if (score >= 1.50 && score < 2.50) {
colorVar = '--levelTwo-color';
descLevel = '2';
} else if (score >= 2.50 && score < 3.50) {
colorVar = '--levelThree-color';
descLevel = '3';
} else if (score >= 3.50 && score < 4.50) {
colorVar = '--levelFour-color';
descLevel = '4';
} else if (score >= 4.50 && score <= 5) {
colorVar = '--levelFive-color';
descLevel = '5';
}
return isBg
? { backgroundColor: `var(${colorVar})`, descLevel }
: { color: `var(${colorVar})`, descLevel };
};
// Warna aspek
const aspectsColors = [
"#37DCCC",
"#FF8C8C",
"#51D0FD",
"#FEA350",
"#AD8AFC",
];
// Data diagram
const chartData = aspectsData?.data?.map((aspect, index) => ({
aspectName: aspect.name,
score: Number(formatScore(getAspectScore(aspect.id))),
fill: aspectsColors[index % aspectsColors.length],
})) || [];
const verifiedChartData =
assessmentStatus === "selesai"
? aspectsData?.data?.map((aspect, index) => ({
aspectName: aspect.name,
score: Number(formatScore(getVerifiedAspectScore(aspect.id, assessmentStatus))),
fill: aspectsColors[index % aspectsColors.length],
})) || []
: [];
const barChartData = aspectsData?.data?.flatMap((aspect) =>
aspect.subAspects.map((subAspect) => ({
subAspectName: subAspect.name,
score: Number(formatScore(getSubAspectScore(subAspect.id))),
fill: "#005BFF",
aspectId: aspect.id,
aspectName: aspect.name
}))
) || [];
const verifiedBarChartData =
assessmentStatus === "selesai"
? aspectsData?.data?.flatMap((aspect) =>
aspect.subAspects.map((subAspect) => ({
subAspectName: subAspect.name,
score: Number(formatScore(getVerifiedSubAspectScore(subAspect.id, assessmentStatus))),
fill: "#005BFF",
aspectId: aspect.id,
aspectName: aspect.name,
}))
) || []
: [];
const sortedBarChartData = barChartData.sort((a, b) => (a.aspectId ?? '').localeCompare(b.aspectId ?? ''));
const sortedVerifiedBarChartData = verifiedBarChartData.sort((a, b) => (a.aspectId ?? '').localeCompare(b.aspectId ?? ''));
const chartConfig =
assessmentStatus === "selesai"
? aspectsData?.data?.reduce((config, aspect, index) => {
config[aspect.name.toLowerCase()] = {
label: aspect.name,
color: aspectsColors[index % aspectsColors.length],
};
return config;
}, {} as ChartConfig) || {}
: {};
const barChartConfig =
assessmentStatus === "selesai"
? aspectsData?.data?.reduce((config, aspect, index) => {
aspect.subAspects.forEach((subAspect) => {
config[subAspect.name.toLowerCase()] = {
label: subAspect.name,
color: aspectsColors[index % aspectsColors.length],
};
});
return config;
}, {} as ChartConfig) || {}
: {};
// Dropdown State
const [isOpen, setIsOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState('Hasil Asesmen');
const handleDropdownToggle = () => {
setIsOpen((prev) => !prev);
};
const handleItemClick = () => {
setSelectedItem(prev =>
prev === 'Hasil Asesmen' ? 'Hasil Terverifikasi' : 'Hasil Asesmen'
);
setIsOpen(false);
};
// Pie Chart Component
function PieChartComponent({ chartData, totalScore, chartConfig }: { chartData: { aspectName: string, score: number, fill: string }[], totalScore: number, chartConfig: ChartConfig }) {
return (
<div className="flex flex-col w-full border-none">
<div className="flex-1 pb-0 w-72">
<ChartContainer
config={chartConfig}
className="-ml-6 -mb-6 lg:mb-6 aspect-square max-h-60 lg:max-h-64"
>
<PieChart>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={chartData}
dataKey="score"
nameKey="aspectName"
innerRadius={50}
strokeWidth={5}
label={({ cx, cy, midAngle, innerRadius, outerRadius, index }) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * (Math.PI / 180));
const y = cy + radius * Math.sin(-midAngle * (Math.PI / 180));
return (
<text
x={x}
y={y}
fill="black"
textAnchor="middle"
dominantBaseline="middle"
className="text-xs lg:text-md"
>
{chartData[index]?.score || ""}
</text>
);
}}
labelLine={false}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-2xl lg:text-3xl font-bold"
>
{totalScore.toLocaleString()}
</tspan>
</text>
);
}
}}
/>
</Pie>
</PieChart>
</ChartContainer>
</div>
{/* Legend */}
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-x-4 text-xs justify-center gap-2 lg:gap-0">
<div className="flex flex-col gap-2">
{chartData.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<span
className="pl-4 w-4 h-4"
style={{ backgroundColor: entry.fill }}
/>
<span className="font-medium whitespace-nowrap">{entry.aspectName}</span>
</div>
))}
</div>
</div>
</div>
);
}
// Mengatur tampilan label sumbu X
const customizedAxisTick = (props: any) => {
const { x, y, payload } = props;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={3}
textAnchor="end"
fill="#666"
transform="rotate(-90)"
className="lg:text-[0.6rem] text-[0.3rem]"
>
{payload.value.slice(0, 3)}
</text>
</g>
);
};
// Bar Chart Component
function BarChartComponent({ barChartData, barChartConfig }: { barChartData: { subAspectName: string, score: number, fill: string, aspectId: string, aspectName: string }[], barChartConfig: ChartConfig }) {
return (
<div className="w-full h-full">
<ChartContainer config={barChartConfig}>
<BarChart accessibilityLayer data={barChartData} margin={{ top: 5, left: -40 }}>
<CartesianGrid vertical={false} horizontal={true} />
<XAxis
dataKey="subAspectName"
tickLine={false}
tickMargin={0}
axisLine={false}
interval={0}
tick={customizedAxisTick}
/>
<YAxis
domain={[0, 5]}
tickCount={6}
/>
<ChartTooltip
cursor={false}
content={({ active, payload }) => {
if (active && payload && payload.length > 0) {
const { subAspectName, score } = payload[0].payload;
return (
<div className="tooltip bg-white p-1 rounded-md shadow-lg">
<p>{`${subAspectName} : ${score}`}</p>
</div>
);
}
return null;
}}
/>
<Bar dataKey="score" radius={2} fill="#007BFF" />
</BarChart>
</ChartContainer>
</div>
);
}
const handlePrintPDF = async () => {
const pdfContainer = document.getElementById("pdfContainer");
if (pdfContainer) {
// Sembunyikan elemen yang tidak ingin dicetak
const buttonPrint = document.getElementById("button-print");
const noPrint = document.getElementById("no-print");
if (buttonPrint) buttonPrint.style.visibility = 'hidden';
if (noPrint) noPrint.style.visibility = 'hidden';
const options = {
margin: [10, 10, 10, -220],
image: { type: 'jpeg', quality: 0.98 },
html2canvas: {
scale: 2,
width: 1510,
height: pdfContainer.scrollHeight,
ignoreElements: (element: { tagName: string; }) => {
// Abaikan elemen <header> dalam pdfContainer
return element.tagName === 'HEADER';
},
},
jsPDF: {
unit: 'pt',
format: 'a4',
orientation: 'portrait',
}
};
try {
const pdfBlob: Blob = await html2pdf()
.set(options)
.from(pdfContainer)
.toPdf()
.get('pdf')
.then((pdf: any) => {
pdf.setProperties({
title: 'Hasil_Asesemen_Level_Maturitas',
});
return pdf.output('blob');
});
const pdfURL = URL.createObjectURL(pdfBlob);
window.open(pdfURL, '_blank');
} catch (err) {
console.error("Error generating PDF:", err);
} finally {
// Tampilkan kembali elemen yang disembunyikan
if (buttonPrint) buttonPrint.style.visibility = 'visible';
if (noPrint) noPrint.style.visibility = 'visible';
}
}
};
return (
<Card className="flex flex-row w-full h-full border-none shadow-none" id="pdfContainer">
<AppHeader
openNavbar={isLeftSidebarOpen}
toggle={toggleLeftSidebar}
toggleLeftSidebar={toggleLeftSidebar}
/>
{isMobile && (
<LeftSheet open={isLeftSidebarOpen} onOpenChange={(open) => setIsLeftSidebarOpen(open)}>
<LeftSheetContent className="h-full w-75 overflow-auto">
<ScrollArea className="h-full w-75 rounded-md p-2">
<Label className="text-gray-800 p-5 text-sm font-normal">Tingkatan Level Maturitas</Label>
<div className="flex flex-col w-64 h-fit border-none shadow-none -mt-6 pt-6">
<div className="flex flex-col mr-2 h-full">
<p className="font-bold mb-4 text-lg">Tingkatan Level Maturitas</p>
{[
{ level: 5, colorVar: '--levelFive-color', title: 'Implementasi Optimal', details: ['Otomatisasi', 'Terintegrasi', 'Membudaya'], textColor: 'white' },
{ level: 4, colorVar: '--levelFour-color', title: 'Implementasi Terkelola', details: ['Terorganisir', 'Review Berkala', 'Berkelanjutan'], textColor: 'white' },
{ level: 3, colorVar: '--levelThree-color', title: 'Implementasi Terdefinisi', details: ['Terorganisir', 'Konsisten', 'Review Berkala'], textColor: 'white' },
{ level: 2, colorVar: '--levelTwo-color', title: 'Implementasi Berulang', details: ['Terorganisir', 'Tidak Konsisten', 'Berulang'], textColor: 'white' },
{ level: 1, colorVar: '--levelOne-color', title: 'Implementasi Awal', details: ['Tidak Terukur', 'Tidak Konsisten', 'Risiko Tinggi'], textColor: 'white' }
].map(({ level, colorVar, title, details }) => (
<div key={level} className="flex flex-row h-full border-none">
<div
className="w-20 h-20 text-white font-medium text-lg flex justify-center items-center"
style={{ background: `var(${colorVar})` }}
>
<p className="text-center">Level {level}</p>
</div>
<div className="flex flex-col items-start justify-center p-2">
<p className="text-xs font-bold whitespace-nowrap">{title}</p>
{details.map((detail) => (
<p key={detail} className="text-xs">{detail}</p>
))}
</div>
</div>
))}
</div>
{/* Total verified score */}
<div className="pt-14">
{selectedItem === 'Hasil Asesmen' ? (
<>
<div className="flex flex-row h-16 border-t font-semibold justify-between items-center gap-2 pl-8 pr-6 -ml-6">
<p className="text-lg">Nilai Maturitas</p>
<span className="text-xl">{totalScore}</span>
</div>
<div className="flex flex-row h-16 font-semibold justify-between items-center gap-2 pl-8 pr-6 text-white -ml-6" style={getScoreStyleClass(Number(totalScore), true)}>
<p className="text-lg">Level Maturitas</p>
<span className="ml-2 text-xl">{getScoreStyleClass(Number(totalScore), true).descLevel}</span>
</div>
</>
) : (
<>
<div className="flex flex-row h-16 border-t font-semibold justify-between items-center gap-2 pl-8 pr-6 -ml-6">
<p className="text-lg">Nilai Maturitas</p>
<span className="text-xl">{totalVerifiedScore}</span>
</div>
<div className="flex flex-row h-16 font-semibold justify-between items-center gap-2 pl-8 pr-6 text-white -ml-6" style={getScoreStyleClass(Number(totalVerifiedScore), true)}>
<p className="text-lg">Level Maturitas</p>
<span className="ml-2 text-xl">{getScoreStyleClass(Number(totalVerifiedScore), true).descLevel}</span>
</div>
</>
)}
</div>
</div>
</ScrollArea>
</LeftSheetContent>
</LeftSheet>
)}
<div className="hidden md:block lg:flex flex-col w-fit h-fit border-r shadow-none -mt-6 pt-6">
<div className="flex flex-col mr-2 h-full">
<p className="font-bold mb-4 text-lg">Tingkatan Level Maturitas</p>
{[
{ level: 5, colorVar: '--levelFive-color', title: 'Implementasi Optimal', details: ['Otomatisasi', 'Terintegrasi', 'Membudaya'], textColor: 'white' },
{ level: 4, colorVar: '--levelFour-color', title: 'Implementasi Terkelola', details: ['Terorganisir', 'Review Berkala', 'Berkelanjutan'], textColor: 'white' },
{ level: 3, colorVar: '--levelThree-color', title: 'Implementasi Terdefinisi', details: ['Terorganisir', 'Konsisten', 'Review Berkala'], textColor: 'white' },
{ level: 2, colorVar: '--levelTwo-color', title: 'Implementasi Berulang', details: ['Terorganisir', 'Tidak Konsisten', 'Berulang'], textColor: 'white' },
{ level: 1, colorVar: '--levelOne-color', title: 'Implementasi Awal', details: ['Tidak Terukur', 'Tidak Konsisten', 'Risiko Tinggi'], textColor: 'white' }
].map(({ level, colorVar, title, details }) => (
<div key={level} className="flex flex-row h-full border-none">
<div
className="w-20 h-20 text-white font-medium text-lg flex justify-center items-center"
style={{ background: `var(${colorVar})` }}
>
<p className="text-center">Level {level}</p>
</div>
<div className="flex flex-col items-start justify-center p-2">
<p className="text-xs font-bold whitespace-nowrap">{title}</p>
{details.map((detail) => (
<p key={detail} className="text-xs">{detail}</p>
))}
</div>
</div>
))}
</div>
{/* Total verified score */}
<div className="pt-14">
{selectedItem === 'Hasil Asesmen' ? (
<>
<div className="flex flex-row h-16 border-t font-semibold justify-between items-center gap-2 pl-8 pr-6 -ml-6">
<p className="text-lg">Nilai Maturitas</p>
<span className="text-xl">{totalScore}</span>
</div>
<div className="flex flex-row h-16 font-semibold justify-between items-center gap-2 pl-8 pr-6 text-white -ml-6" style={getScoreStyleClass(Number(totalScore), true)}>
<p className="text-lg">Level Maturitas</p>
<span className="ml-2 text-xl">{getScoreStyleClass(Number(totalScore), true).descLevel}</span>
</div>
</>
) : (
<>
<div className="flex flex-row h-16 border-t font-semibold justify-between items-center gap-2 pl-8 pr-6 -ml-6">
<p className="text-lg">Nilai Maturitas</p>
<span className="text-xl">{totalVerifiedScore}</span>
</div>
<div className="flex flex-row h-16 font-semibold justify-between items-center gap-2 pl-8 pr-6 text-white -ml-6" style={getScoreStyleClass(Number(totalVerifiedScore), true)}>
<p className="text-lg">Level Maturitas</p>
<span className="ml-2 text-xl">{getScoreStyleClass(Number(totalVerifiedScore), true).descLevel}</span>
</div>
</>
)}
</div>
</div>
<Card className="flex flex-col w-screen h-fit border-none shadow-none -mt-6 lg:-mr-6 lg:-ml-0 -mx-3 lg:bg-stone-50 md:bg-white overflow-hidden">
<div className="flex flex-col w-full h-fit mb-4 justify-center items-start bg-white p-4 border-b">
{/* Konten Header */}
<div className="flex flex-col lg:flex-row justify-between items-center w-full">
{isSuperAdmin ? (
<div className="flex flex-col">
<button
className="flex items-center text-sm text-blue-600 gap-2 mb-2" id="no-print"
onClick={() => window.close()}
>
<TbChevronLeft size={20} className="mr-1" />
Kembali
</button>
<p className="text-md lg:text-2xl font-bold">Detail Hasil Asesmen</p>
<p className="text-xs text-muted-foreground">Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah</p>
</div>
) : (
<div className="flex flex-col">
<p className="text-md lg:text-2xl font-bold">Dasboard Hasil Tingkat Kematangan Keamanan Cyber</p>
<p className="text-xs text-muted-foreground">Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah</p>
</div>
)}
<div className="flex flex-row gap-2 mt-4 lg:mt-0" id="button-print">
<div className="flex">
<DropdownMenu>
<DropdownMenuTrigger
className="text-black flex w-44 p-2 pl-4 rounded-lg text-sm items-start justify-between border outline-none ring-offset-0"
onClick={handleDropdownToggle}
>
{selectedItem}
{isOpen ? (
<TbChevronUp size={20} className="justify-center items-center" />
) : (
<TbChevronDown size={20} className="justify-center items-center" />
)}
</DropdownMenuTrigger>
{isOpen && (
<DropdownMenuContent className="bg-white text-black flex w-44 rounded-sm text-xs lg:text-sm items-start">
<DropdownMenuItem className="w-full" onClick={handleItemClick}>
{selectedItem === 'Hasil Asesmen' ? 'Hasil Terverifikasi' : 'Hasil Asesmen'}
</DropdownMenuItem>
</DropdownMenuContent>
)}
</DropdownMenu>
</div>
<button
onClick={() => handlePrintPDF()}
className="bg-blue-600 text-white flex w-fit py-2 px-4 rounded-lg text-xs lg:text-sm items-start justify-between outline-none ring-offset-0"
>
Cetak PDF
<TbFileTypePdf size={20} className="ml-2" />
</button>
</div>
</div>
</div>
{isSuperAdmin &&
<Card className="flex flex-col h-full mb-6 justify-center items-start mx-4">
<div className="flex lg:flex-row flex-col text-xs h-full w-full justify-between p-6 gap-4 lg:gap-0">
<div className="flex flex-col gap-4">
<div>
<p className="text-muted-foreground">Nama Responden</p>
<p>{assessmentResult?.respondentName}</p>
</div>
<div>
<p className="text-muted-foreground">Posisi</p>
<p>{assessmentResult?.position}</p>
</div>
<div>
<p className="text-muted-foreground">Nama Pengguna</p>
<p>{assessmentResult?.username}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<p className="text-muted-foreground">Nama Perusahaan</p>
<p>{assessmentResult?.companyName}</p>
</div>
<div>
<p className="text-muted-foreground">Pengalaman Kerja</p>
<p>{assessmentResult?.workExperience}</p>
</div>
<div>
<p className="text-muted-foreground">Email</p>
<p>{assessmentResult?.email}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<p className="text-muted-foreground">No. HP</p>
<p>{assessmentResult?.phoneNumber}</p>
</div>
<div>
<p className="text-muted-foreground">Alamat</p>
<p>{assessmentResult?.address}</p>
</div>
<div>
<p className="text-muted-foreground">Tanggal Asesmen</p>
<p>
{assessmentResult?.assessmentDate ? (
new Intl.DateTimeFormat("id-ID", {
year: "numeric",
month: "long",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: true,
})
.format(new Date(assessmentResult.assessmentDate))
.replace(/\./g, ':')
.replace('pukul ', '')
) : (
'N/A'
)}
</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div>
<p className="text-muted-foreground">Status Asesmen</p>
<p>{assessmentResult?.statusAssessment}</p>
</div>
</div>
</div>
</Card>
}
{/* Conditional rendering based on selectedItem */}
{selectedItem === 'Hasil Asesmen' ? (
<>
{/* Score Table */}
<Card className="flex flex-col h-fit my-2 mb-6 overflow-hidden lg:mx-4">
<p className="text-sm lg:text-lg font-bold p-2 border-b">Tabel Nilai Hasil Asesmen</p>
<table className="w-full table-fixed border-collapse border rounded-lg overflow-hidden">
<thead>
<tr>
{aspectsData?.data?.map((aspect) => (
<th
key={aspect.id}
colSpan={2}
className="text-start font-normal bg-white border border-gray-200 p-2 lg:p-4 w-1/5 whitespace-nowrap"
>
<div className="flex flex-col items-start">
<p className="text-[0.6rem] lg:text-sm text-black">{aspect.name}</p>
<span
className={clsx(
"text-sm lg:text-3xl font-bold",
)}
style={getScoreStyleClass(getAspectScore(aspect.id))}
>
{formatScore(getAspectScore(aspect.id))}
</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{aspectsData && Array.from({ length: Math.max(...aspectsData.data.map(aspect => aspect.subAspects.length)) }).map((_, rowIndex) => (
<tr key={rowIndex} className={rowIndex % 2 === 0 ? "bg-slate-100" : "bg-white"}>
{aspectsData?.data?.map((aspect) => (
<React.Fragment key={aspect.id}>
{/* Sub-aspect Name Column (No Right Border) */}
<td className="text-[0.33rem] md:text-[0.5rem] lg:text-xs text-black p-1 py-0 lg:py-2 border-t border-l border-b border-gray-200 w-full text-left">
{aspect.subAspects[rowIndex]?.name || ""}
</td>
{/* Sub-aspect Score Column (No Left Border and w-fit for flexible width) */}
<td className="text-[0.4rem] lg:text-sm font-bold text-right p-1 lg:p-2 border-t border-b border-r border-gray-200 w-fit">
{aspect.subAspects[rowIndex] ? formatScore(getSubAspectScore(aspect.subAspects[rowIndex].id)) : ""}
</td>
</React.Fragment>
))}
</tr>
))}
</tbody>
</table>
</Card>
</>
) : (
<>
{/* Verified Result Table */}
<Card className="flex flex-col h-fit my-2 mb-6 overflow-hidden border-y lg:mx-4">
<p className="text-sm lg:text-lg font-bold p-2 border-b">Tabel Nilai Hasil Asesmen Terverifikasi</p>
<table className="w-full table-fixed border-collapse border rounded-lg overflow-hidden">
<thead>
<tr>
{aspectsData?.data?.map((aspect) => (
<th
key={aspect.id}
colSpan={2}
className="text-start font-normal bg-white border border-gray-200 p-2 lg:p-4 w-1/5 whitespace-nowrap"
>
<div className="flex flex-col items-start">
<p className="text-[0.6rem] lg:text-sm text-black">{aspect.name}</p>
<span
className={clsx(
"text-sm lg:text-3xl font-bold",
)}
style={getScoreStyleClass(getVerifiedAspectScore(aspect.id, assessmentStatus ?? ''))}
>
{formatScore(getVerifiedAspectScore(aspect.id, assessmentStatus ?? ''))}
</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{aspectsData && Array.from({ length: Math.max(...aspectsData.data.map(aspect => aspect.subAspects.length)) }).map((_, rowIndex) => (
<tr key={rowIndex} className={rowIndex % 2 === 0 ? "bg-slate-100" : "bg-white"}>
{aspectsData?.data?.map((aspect) => (
<React.Fragment key={aspect.id}>
{/* Sub-aspect Name Column (No Right Border) */}
<td className="text-[0.33rem] md:text-[0.5rem] lg:text-xs text-black p-1 py-0 lg:py-2 border-t border-l border-b border-gray-200 w-full text-left">
{aspect.subAspects[rowIndex]?.name || ""}
</td>
{/* Sub-aspect Score Column (No Left Border and w-fit for flexible width) */}
<td className="text-[0.4rem] lg:text-sm font-bold text-right p-1 lg:p-2 border-t border-b border-r border-gray-200 w-fit">
{aspect.subAspects[rowIndex] ? formatScore(getVerifiedSubAspectScore(aspect.subAspects[rowIndex].id, assessmentStatus ?? '')) : ""}
</td>
</React.Fragment>
))}
</tr>
))}
</tbody>
</table>
</Card>
</>
)}
<Card className="flex flex-col lg:flex-row gap-4 border-none shadow-none lg:mx-4 bg-transparent mb-4">
{/* Bar Chart */}
{selectedItem === 'Hasil Asesmen' ? (
<>
<Card className="w-full">
<CardHeader className="items-start">
<CardTitle className="text-sm lg:text-lg">Diagram Nilai Hasil Asesmen</CardTitle>
</CardHeader>
<CardContent>
<BarChartComponent barChartData={sortedBarChartData} barChartConfig={barChartConfig} />
</CardContent>
</Card>
</>
) : (
<>
<Card className="w-full">
<CardHeader className="items-start">
<CardTitle className="text-sm lg:text-lg">Diagram Nilai Hasil Asesmen Terverifikasi</CardTitle>
</CardHeader>
<CardContent>
<BarChartComponent barChartData={sortedVerifiedBarChartData} barChartConfig={barChartConfig} />
</CardContent>
</Card>
</>
)}
{/* Pie Chart */}
{selectedItem === 'Hasil Asesmen' ? (
<Card className="flex flex-col w-full lg:w-64">
<CardContent>
<PieChartComponent
chartData={chartData}
totalScore={totalScore}
chartConfig={chartConfig}
/>
</CardContent>
</Card>
) : (
<Card className="flex flex-col w-full lg:w-64">
<CardContent>
<PieChartComponent
chartData={verifiedChartData}
totalScore={totalVerifiedScore}
chartConfig={chartConfig}
/>
</CardContent>
</Card>
)}
</Card>
</Card>
</Card>
);
}

View File

@ -0,0 +1,15 @@
import { getAllAspectsAverageScore } from '@/modules/assessmentResult/queries/assessmentResultQueries';
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod';
const searchParamSchema = z.object({
detail: z.string().default("").optional(),
});
export const Route = createFileRoute("/_dashboardLayout/assessmentResult/")({
validateSearch: searchParamSchema,
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(getAllAspectsAverageScore("0"));
},
});

View File

@ -0,0 +1,125 @@
import PageTemplate from "@/components/PageTemplate";
import { createLazyFileRoute } from "@tanstack/react-router";
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
import { createColumnHelper } from "@tanstack/react-table";
import { TbEye } from "react-icons/tb";
import { assessmentResultsQueryOptions, postAnswerRevisionMutation, postAnswerRevisionQueryOptions } from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
import { Button } from "@/shadcn/components/ui/button";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import FormResponseError from "@/errors/FormResponseError";
import { notifications } from "@mantine/notifications";
import { Badge } from "@/shadcn/components/ui/badge";
import useAuth from "@/hooks/useAuth";
export const Route = createLazyFileRoute('/_dashboardLayout/assessmentResultsManagement/')({
component: assessmentResultsManagementPage,
});
type DataType = ExtractQueryDataType<typeof assessmentResultsQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
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?id=${assessmentId}`;
window.open(resultUrl, "_blank");
};
export default function assessmentResultsManagementPage() {
const { user } = useAuth();
const revisedBy = user ? user.name : '';
// Use the mutation defined in the queries file
const mutation = postAnswerRevisionMutation();
const verifyAssessment = (assessmentId: string) => {
if (!assessmentId) {
console.error("Assessment ID is missing");
return;
}
// Call the mutation to post the answer revision
mutation.mutate({ assessmentId, revisedBy });
const resultUrl = `/verifying?id=${assessmentId}`;
window.open(resultUrl, "_blank");
};
return (
<PageTemplate
title="Manajemen Hasil Asesmen"
queryOptions={assessmentResultsQueryOptions}
// modals={[assessmentResultsFormModal()]}
createButton={false}
columnDefs={[
columnHelper.display({
header: "#",
cell: (props) => props.row.index + 1,
}),
columnHelper.display({
header: "Nama Responden",
cell: (props) => props.row.original.respondentName,
}),
columnHelper.display({
header: "Nama Perusahaan",
cell: (props) => props.row.original.companyName,
}),
columnHelper.display({
id: "statusAssessments",
header: () => <div className="text-center">Status Verifikasi</div>,
cell: (props) => {
const status = props.row.original.statusAssessments;
switch (status) {
case "belum diverifikasi":
return <div className="flex items-center justify-center text-center">
<Badge variant={"unverified"}>Belum Diverifikasi</Badge>
</div>;
case "selesai":
return <div className="flex items-center justify-center text-center">
<Badge variant={"completed"}>Selesai</Badge>
</div>;
default:
}
},
}),
columnHelper.display({
header: "Hasil Asesmen",
cell: (props) => props.row.original.assessmentsResult,
}),
columnHelper.display({
header: " ",
cell: (props) => (
<div className="flex flex-row w-fit items-center rounded gap-2">
{props.row.original.statusAssessments === 'belum diverifikasi' && (
<Button
variant="ghost"
className="w-fit items-center bg-blue-600 hover:bg-blue-900"
onClick={() => verifyAssessment(props.row.original.id ?? '')}
>
<span className="text-white">Verifikasi</span>
</Button>
)}
</div>
),
}),
columnHelper.display({
header: "Aksi",
cell: (props) => (
<div className="flex flex-row w-fit items-center rounded gap-2">
<Button
variant="ghost"
className="w-fit items-center hover:bg-gray-300 border"
onClick={() => handleViewResult(props.row.original.id ?? '')}
>
<TbEye className="text-black" />
</Button>
</div>
),
}),
]}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More