Compare commits
No commits in common. "325306bd93a3635ab9829957a278d10a1eb26d5b" and "ad4a9f0e7082677a65eeb9069cae95105d2e2e26" have entirely different histories.
325306bd93
...
ad4a9f0e70
|
|
@ -1,14 +1,7 @@
|
||||||
BASE_URL =
|
|
||||||
APP_PORT = 3000
|
APP_PORT = 3000
|
||||||
|
|
||||||
DATABASE_URL =
|
DATABASE_URL =
|
||||||
|
|
||||||
ACCESS_TOKEN_SECRET =
|
ACCESS_TOKEN_SECRET =
|
||||||
REFRESH_TOKEN_SECRET =
|
REFRESH_TOKEN_SECRET =
|
||||||
RESET_PASSWORD_TOKEN_SECRET =
|
|
||||||
COOKIE_SECRET =
|
COOKIE_SECRET =
|
||||||
|
|
||||||
SMTP_USERNAME =
|
|
||||||
SMTP_PASSWORD =
|
|
||||||
SMTP_HOST =
|
|
||||||
SMTP_PORT =
|
|
||||||
2
apps/backend/files/.gitignore
vendored
2
apps/backend/files/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
*
|
|
||||||
!.gitignore
|
|
||||||
|
|
@ -19,7 +19,6 @@
|
||||||
"hono": "^4.4.6",
|
"hono": "^4.4.6",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"nodemailer": "^6.9.14",
|
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"sharp": "^0.33.4",
|
"sharp": "^0.33.4",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|
@ -28,7 +27,6 @@
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
"@types/node": "^20.14.2",
|
"@types/node": "^20.14.2",
|
||||||
"@types/nodemailer": "^6.4.15",
|
|
||||||
"drizzle-kit": "^0.22.7",
|
"drizzle-kit": "^0.22.7",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
"tsx": "^4.15.5",
|
"tsx": "^4.15.5",
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,11 @@ import { z } from "zod";
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
BASE_URL: z.string(),
|
|
||||||
APP_PORT: z.coerce.number().int(),
|
APP_PORT: z.coerce.number().int(),
|
||||||
DATABASE_URL: z.string(),
|
DATABASE_URL: z.string(),
|
||||||
ACCESS_TOKEN_SECRET: z.string(),
|
ACCESS_TOKEN_SECRET: z.string(),
|
||||||
REFRESH_TOKEN_SECRET: z.string(),
|
REFRESH_TOKEN_SECRET: z.string(),
|
||||||
RESET_PASSWORD_TOKEN_SECRET: z.string(),
|
|
||||||
COOKIE_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);
|
const parsedEnv = envSchema.safeParse(process.env);
|
||||||
|
|
|
||||||
|
|
@ -32,102 +32,6 @@ const permissionsData = [
|
||||||
{
|
{
|
||||||
code: "roles.delete",
|
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;
|
] as const;
|
||||||
|
|
||||||
export type SpecificPermissionCode = (typeof permissionsData)[number]["code"];
|
export type SpecificPermissionCode = (typeof permissionsData)[number]["code"];
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,10 @@ const roleData: RoleData[] = [
|
||||||
name: "Super Admin",
|
name: "Super Admin",
|
||||||
permissions: permissionsData.map((permission) => permission.code),
|
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
|
// Manually specify the union of role codes
|
||||||
export type RoleCode = "super-admin" | "user" | "*";
|
export type RoleCode = "super-admin" | "*";
|
||||||
|
|
||||||
const exportedRoleData = roleData;
|
const exportedRoleData = roleData;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,43 +2,17 @@ import { SidebarMenu } from "../types";
|
||||||
|
|
||||||
const sidebarMenus: SidebarMenu[] = [
|
const sidebarMenus: SidebarMenu[] = [
|
||||||
{
|
{
|
||||||
label: "Manajemen Pengguna",
|
label: "Dashboard",
|
||||||
icon: { tb: "TbUser" },
|
icon: { tb: "TbLayoutDashboard" },
|
||||||
|
allowedPermissions: ["*"],
|
||||||
|
link: "/dashboard",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
icon: { tb: "TbUsers" },
|
||||||
allowedPermissions: ["permissions.read"],
|
allowedPermissions: ["permissions.read"],
|
||||||
link: "/users",
|
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",
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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" }),
|
|
||||||
});
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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');
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
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" }),
|
|
||||||
});
|
|
||||||
|
|
@ -5,13 +5,13 @@ import { permissionsToUsers } from "./permissionsToUsers";
|
||||||
import { permissionsToRoles } from "./permissionsToRoles";
|
import { permissionsToRoles } from "./permissionsToRoles";
|
||||||
|
|
||||||
export const permissionsSchema = pgTable("permissions", {
|
export const permissionsSchema = pgTable("permissions", {
|
||||||
id: varchar("id", { length: 50 })
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => createId()),
|
.$defaultFn(() => createId()),
|
||||||
code: varchar("code", { length: 50 }).notNull().unique(),
|
code: varchar("code", { length: 50 }).notNull().unique(),
|
||||||
description: varchar("description", { length: 255 }),
|
description: varchar("description", { length: 255 }),
|
||||||
createdAt: timestamp("createdAt").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const permissionsRelations = relations(
|
export const permissionsRelations = relations(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
||||||
import { permissionsSchema } from "./permissions";
|
import { permissionsSchema } from "./permissions";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { rolesSchema } from "./roles";
|
import { rolesSchema } from "./roles";
|
||||||
|
|
@ -6,10 +6,10 @@ import { rolesSchema } from "./roles";
|
||||||
export const permissionsToRoles = pgTable(
|
export const permissionsToRoles = pgTable(
|
||||||
"permissions_to_roles",
|
"permissions_to_roles",
|
||||||
{
|
{
|
||||||
roleId: varchar("roleId", { length: 50 })
|
roleId: text("roleId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => rolesSchema.id),
|
.references(() => rolesSchema.id),
|
||||||
permissionId: varchar("permissionId", { length: 50 })
|
permissionId: text("permissionId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => permissionsSchema.id),
|
.references(() => permissionsSchema.id),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { permissionsSchema } from "./permissions";
|
import { permissionsSchema } from "./permissions";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
|
|
@ -6,10 +6,10 @@ import { relations } from "drizzle-orm";
|
||||||
export const permissionsToUsers = pgTable(
|
export const permissionsToUsers = pgTable(
|
||||||
"permissions_to_users",
|
"permissions_to_users",
|
||||||
{
|
{
|
||||||
userId: varchar("userId", { length: 50 })
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
permissionId: varchar("permissionId", { length: 50 })
|
permissionId: text("permissionId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => permissionsSchema.id),
|
.references(() => permissionsSchema.id),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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" }),
|
|
||||||
});
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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],
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
@ -4,14 +4,14 @@ import { pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||||
import { permissionsToRoles } from "./permissionsToRoles";
|
import { permissionsToRoles } from "./permissionsToRoles";
|
||||||
|
|
||||||
export const rolesSchema = pgTable("roles", {
|
export const rolesSchema = pgTable("roles", {
|
||||||
id: varchar("id", { length: 50 })
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => createId()),
|
.$defaultFn(() => createId()),
|
||||||
code: varchar("code", { length: 50 }).notNull().unique(),
|
code: varchar("code", { length: 50 }).notNull().unique(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
description: varchar("description", { length: 255 }),
|
description: varchar("description", { length: 255 }),
|
||||||
createdAt: timestamp("createdAt").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rolesRelations = relations(rolesSchema, ({ many }) => ({
|
export const rolesRelations = relations(rolesSchema, ({ many }) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { rolesSchema } from "./roles";
|
import { rolesSchema } from "./roles";
|
||||||
|
|
@ -6,10 +6,10 @@ import { rolesSchema } from "./roles";
|
||||||
export const rolesToUsers = pgTable(
|
export const rolesToUsers = pgTable(
|
||||||
"roles_to_users",
|
"roles_to_users",
|
||||||
{
|
{
|
||||||
userId: varchar("userId", { length: 50 })
|
userId: text("userId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
roleId: varchar("roleId", { length: 50 })
|
roleId: text("roleId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => rolesSchema.id),
|
.references(() => rolesSchema.id),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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" }),
|
|
||||||
});
|
|
||||||
|
|
@ -9,28 +9,22 @@ import {
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { permissionsToUsers } from "./permissionsToUsers";
|
import { permissionsToUsers } from "./permissionsToUsers";
|
||||||
import { rolesToUsers } from "./rolesToUsers";
|
import { rolesToUsers } from "./rolesToUsers";
|
||||||
import { respondents } from "./respondents";
|
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: varchar("id", { length: 50 })
|
id: text("id")
|
||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => createId()),
|
.$defaultFn(() => createId()),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
username: varchar("username").notNull().unique(),
|
username: varchar("username").notNull().unique(),
|
||||||
email: varchar("email").notNull().unique(),
|
email: varchar("email"),
|
||||||
password: text("password").notNull(),
|
password: text("password").notNull(),
|
||||||
isEnabled: boolean("isEnabled").default(true),
|
isEnabled: boolean("is_enable").default(true),
|
||||||
resetPasswordToken: varchar("resetPasswordToken"),
|
createdAt: timestamp("created_at", { mode: "date" }).defaultNow(),
|
||||||
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow(),
|
||||||
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
|
deletedAt: timestamp("deleted_at", { mode: "date" }),
|
||||||
deletedAt: timestamp("deletedAt", { mode: "date" }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many, one}) => ({
|
export const usersRelations = relations(users, ({ many }) => ({
|
||||||
permissionsToUsers: many(permissionsToUsers),
|
permissionsToUsers: many(permissionsToUsers),
|
||||||
rolesToUsers: many(rolesToUsers),
|
rolesToUsers: many(rolesToUsers),
|
||||||
respondent: one(respondents, {
|
|
||||||
fields: [users.id],
|
|
||||||
references: [respondents.userId],
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,6 @@ import db from ".";
|
||||||
import permissionSeeder from "./seeds/permissionSeeder";
|
import permissionSeeder from "./seeds/permissionSeeder";
|
||||||
import roleSeeder from "./seeds/rolesSeeder";
|
import roleSeeder from "./seeds/rolesSeeder";
|
||||||
import userSeeder from "./seeds/userSeeder";
|
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 () => {
|
(async () => {
|
||||||
console.time("Done seeding");
|
console.time("Done seeding");
|
||||||
|
|
@ -16,13 +9,6 @@ import respondentSeeder from "./seeds/respondentsSeeder";
|
||||||
await permissionSeeder();
|
await permissionSeeder();
|
||||||
await roleSeeder();
|
await roleSeeder();
|
||||||
await userSeeder();
|
await userSeeder();
|
||||||
await respondentSeeder();
|
|
||||||
await aspectsSeeder();
|
|
||||||
await subAspectsSeeder();
|
|
||||||
await questionSeeder();
|
|
||||||
await optionsSeeder();
|
|
||||||
await assessmentsSeeder();
|
|
||||||
await answersSeeder();
|
|
||||||
})().then(() => {
|
})().then(() => {
|
||||||
console.log("\n");
|
console.log("\n");
|
||||||
console.timeEnd("Done seeding");
|
console.timeEnd("Done seeding");
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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
|
|
@ -1,110 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -11,37 +11,8 @@ const userSeeder = async () => {
|
||||||
name: "Super Admin",
|
name: "Super Admin",
|
||||||
password: await hashPassword("123456"),
|
password: await hashPassword("123456"),
|
||||||
username: "superadmin",
|
username: "superadmin",
|
||||||
email: "admin@admin.com",
|
|
||||||
roles: ["super-admin"],
|
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...");
|
console.log("Seeding users...");
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import { configDotenv } from "dotenv";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import authRoutes from "./routes/auth/route";
|
import authRoutes from "./routes/auth/route";
|
||||||
import usersRoute from "./routes/users/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 { verifyAccessToken } from "./utils/authUtils";
|
||||||
import permissionRoutes from "./routes/permissions/route";
|
import permissionRoutes from "./routes/permissions/route";
|
||||||
import { cors } from "hono/cors";
|
import { cors } from "hono/cors";
|
||||||
|
|
@ -17,12 +15,6 @@ import DashboardError from "./errors/DashboardError";
|
||||||
import HonoEnv from "./types/HonoEnv";
|
import HonoEnv from "./types/HonoEnv";
|
||||||
import devRoutes from "./routes/dev/route";
|
import devRoutes from "./routes/dev/route";
|
||||||
import appEnv from "./appEnv";
|
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();
|
configDotenv();
|
||||||
|
|
||||||
|
|
@ -86,20 +78,11 @@ const routes = app
|
||||||
.route("/dashboard", dashboardRoutes)
|
.route("/dashboard", dashboardRoutes)
|
||||||
.route("/roles", rolesRoute)
|
.route("/roles", rolesRoute)
|
||||||
.route("/dev", devRoutes)
|
.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) => {
|
.onError((err, c) => {
|
||||||
if (err instanceof DashboardError) {
|
if (err instanceof DashboardError) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
message: err.message,
|
message: err.message,
|
||||||
|
|
||||||
errorCode: err.errorCode,
|
errorCode: err.errorCode,
|
||||||
formErrors: err.formErrors,
|
formErrors: err.formErrors,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@ const authInfo = createMiddleware<HonoEnv>(async (c, next) => {
|
||||||
|
|
||||||
// Setting the currentUser with fetched data
|
// Setting the currentUser with fetched data
|
||||||
c.set("currentUser", {
|
c.set("currentUser", {
|
||||||
id: user[0].users.id, // Adding user ID here
|
|
||||||
name: user[0].users.name, // Assuming the first result is the user
|
name: user[0].users.name, // Assuming the first result is the user
|
||||||
permissions: Array.from(permissions),
|
permissions: Array.from(permissions),
|
||||||
roles: Array.from(roles),
|
roles: Array.from(roles),
|
||||||
|
|
|
||||||
|
|
@ -1,278 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,620 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,779 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -134,7 +134,6 @@ const authRoutes = new Hono<HonoEnv>()
|
||||||
user: {
|
user: {
|
||||||
id: user[0].users.id,
|
id: user[0].users.id,
|
||||||
name: user[0].users.name,
|
name: user[0].users.name,
|
||||||
role: user[0].roles?.code,
|
|
||||||
permissions: Array.from(permissions),
|
permissions: Array.from(permissions),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,530 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,519 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq, ilike, isNull, or, sql, not, inArray } from "drizzle-orm";
|
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -12,21 +12,30 @@ import HonoEnv from "../../types/HonoEnv";
|
||||||
import requestValidator from "../../utils/requestValidator";
|
import requestValidator from "../../utils/requestValidator";
|
||||||
import authInfo from "../../middlewares/authInfo";
|
import authInfo from "../../middlewares/authInfo";
|
||||||
import checkPermission from "../../middlewares/checkPermission";
|
import checkPermission from "../../middlewares/checkPermission";
|
||||||
import { respondents } from "../../drizzle/schema/respondents";
|
|
||||||
import { forbidden, notFound } from "../../errors/DashboardError";
|
|
||||||
|
|
||||||
export const userFormSchema = z.object({
|
export const userFormSchema = z.object({
|
||||||
name: z.string().min(1, "Nama wajib diisi").max(255),
|
name: z.string().min(1).max(255),
|
||||||
username: z.string().min(1, "Username wajib diisi").max(255),
|
username: z.string().min(1).max(255),
|
||||||
email: z.string().min(1, "Email wajib diisi").max(255),
|
email: z.string().email().optional().or(z.literal("")),
|
||||||
password: z.string().min(6, "Password wajib diisi"),
|
password: z.string().min(6),
|
||||||
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"),
|
isEnabled: z.string().default("false"),
|
||||||
roles: z.array(z.string().min(1, "Role wajib diisi")),
|
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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userUpdateSchema = userFormSchema.extend({
|
export const userUpdateSchema = userFormSchema.extend({
|
||||||
|
|
@ -42,8 +51,6 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
* - includeTrashed: boolean (default: false)\
|
* - includeTrashed: boolean (default: false)\
|
||||||
* - withMetadata: boolean
|
* - withMetadata: boolean
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Get all users with search
|
|
||||||
.get(
|
.get(
|
||||||
"/",
|
"/",
|
||||||
checkPermission("users.readAll"),
|
checkPermission("users.readAll"),
|
||||||
|
|
@ -59,69 +66,17 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((v) => v?.toLowerCase() === "true"),
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
page: z.coerce.number().int().min(0).default(0),
|
page: z.coerce.number().int().min(0).default(0),
|
||||||
limit: z.coerce.number().int().min(1).max(1000).default(10),
|
limit: z.coerce.number().int().min(1).max(1000).default(1),
|
||||||
q: z.string().default(""), // Keyword search
|
q: z.string().default(""),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
// Query to count total data without duplicates
|
const totalCountQuery = includeTrashed
|
||||||
const totalCountQuery = db
|
? sql<number>`(SELECT count(*) FROM ${users})`
|
||||||
.select({
|
: sql<number>`(SELECT count(*) FROM ${users} WHERE ${users.deletedAt} IS NULL)`;
|
||||||
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)
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
|
|
@ -132,78 +87,38 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
updatedAt: users.updatedAt,
|
updatedAt: users.updatedAt,
|
||||||
...(includeTrashed ? { deletedAt: users.deletedAt } : {}),
|
...(includeTrashed ? { deletedAt: users.deletedAt } : {}),
|
||||||
company: respondents.companyName,
|
fullCount: totalCountQuery,
|
||||||
role: {
|
|
||||||
name: rolesSchema.name,
|
|
||||||
id: rolesSchema.id,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(respondents, eq(users.id, respondents.userId))
|
.where(
|
||||||
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
|
and(
|
||||||
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
|
includeTrashed ? undefined : isNull(users.deletedAt),
|
||||||
.where(inArray(users.id, userIdsQuery)) // Only take data based on IDs from subquery
|
q
|
||||||
.orderBy(users.createdAt);
|
? or(
|
||||||
|
ilike(users.name, q),
|
||||||
// Group roles for each user to avoid duplication
|
ilike(users.username, q),
|
||||||
const userMap = new Map<
|
ilike(users.email, q),
|
||||||
string,
|
eq(users.id, q)
|
||||||
{
|
)
|
||||||
id: string;
|
: undefined
|
||||||
name: string;
|
)
|
||||||
email: string | null;
|
)
|
||||||
username: string;
|
.offset(page * limit)
|
||||||
isEnabled: boolean;
|
.limit(limit);
|
||||||
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({
|
return c.json({
|
||||||
data: groupedData,
|
data: result.map((d) => ({ ...d, fullCount: undefined })),
|
||||||
_metadata: {
|
_metadata: {
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
totalPages: Math.ceil(totalCount / limit),
|
totalPages: Math.ceil(
|
||||||
totalItems: totalCount,
|
(Number(result[0]?.fullCount) ?? 0) / limit
|
||||||
|
),
|
||||||
|
totalItems: Number(result[0]?.fullCount) ?? 0,
|
||||||
perPage: limit,
|
perPage: limit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
//get user by id
|
//get user by id
|
||||||
.get(
|
.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
|
|
@ -224,12 +139,7 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
name: users.name,
|
name: users.name,
|
||||||
position: respondents.position,
|
|
||||||
workExperience: respondents.workExperience,
|
|
||||||
email: users.email,
|
email: users.email,
|
||||||
companyName: respondents.companyName,
|
|
||||||
address: respondents.address,
|
|
||||||
phoneNumber: respondents.phoneNumber,
|
|
||||||
username: users.username,
|
username: users.username,
|
||||||
isEnabled: users.isEnabled,
|
isEnabled: users.isEnabled,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
|
|
@ -241,7 +151,6 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.leftJoin(respondents, eq(users.id, respondents.userId))
|
|
||||||
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
|
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
|
||||||
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
|
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
|
||||||
.where(
|
.where(
|
||||||
|
|
@ -252,9 +161,9 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!queryResult.length)
|
if (!queryResult.length)
|
||||||
throw notFound({
|
throw new HTTPException(404, {
|
||||||
message : "The user does not exists",
|
message: "The user does not exists",
|
||||||
})
|
});
|
||||||
|
|
||||||
const roles = queryResult.reduce((prev, curr) => {
|
const roles = queryResult.reduce((prev, curr) => {
|
||||||
if (!curr.role) return prev;
|
if (!curr.role) return prev;
|
||||||
|
|
@ -271,121 +180,38 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
return c.json(userData);
|
return c.json(userData);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
//create user
|
//create user
|
||||||
.post(
|
.post(
|
||||||
"/",
|
"/",
|
||||||
checkPermission("users.create"),
|
checkPermission("users.create"),
|
||||||
requestValidator("json", userFormSchema),
|
requestValidator("form", userFormSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const userData = c.req.valid("json");
|
const userData = c.req.valid("form");
|
||||||
|
|
||||||
// Check if the provided email or username is already exists in database
|
const user = await db
|
||||||
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)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
name: userData.name,
|
name: userData.name,
|
||||||
username: userData.username,
|
username: userData.username,
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
password: hashedPassword,
|
password: await hashPassword(userData.password),
|
||||||
isEnabled: userData.isEnabled?.toLowerCase() === "true" || true,
|
isEnabled: userData.isEnabled.toLowerCase() === "true",
|
||||||
})
|
})
|
||||||
.returning()
|
.returning();
|
||||||
.catch(() => {
|
|
||||||
throw forbidden({
|
|
||||||
message: "Error creating user",
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create respondent
|
if (userData.roles) {
|
||||||
const [newRespondent] = await trx
|
const roles = JSON.parse(userData.roles) as string[];
|
||||||
.insert(respondents)
|
console.log(roles);
|
||||||
.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 (roles.length) {
|
||||||
if (userData.roles && userData.roles.length > 0) {
|
await db.insert(rolesToUsers).values(
|
||||||
const roles = userData.roles;
|
roles.map((role) => ({
|
||||||
|
userId: user[0].id,
|
||||||
for (let roleId of roles) {
|
roleId: role,
|
||||||
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(
|
return c.json(
|
||||||
{
|
{
|
||||||
|
|
@ -400,32 +226,10 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
.patch(
|
.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
checkPermission("users.update"),
|
checkPermission("users.update"),
|
||||||
requestValidator("json", userUpdateSchema),
|
requestValidator("form", userUpdateSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const userId = c.req.param("id");
|
const userId = c.req.param("id");
|
||||||
const userData = c.req.valid("json");
|
const userData = c.req.valid("form");
|
||||||
|
|
||||||
// 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
|
const user = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -434,70 +238,18 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
|
|
||||||
if (!user[0]) return c.notFound();
|
if (!user[0]) return c.notFound();
|
||||||
|
|
||||||
// Start transaction to update both user and respondent
|
await db
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
// Update user
|
|
||||||
await trx
|
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
name: userData.name,
|
...userData,
|
||||||
username: userData.username,
|
...(userData.password
|
||||||
email: userData.email,
|
? { password: await hashPassword(userData.password) }
|
||||||
|
: {}),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
isEnabled: userData.isEnabled.toLowerCase() === "true",
|
isEnabled: userData.isEnabled.toLowerCase() === "true",
|
||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.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({
|
return c.json({
|
||||||
message: "User updated successfully",
|
message: "User updated successfully",
|
||||||
});
|
});
|
||||||
|
|
@ -521,7 +273,6 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
const skipTrash =
|
const skipTrash =
|
||||||
c.req.valid("form").skipTrash.toLowerCase() === "true";
|
c.req.valid("form").skipTrash.toLowerCase() === "true";
|
||||||
|
|
||||||
// Check if the user exists
|
|
||||||
const user = await db
|
const user = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
|
|
@ -532,20 +283,17 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throw error if the user does not exist
|
|
||||||
if (!user[0])
|
if (!user[0])
|
||||||
throw notFound ({
|
throw new HTTPException(404, {
|
||||||
message: "The user is not found",
|
message: "The user is not found",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Throw error if the user is trying to delete themselves
|
|
||||||
if (user[0].id === currentUserId) {
|
if (user[0].id === currentUserId) {
|
||||||
throw forbidden ({
|
throw new HTTPException(400, {
|
||||||
message: "You cannot delete yourself",
|
message: "You cannot delete yourself",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete or soft delete user
|
|
||||||
if (skipTrash) {
|
if (skipTrash) {
|
||||||
await db.delete(users).where(eq(users.id, userId));
|
await db.delete(users).where(eq(users.id, userId));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -563,27 +311,21 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
)
|
)
|
||||||
|
|
||||||
//undo delete
|
//undo delete
|
||||||
.patch(
|
.patch("/restore/:id", checkPermission("users.restore"), async (c) => {
|
||||||
"/restore/:id",
|
|
||||||
checkPermission("users.restore"),
|
|
||||||
async (c) => {
|
|
||||||
const userId = c.req.param("id");
|
const userId = c.req.param("id");
|
||||||
|
|
||||||
// Check if the user exists
|
|
||||||
const user = (
|
const user = (
|
||||||
await db.select().from(users).where(eq(users.id, userId))
|
await db.select().from(users).where(eq(users.id, userId))
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
if (!user) return c.notFound();
|
if (!user) return c.notFound();
|
||||||
|
|
||||||
// Throw error if the user is not deleted
|
|
||||||
if (!user.deletedAt) {
|
if (!user.deletedAt) {
|
||||||
throw forbidden({
|
throw new HTTPException(400, {
|
||||||
message: "The user is not deleted",
|
message: "The user is not deleted",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore user
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ deletedAt: null })
|
.set({ deletedAt: null })
|
||||||
|
|
|
||||||
1
apps/backend/src/types/HonoEnv.d.ts
vendored
1
apps/backend/src/types/HonoEnv.d.ts
vendored
|
|
@ -5,7 +5,6 @@ type HonoEnv = {
|
||||||
Variables: {
|
Variables: {
|
||||||
uid?: string;
|
uid?: string;
|
||||||
currentUser?: {
|
currentUser?: {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
permissions: SpecificPermissionCode[];
|
permissions: SpecificPermissionCode[];
|
||||||
roles: RoleCode[];
|
roles: RoleCode[];
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import appEnv from "../appEnv";
|
||||||
// Environment variables for secrets, defaulting to a random secret if not set.
|
// Environment variables for secrets, defaulting to a random secret if not set.
|
||||||
const accessTokenSecret = appEnv.ACCESS_TOKEN_SECRET;
|
const accessTokenSecret = appEnv.ACCESS_TOKEN_SECRET;
|
||||||
const refreshTokenSecret = appEnv.REFRESH_TOKEN_SECRET;
|
const refreshTokenSecret = appEnv.REFRESH_TOKEN_SECRET;
|
||||||
const resetPasswordTokenSecret = appEnv.RESET_PASSWORD_TOKEN_SECRET;
|
|
||||||
|
|
||||||
// Algorithm to be used for JWT encoding.
|
// Algorithm to be used for JWT encoding.
|
||||||
const algorithm: jwt.Algorithm = "HS256";
|
const algorithm: jwt.Algorithm = "HS256";
|
||||||
|
|
@ -12,7 +11,6 @@ const algorithm: jwt.Algorithm = "HS256";
|
||||||
// Expiry settings for tokens. 'null' signifies no expiry.
|
// Expiry settings for tokens. 'null' signifies no expiry.
|
||||||
export const accessTokenExpiry: number | string | null = null;
|
export const accessTokenExpiry: number | string | null = null;
|
||||||
export const refreshTokenExpiry: number | string | null = "30d";
|
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.
|
// Interfaces to describe the payload structure for access and refresh tokens.
|
||||||
interface AccessTokenPayload {
|
interface AccessTokenPayload {
|
||||||
|
|
@ -23,10 +21,6 @@ interface RefreshTokenPayload {
|
||||||
uid: string;
|
uid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResetPasswordTokenPayload {
|
|
||||||
uid: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a JSON Web Token (JWT) for access control using a specified payload.
|
* Generates a JSON Web Token (JWT) for access control using a specified payload.
|
||||||
*
|
*
|
||||||
|
|
@ -90,35 +84,3 @@ export const verifyRefreshToken = async (token: string) => {
|
||||||
return null;
|
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/src/assets/logos/amati-icon.png" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Amati</title>
|
<title>Vite + React + TS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -10,22 +10,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
|
||||||
"@mantine/core": "^7.10.2",
|
"@mantine/core": "^7.10.2",
|
||||||
"@mantine/dates": "^7.10.2",
|
"@mantine/dates": "^7.10.2",
|
||||||
"@mantine/form": "^7.10.2",
|
"@mantine/form": "^7.10.2",
|
||||||
"@mantine/hooks": "^7.10.2",
|
"@mantine/hooks": "^7.10.2",
|
||||||
"@mantine/notifications": "^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",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@tanstack/react-query": "^5.45.0",
|
"@tanstack/react-query": "^5.45.0",
|
||||||
"@tanstack/react-router": "^1.38.1",
|
"@tanstack/react-router": "^1.38.1",
|
||||||
|
|
@ -35,14 +24,11 @@
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"hono": "^4.4.6",
|
"hono": "^4.4.6",
|
||||||
"html2pdf.js": "^0.10.2",
|
|
||||||
"lucide-react": "^0.414.0",
|
"lucide-react": "^0.414.0",
|
||||||
"mantine-form-zod-resolver": "^1.1.0",
|
"mantine-form-zod-resolver": "^1.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.53.0",
|
|
||||||
"react-icons": "^5.2.1",
|
"react-icons": "^5.2.1",
|
||||||
"recharts": "^2.13.0",
|
|
||||||
"tailwind-merge": "^2.4.0",
|
"tailwind-merge": "^2.4.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 447 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
|
|
@ -1,195 +1,96 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import logo from "@/assets/logos/amati-logo.png";
|
import {
|
||||||
|
AppShell,
|
||||||
|
Avatar,
|
||||||
|
Burger,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
UnstyledButton,
|
||||||
|
Text,
|
||||||
|
rem,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import logo from "@/assets/logos/logo.png";
|
||||||
import cx from "clsx";
|
import cx from "clsx";
|
||||||
import classNames from "./styles/appHeader.module.css";
|
import classNames from "./styles/appHeader.module.css";
|
||||||
import { IoMdMenu } from "react-icons/io";
|
import { TbChevronDown } from "react-icons/tb";
|
||||||
import { Link, useLocation } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import useAuth from "@/hooks/useAuth";
|
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 getUserMenus from "../actions/getUserMenus";
|
||||||
// import { useAuth } from "@/modules/auth/contexts/AuthContext";
|
// import { useAuth } from "@/modules/auth/contexts/AuthContext";
|
||||||
// import UserMenuItem from "./UserMenuItem";
|
// import UserMenuItem from "./UserMenuItem";
|
||||||
// import { toggleLeftSidebar } from "../../src/routes/_assessmentLayout/assessment/index.lazy";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
openNavbar: boolean;
|
openNavbar: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
permissions: string[];
|
|
||||||
role: string;
|
|
||||||
photoProfile?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
toggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// const mockUserData = {
|
// const mockUserData = {
|
||||||
// name: "Fulan bin Fulanah",
|
// name: "Fulan bin Fulanah",
|
||||||
// email: "janspoon@fighter.dev",
|
// email: "janspoon@fighter.dev",
|
||||||
// image: "https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-5.png",
|
// image: "https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-5.png",
|
||||||
// };
|
// };
|
||||||
|
|
||||||
interface Props {
|
export default function AppHeader(props: Props) {
|
||||||
toggle: () => void;
|
|
||||||
toggleLeftSidebar: () => void; // Add this prop
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppHeader({ toggle, toggleLeftSidebar }: Props) {
|
|
||||||
const [userMenuOpened, setUserMenuOpened] = useState(false);
|
const [userMenuOpened, setUserMenuOpened] = useState(false);
|
||||||
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
const { user }: { user: User | null } = useAuth();
|
const { user } = useAuth();
|
||||||
const isSuperAdmin = user?.role === "super-admin";
|
|
||||||
|
|
||||||
// const userMenus = getUserMenus().map((item, i) => (
|
// const userMenus = getUserMenus().map((item, i) => (
|
||||||
// <UserMenuItem item={item} key={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 (
|
return (
|
||||||
<header className="fixed top-0 left-0 w-full h-16 bg-white z-50 border">
|
<AppShell.Header>
|
||||||
<div className="flex h-full justify-between w-full items-center">
|
<Group h="100%" px="md" justify="space-between">
|
||||||
<div className="flex items-center">
|
<Burger
|
||||||
{shouldShowButton && (
|
opened={props.openNavbar}
|
||||||
<Button
|
onClick={props.toggle}
|
||||||
onClick={toggle}
|
hiddenFrom="sm"
|
||||||
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"
|
size="sm"
|
||||||
>
|
|
||||||
<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>
|
<img src={logo} alt="" className="h-8" />
|
||||||
|
<Menu
|
||||||
{/* Conditional Navlinks */}
|
width={260}
|
||||||
{!isSuperAdmin && (
|
position="bottom-end"
|
||||||
<div className="flex space-x-4 justify-center w-full ml-14">
|
transitionProps={{ transition: "pop-top-right" }}
|
||||||
{showAssessmentResultLinks && (
|
onOpen={() => setUserMenuOpened(true)}
|
||||||
<>
|
onClose={() => setUserMenuOpened(false)}
|
||||||
<Link
|
withinPortal
|
||||||
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
|
<Menu.Target>
|
||||||
</Link>
|
<UnstyledButton
|
||||||
<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}
|
|
||||||
>
|
|
||||||
<DropdownMenuTrigger asChild className="flex">
|
|
||||||
<button
|
|
||||||
className={cx(classNames.user, {
|
className={cx(classNames.user, {
|
||||||
[classNames.userActive]: userMenuOpened,
|
[classNames.userActive]: userMenuOpened,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<Group gap={7}>
|
||||||
<Avatar>
|
<Avatar
|
||||||
{user?.photoProfile ? (
|
// src={user?.photoProfile}
|
||||||
<AvatarImage src={user.photoProfile} />
|
// alt={user?.name}
|
||||||
) : (
|
radius="xl"
|
||||||
<AvatarFallback>
|
size={30}
|
||||||
{user?.name?.charAt(0) ?? "A"}
|
/>
|
||||||
</AvatarFallback>
|
<Text fw={500} size="sm" lh={1} mr={3}>
|
||||||
)}
|
{/* {user?.name} */}
|
||||||
</Avatar>
|
{user?.name ?? "Anonymous"}
|
||||||
</div>
|
</Text>
|
||||||
</button>
|
<TbChevronDown
|
||||||
</DropdownMenuTrigger>
|
style={{ width: rem(12), height: rem(12) }}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<Menu.Dropdown>
|
||||||
align="end"
|
<Menu.Item component={Link} to="/logout">
|
||||||
className="transition-all duration-200 z-50 border bg-white w-64"
|
Logout
|
||||||
>
|
</Menu.Item>
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/logout">Logout</Link>
|
{/* {userMenus} */}
|
||||||
</DropdownMenuItem>
|
</Menu.Dropdown>
|
||||||
</DropdownMenuContent>
|
</Menu>
|
||||||
</DropdownMenu>
|
</Group>
|
||||||
</div>
|
</AppShell.Header>
|
||||||
</header>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
|
import { AppShell, ScrollArea } from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import client from "../honoClient";
|
import client from "../honoClient";
|
||||||
import MenuItem from "./NavbarMenuItem";
|
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 MenuItem from "./SidebarMenuItem";
|
||||||
// import { useAuth } from "@/modules/auth/contexts/AuthContext";
|
// import { useAuth } from "@/modules/auth/contexts/AuthContext";
|
||||||
|
|
@ -17,21 +13,7 @@ import useAuth from "@/hooks/useAuth";
|
||||||
* @returns A React element representing the application's navigation bar.
|
* @returns A React element representing the application's navigation bar.
|
||||||
*/
|
*/
|
||||||
export default function AppNavbar() {
|
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({
|
const { data } = useQuery({
|
||||||
queryKey: ["sidebarData"],
|
queryKey: ["sidebarData"],
|
||||||
|
|
@ -39,81 +21,24 @@ export default function AppNavbar() {
|
||||||
const res = await client.dashboard.getSidebarItems.$get();
|
const res = await client.dashboard.getSidebarItems.$get();
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
console.error("Error:", res.status, res.statusText);
|
console.error("Error:", res.status, res.statusText);
|
||||||
|
|
||||||
|
//TODO: Handle error properly
|
||||||
throw new Error("Error fetching sidebar data");
|
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 (
|
return (
|
||||||
<>
|
<AppShell.Navbar p="md">
|
||||||
<div>
|
<ScrollArea style={{ flex: "1" }}>
|
||||||
{/* Header */}
|
{data?.map((menu, i) => <MenuItem menu={menu} key={i} />)}
|
||||||
<AppHeader toggle={toggleSidebar} openNavbar={isSidebarOpen} toggleLeftSidebar={toggleLeftSidebar} />
|
{/* {user?.sidebarMenus.map((menu, i) => (
|
||||||
|
<MenuItem menu={menu} key={i} />
|
||||||
{/* Sidebar */}
|
)) ?? null} */}
|
||||||
{!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>
|
</ScrollArea>
|
||||||
</div>
|
</AppShell.Navbar>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,5 @@
|
||||||
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
|
import { Table, Center, ScrollArea } from "@mantine/core";
|
||||||
import {
|
import { Table as ReactTable, flexRender } from "@tanstack/react-table";
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from "@/shadcn/components/ui/table";
|
|
||||||
import { flexRender, Table as ReactTable } from "@tanstack/react-table";
|
|
||||||
|
|
||||||
interface Props<TData> {
|
interface Props<TData> {
|
||||||
table: ReactTable<TData>;
|
table: ReactTable<TData>;
|
||||||
|
|
@ -15,15 +7,22 @@ interface Props<TData> {
|
||||||
|
|
||||||
export default function DashboardTable<T>({ table }: Props<T>) {
|
export default function DashboardTable<T>({ table }: Props<T>) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-full overflow-x-auto border rounded-lg">
|
<ScrollArea.Autosize>
|
||||||
<Table className="min-w-full divide-y divide-muted-foreground bg-white">
|
<Table
|
||||||
<TableHeader>
|
verticalSpacing="xs"
|
||||||
|
horizontalSpacing="xs"
|
||||||
|
striped
|
||||||
|
highlightOnHover
|
||||||
|
withColumnBorders
|
||||||
|
withRowBorders
|
||||||
|
>
|
||||||
|
{/* Thead */}
|
||||||
|
<Table.Thead>
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<Table.Tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<TableHead
|
<Table.Th
|
||||||
key={header.id}
|
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={{
|
style={{
|
||||||
maxWidth: `${header.column.columnDef.maxSize}px`,
|
maxWidth: `${header.column.columnDef.maxSize}px`,
|
||||||
width: `${header.getSize()}`,
|
width: `${header.getSize()}`,
|
||||||
|
|
@ -31,41 +30,45 @@ export default function DashboardTable<T>({ table }: Props<T>) {
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
: flexRender(
|
||||||
</TableHead>
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</Table.Th>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</Table.Tr>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</Table.Thead>
|
||||||
|
|
||||||
<TableBody>
|
{/* Tbody */}
|
||||||
|
<Table.Tbody>
|
||||||
{table.getRowModel().rows.length > 0 ? (
|
{table.getRowModel().rows.length > 0 ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow key={row.id}>
|
<Table.Tr key={row.id}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<Table.Td
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className="px-6 py-4 whitespace-nowrap text-sm text-black"
|
|
||||||
style={{
|
style={{
|
||||||
maxWidth: `${cell.column.columnDef.maxSize}px`,
|
maxWidth: `${cell.column.columnDef.maxSize}px`,
|
||||||
whiteSpace: "normal",
|
|
||||||
wordWrap: "break-word",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(
|
||||||
</TableCell>
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</Table.Tr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<Table.Tr>
|
||||||
<TableCell colSpan={table.getAllColumns().length} className="px-6 py-4 text-center text-sm text-gray-500">
|
<Table.Td colSpan={table.getAllColumns().length}>
|
||||||
- No Data -
|
<Center>- No Data -</Center>
|
||||||
</TableCell>
|
</Table.Td>
|
||||||
</TableRow>
|
</Table.Tr>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</ScrollArea.Autosize>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
|
||||||
import classNames from "./styles/navbarChildMenu.module.css";
|
import classNames from "./styles/navbarChildMenu.module.css";
|
||||||
import { SidebarMenu } from "backend/types";
|
import { SidebarMenu } from "backend/types";
|
||||||
|
|
||||||
|
|
@ -20,10 +22,13 @@ export default function ChildMenu(props: Props) {
|
||||||
: `/${props.item.link}`;
|
: `/${props.item.link}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={`${linkPath}`}
|
<Text<"a">
|
||||||
className={`${props.active ? "font-bold" : "font-normal"} ${classNames.link} text-blue-600 hover:underline`}
|
component="a"
|
||||||
|
className={classNames.link}
|
||||||
|
href={`${linkPath}`}
|
||||||
|
fw={props.active ? "bold" : "normal"}
|
||||||
>
|
>
|
||||||
{props.item.label}
|
{props.item.label}
|
||||||
</a>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import { useState } from "react";
|
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 * 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 dashboardConfig from "../dashboard.config";
|
||||||
// import { usePathname } from "next/navigation";
|
// import { usePathname } from "next/navigation";
|
||||||
// import areURLsSame from "@/utils/areUrlSame";
|
// import areURLsSame from "@/utils/areUrlSame";
|
||||||
|
|
@ -8,14 +19,9 @@ import * as TbIcons from "react-icons/tb";
|
||||||
import { SidebarMenu } from "backend/types";
|
import { SidebarMenu } from "backend/types";
|
||||||
import ChildMenu from "./NavbarChildMenu";
|
import ChildMenu from "./NavbarChildMenu";
|
||||||
import { Link } from "@tanstack/react-router";
|
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 {
|
interface Props {
|
||||||
menu: SidebarMenu;
|
menu: SidebarMenu;
|
||||||
isActive: boolean;
|
|
||||||
onClick: (link: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: Make bold and collapsed when the item is active
|
//TODO: Make bold and collapsed when the item is active
|
||||||
|
|
@ -28,7 +34,7 @@ interface Props {
|
||||||
* @param props.menu - The menu item data to display.
|
* @param props.menu - The menu item data to display.
|
||||||
* @returns A React element representing an individual menu item.
|
* @returns A React element representing an individual menu item.
|
||||||
*/
|
*/
|
||||||
export default function MenuItem({ menu, isActive, onClick }: Props) {
|
export default function MenuItem({ menu }: Props) {
|
||||||
const hasChildren = Array.isArray(menu.children);
|
const hasChildren = Array.isArray(menu.children);
|
||||||
|
|
||||||
// const pathname = usePathname();
|
// const pathname = usePathname();
|
||||||
|
|
@ -44,13 +50,6 @@ export default function MenuItem({ menu, isActive, onClick }: Props) {
|
||||||
setOpened((prev) => !prev);
|
setOpened((prev) => !prev);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
onClick(menu.link ?? "");
|
|
||||||
if (!hasChildren) {
|
|
||||||
toggleOpenMenu();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mapping children menu items if available
|
// Mapping children menu items if available
|
||||||
const subItems = (hasChildren ? menu.children! : []).map((child, index) => (
|
const subItems = (hasChildren ? menu.children! : []).map((child, index) => (
|
||||||
<ChildMenu key={index} item={child} active={false} />
|
<ChildMenu key={index} item={child} active={false} />
|
||||||
|
|
@ -70,41 +69,43 @@ export default function MenuItem({ menu, isActive, onClick }: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Main Menu Item */}
|
{/* Main Menu Item */}
|
||||||
<Button
|
<UnstyledButton<typeof Link | "button">
|
||||||
variant="ghost"
|
onClick={toggleOpenMenu}
|
||||||
className={cn(
|
className={`${classNames.control} py-2`}
|
||||||
"w-full p-2 rounded-md justify-between focus:outline-none",
|
to={menu.link}
|
||||||
isActive ? "bg-[--primary-color] text-white" : "text-black"
|
component={menu.link ? Link : "button"}
|
||||||
)}
|
|
||||||
onClick={handleClick}
|
|
||||||
asChild
|
|
||||||
>
|
>
|
||||||
<Link to={menu.link ?? "#"}>
|
<Group justify="space-between" gap={0}>
|
||||||
<div className="flex items-center">
|
{/* Icon and Label */}
|
||||||
{/* Icon */}
|
<Box style={{ display: "flex", alignItems: "center" }}>
|
||||||
<span className="mr-3">
|
<ThemeIcon variant="light" size={30} color={menu.color}>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon style={{ width: rem(18), height: rem(18) }} />
|
||||||
</span>
|
</ThemeIcon>
|
||||||
{/* Label */}
|
|
||||||
<span className="text-xs font-normal whitespace-normal">{menu.label}</span>
|
<Box ml="md" fw={500}>
|
||||||
</div>
|
{menu.label}
|
||||||
{/* Chevron Icon */}
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Chevron Icon for collapsible items */}
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<ChevronRightIcon
|
<TbChevronRight
|
||||||
className={`w-4 h-4 transition-transform ${
|
strokeWidth={1.5}
|
||||||
opened ? "rotate-90" : "rotate-0"
|
style={{
|
||||||
}`}
|
width: rem(16),
|
||||||
|
height: rem(16),
|
||||||
|
transform: opened
|
||||||
|
? "rotate(-90deg)"
|
||||||
|
: "rotate(90deg)",
|
||||||
|
}}
|
||||||
|
className={classNames.chevron}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Group>
|
||||||
</Button>
|
</UnstyledButton>
|
||||||
|
|
||||||
{/* Collapsible Sub-Menu */}
|
{/* Collapsible Sub-Menu */}
|
||||||
{hasChildren && (
|
{hasChildren && <Collapse in={opened}>{subItems}</Collapse>}
|
||||||
<div className={cn("transition-all", opened ? "block" : "hidden")}>
|
|
||||||
{subItems}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
/* eslint-disable no-mixed-spaces-and-tabs */
|
/* 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 React, { ReactNode, useState } from "react";
|
||||||
import { TbPlus, TbSearch } from "react-icons/tb";
|
import { TbPlus, TbSearch } from "react-icons/tb";
|
||||||
import DashboardTable from "./DashboardTable";
|
import DashboardTable from "./DashboardTable";
|
||||||
|
|
@ -13,25 +25,7 @@ import {
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useDebouncedValue } from "@mantine/hooks";
|
import { useDebouncedCallback } 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>> = {
|
type PaginatedResponse<T extends Record<string, unknown>> = {
|
||||||
data: Array<T>;
|
data: Array<T>;
|
||||||
|
|
@ -76,36 +70,24 @@ const createCreateButton = (
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
property: Props<any, any, any>["createButton"] = true
|
property: Props<any, any, any>["createButton"] = true
|
||||||
) => {
|
) => {
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const addQuery = () => {
|
|
||||||
navigate({ to: `${window.location.pathname}`, search: { create: true } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (property === true) {
|
if (property === true) {
|
||||||
const location = useLocation();
|
|
||||||
const isAssessmentRequestPage =
|
|
||||||
location.pathname === "/assessmentRequest";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className="gap-2"
|
leftSection={<TbPlus />}
|
||||||
variant={isAssessmentRequestPage ? "request" : "outline"}
|
component={Link}
|
||||||
onClick={addQuery}
|
search={{ create: true }}
|
||||||
>
|
>
|
||||||
{isAssessmentRequestPage ? "Ajukan Permohonan" : "Tambah Data"}
|
Create New
|
||||||
<TbPlus />
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
} else if (typeof property === "string") {
|
} else if (typeof property === "string") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className="gap-2"
|
leftSection={<TbPlus />}
|
||||||
variant={"outline"}
|
component={Link}
|
||||||
onClick={addQuery}
|
search={{ create: true }}
|
||||||
>
|
>
|
||||||
{property}
|
{property}
|
||||||
<TbPlus />
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -113,109 +95,6 @@ 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.
|
* PageTemplate component for displaying a paginated table with search and filter functionality.
|
||||||
|
|
||||||
|
|
@ -234,14 +113,14 @@ export default function PageTemplate<
|
||||||
q: "",
|
q: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [debouncedSearchQuery] = useDebouncedValue(filterOptions.q, 500);
|
// const [deboucedSearchQuery] = useDebouncedValue(filterOptions.q, 500);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
...(typeof props.queryOptions === "function"
|
...(typeof props.queryOptions === "function"
|
||||||
? props.queryOptions(
|
? props.queryOptions(
|
||||||
filterOptions.page,
|
filterOptions.page,
|
||||||
filterOptions.limit,
|
filterOptions.limit,
|
||||||
debouncedSearchQuery
|
filterOptions.q
|
||||||
)
|
)
|
||||||
: props.queryOptions),
|
: props.queryOptions),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|
@ -252,11 +131,7 @@ export default function PageTemplate<
|
||||||
columns: props.columnDefs,
|
columns: props.columnDefs,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
cell: (props) => (
|
cell: (props) => <Text>{props.getValue() as ReactNode}</Text>,
|
||||||
<span>
|
|
||||||
{props.getValue() as ReactNode}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -265,13 +140,13 @@ export default function PageTemplate<
|
||||||
*
|
*
|
||||||
* @param value - The new search query value.
|
* @param value - The new search query value.
|
||||||
*/
|
*/
|
||||||
const handleSearchQueryChange = (value: string) => {
|
const handleSearchQueryChange = useDebouncedCallback((value: string) => {
|
||||||
setFilterOptions((prev) => ({
|
setFilterOptions((prev) => ({
|
||||||
page: 0,
|
page: 0,
|
||||||
limit: prev.limit,
|
limit: prev.limit,
|
||||||
q: value,
|
q: value,
|
||||||
}));
|
}));
|
||||||
};
|
}, 500);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the change in page number.
|
* Handles the change in page number.
|
||||||
|
|
@ -280,85 +155,75 @@ export default function PageTemplate<
|
||||||
*/
|
*/
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setFilterOptions((prev) => ({
|
setFilterOptions((prev) => ({
|
||||||
page: page - 1, // Adjust for zero-based index
|
page: page - 1,
|
||||||
limit: prev.limit,
|
limit: prev.limit,
|
||||||
q: prev.q,
|
q: prev.q,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-4">
|
<Stack>
|
||||||
<p className="text-2xl font-bold">{props.title}</p>
|
<Title order={1}>{props.title}</Title>
|
||||||
<Card className="p-4 border-hidden">
|
<Card>
|
||||||
|
{/* Top Section */}
|
||||||
|
<Flex justify="flex-end">
|
||||||
|
{createCreateButton(props.createButton)}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
{/* Table Functionality */}
|
{/* Table Functionality */}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* Search and Create Button */}
|
{/* Search */}
|
||||||
<div className="flex flex-col md:flex-row lg:flex-row pb-4 justify-between gap-4">
|
<div className="flex pb-4">
|
||||||
<div className="relative w-full">
|
<TextInput
|
||||||
<TbSearch className="absolute top-1/2 left-3 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
leftSection={<TbSearch />}
|
||||||
<Input
|
|
||||||
id="search"
|
|
||||||
name="search"
|
|
||||||
className="w-full max-w-xs pl-10"
|
|
||||||
value={filterOptions.q}
|
value={filterOptions.q}
|
||||||
onChange={(e) => handleSearchQueryChange(e.target.value)}
|
onChange={(e) =>
|
||||||
placeholder="Pencarian..."
|
handleSearchQueryChange(e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Search..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex">
|
|
||||||
{createCreateButton(props.createButton)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<DashboardTable table={table} />
|
<DashboardTable table={table} />
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{query.data && (
|
{query.data && (
|
||||||
<div className="pt-4 flex flex-col md:flex-row lg:flex-row items-center justify-between gap-2">
|
<div className="pt-4 flex-wrap flex items-center gap-4">
|
||||||
<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
|
<Select
|
||||||
onValueChange={(value) =>
|
label="Per Page"
|
||||||
|
data={["5", "10", "50", "100", "500", "1000"]}
|
||||||
|
allowDeselect={false}
|
||||||
|
defaultValue="10"
|
||||||
|
searchValue={filterOptions.limit.toString()}
|
||||||
|
onChange={(value) =>
|
||||||
setFilterOptions((prev) => ({
|
setFilterOptions((prev) => ({
|
||||||
page: prev.page,
|
page: prev.page,
|
||||||
limit: parseInt(value ?? "10"),
|
limit: parseInt(value ?? "10"),
|
||||||
q: prev.q,
|
q: prev.q,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
defaultValue="10"
|
checkIconPosition="right"
|
||||||
>
|
className="w-20"
|
||||||
<SelectTrigger className="w-fit p-4 gap-4">
|
/>
|
||||||
<SelectValue placeholder="Per Page" />
|
<Pagination
|
||||||
</SelectTrigger>
|
value={filterOptions.page + 1}
|
||||||
<SelectContent>
|
total={query.data._metadata.totalPages}
|
||||||
<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}
|
onChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-4">
|
<Text c="dimmed" size="sm">
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
Showing {query.data.data.length} of{" "}
|
||||||
Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems}
|
{query.data._metadata.totalItems}
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* The Modals */}
|
||||||
{props.modals?.map((modal, index) => (
|
{props.modals?.map((modal, index) => (
|
||||||
<React.Fragment key={index}>{modal}</React.Fragment>
|
<React.Fragment key={index}>{modal}</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,11 @@ interface AuthContextType {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
role: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
saveAuthData: (
|
saveAuthData: (
|
||||||
userData: { id: string; name: string; permissions: string[]; role: string },
|
userData: NonNullable<AuthContextType["user"]>,
|
||||||
accessToken?: string
|
accessToken?: NonNullable<AuthContextType["accessToken"]>
|
||||||
) => void;
|
) => void;
|
||||||
clearAuthData: () => void;
|
clearAuthData: () => void;
|
||||||
checkPermission: (permission: string) => boolean;
|
checkPermission: (permission: string) => boolean;
|
||||||
|
|
@ -26,7 +25,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [userId, setUserId] = useState<string | null>(null);
|
const [userId, setUserId] = useState<string | null>(null);
|
||||||
const [userName, setUserName] = useState<string | null>(null);
|
const [userName, setUserName] = useState<string | null>(null);
|
||||||
const [permissions, setPermissions] = useState<string[] | null>(null);
|
const [permissions, setPermissions] = useState<string[] | null>(null);
|
||||||
const [role, setRole] = useState<string | null>(null);
|
|
||||||
const [accessToken, setAccessToken] = useState<string | null>(
|
const [accessToken, setAccessToken] = useState<string | null>(
|
||||||
localStorage.getItem("accessToken")
|
localStorage.getItem("accessToken")
|
||||||
);
|
);
|
||||||
|
|
@ -38,7 +36,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setUserId(userData.id);
|
setUserId(userData.id);
|
||||||
setUserName(userData.name);
|
setUserName(userData.name);
|
||||||
setPermissions(userData.permissions);
|
setPermissions(userData.permissions);
|
||||||
setRole(userData.role);
|
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
setAccessToken(accessToken);
|
setAccessToken(accessToken);
|
||||||
localStorage.setItem("accessToken", accessToken);
|
localStorage.setItem("accessToken", accessToken);
|
||||||
|
|
@ -49,7 +46,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setUserId(null);
|
setUserId(null);
|
||||||
setUserName(null);
|
setUserName(null);
|
||||||
setPermissions(null);
|
setPermissions(null);
|
||||||
setRole(null);
|
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
localStorage.removeItem("accessToken");
|
localStorage.removeItem("accessToken");
|
||||||
};
|
};
|
||||||
|
|
@ -64,7 +60,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user: userId
|
user: userId
|
||||||
? { id: userId, name: userName!, permissions: permissions!, role: role! }
|
? { id: userId, name: userName!, permissions: permissions! }
|
||||||
: null,
|
: null,
|
||||||
accessToken,
|
accessToken,
|
||||||
saveAuthData,
|
saveAuthData,
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,3 @@
|
||||||
@apply bg-background text-foreground;
|
@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;
|
|
||||||
}
|
|
||||||
|
|
@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import "./styles/tailwind.css";
|
import "./styles/tailwind.css";
|
||||||
import "./styles/fonts/inter.css";
|
import "./styles/fonts/manrope.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
@ -1,236 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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 },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
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),
|
|
||||||
});
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,348 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
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
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,14 +1,5 @@
|
||||||
import client from "@/honoClient";
|
import client from "@/honoClient";
|
||||||
import {
|
import { Button, Flex, Modal, Text } from "@mantine/core";
|
||||||
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { getRouteApi, useSearch } from "@tanstack/react-router";
|
import { getRouteApi, useSearch } from "@tanstack/react-router";
|
||||||
import { deleteUser } from "../queries/userQueries";
|
import { deleteUser } from "../queries/userQueries";
|
||||||
|
|
@ -69,34 +60,40 @@ export default function UserDeleteModal() {
|
||||||
const isModalOpen = Boolean(searchParams.delete && userQuery.data);
|
const isModalOpen = Boolean(searchParams.delete && userQuery.data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertDialog open={isModalOpen} onOpenChange={() => navigate({ search: {} })}>
|
<Modal
|
||||||
<AlertDialogContent>
|
opened={isModalOpen}
|
||||||
<AlertDialogHeader>
|
onClose={() => navigate({ search: {} })}
|
||||||
<AlertDialogTitle>Konfirmasi Hapus</AlertDialogTitle>
|
title={`Delete confirmation`}
|
||||||
<AlertDialogDescription>
|
>
|
||||||
Apakah Anda yakin ingin menghapus pengguna{" "}
|
<Text size="sm">
|
||||||
<strong>{userQuery.data?.name}</strong>?
|
Are you sure you want to delete user{" "}
|
||||||
<br />
|
<Text span fw={700}>
|
||||||
Tindakan ini tidak dapat diubah.
|
{userQuery.data?.name}
|
||||||
</AlertDialogDescription>
|
</Text>
|
||||||
</AlertDialogHeader>
|
? This action is irreversible.
|
||||||
<AlertDialogFooter>
|
</Text>
|
||||||
<AlertDialogCancel
|
|
||||||
|
{/* {errorMessage && <Alert color="red">{errorMessage}</Alert>} */}
|
||||||
|
{/* Buttons */}
|
||||||
|
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={() => navigate({ search: {} })}
|
onClick={() => navigate({ search: {} })}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
>
|
>
|
||||||
Batal
|
Cancel
|
||||||
</AlertDialogCancel>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
color="red"
|
|
||||||
onClick={() => mutation.mutate({ id: userId })}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
>
|
|
||||||
{mutation.isPending ? "Hapus..." : "Hapus Pengguna"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogFooter>
|
<Button
|
||||||
</AlertDialogContent>
|
variant="subtle"
|
||||||
</AlertDialog>
|
// leftSection={<TbDeviceFloppy size={20} />}
|
||||||
|
type="submit"
|
||||||
|
color="red"
|
||||||
|
loading={mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate({ id: userId })}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
import client from "../../../honoClient";
|
|
||||||
import stringToColorHex from "@/utils/stringToColorHex";
|
import stringToColorHex from "@/utils/stringToColorHex";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
AvatarFallback,
|
Button,
|
||||||
AvatarImage
|
Center,
|
||||||
} from "@/shadcn/components/ui/avatar";
|
Flex,
|
||||||
import { Modal, ScrollArea } from "@mantine/core";
|
Modal,
|
||||||
import { Button } from "@/shadcn/components/ui/button";
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useForm } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { getRouteApi } from "@tanstack/react-router";
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
import { createUser, updateUser } from "../queries/userQueries";
|
import { createUser, updateUser } from "../queries/userQueries";
|
||||||
|
import { TbDeviceFloppy } from "react-icons/tb";
|
||||||
|
import client from "../../../honoClient";
|
||||||
import { getUserByIdQueryOptions } from "../queries/userQueries";
|
import { getUserByIdQueryOptions } from "../queries/userQueries";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
@ -39,7 +42,7 @@ export default function UserFormModal() {
|
||||||
const detailId = searchParams.detail;
|
const detailId = searchParams.detail;
|
||||||
const editId = searchParams.edit;
|
const editId = searchParams.edit;
|
||||||
|
|
||||||
const formType = detailId ? "detail" : editId ? "ubah" : "tambah";
|
const formType = detailId ? "detail" : editId ? "edit" : "create";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CHANGE FOLLOWING:
|
* CHANGE FOLLOWING:
|
||||||
|
|
@ -48,7 +51,7 @@ export default function UserFormModal() {
|
||||||
const userQuery = useQuery(getUserByIdQueryOptions(dataId));
|
const userQuery = useQuery(getUserByIdQueryOptions(dataId));
|
||||||
|
|
||||||
const modalTitle =
|
const modalTitle =
|
||||||
formType.charAt(0).toUpperCase() + formType.slice(1) + " Pengguna";
|
formType.charAt(0).toUpperCase() + formType.slice(1) + " User";
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
|
@ -59,11 +62,6 @@ export default function UserFormModal() {
|
||||||
photoProfileUrl: "",
|
photoProfileUrl: "",
|
||||||
password: "",
|
password: "",
|
||||||
roles: [] as string[],
|
roles: [] as string[],
|
||||||
companyName: "",
|
|
||||||
position: "",
|
|
||||||
workExperience: "",
|
|
||||||
address: "",
|
|
||||||
phoneNumber: "",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -77,17 +75,12 @@ export default function UserFormModal() {
|
||||||
|
|
||||||
form.setValues({
|
form.setValues({
|
||||||
id: data.id,
|
id: data.id,
|
||||||
email: data.email,
|
email: data.email ?? "",
|
||||||
name: data.name,
|
name: data.name,
|
||||||
photoProfileUrl: "",
|
photoProfileUrl: "",
|
||||||
username: data.username,
|
username: data.username,
|
||||||
password: "",
|
password: "",
|
||||||
roles: data.roles.map((v) => v.id), //only extract the id
|
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({});
|
form.setErrors({});
|
||||||
|
|
@ -98,11 +91,11 @@ export default function UserFormModal() {
|
||||||
mutationKey: ["usersMutation"],
|
mutationKey: ["usersMutation"],
|
||||||
mutationFn: async (
|
mutationFn: async (
|
||||||
options:
|
options:
|
||||||
| { action: "ubah"; data: Parameters<typeof updateUser>[0] }
|
| { action: "edit"; data: Parameters<typeof updateUser>[0] }
|
||||||
| { action: "tambah"; data: Parameters<typeof createUser>[0] }
|
| { action: "create"; data: Parameters<typeof createUser>[0] }
|
||||||
) => {
|
) => {
|
||||||
console.log("called");
|
console.log("called");
|
||||||
return options.action === "ubah"
|
return options.action === "edit"
|
||||||
? await updateUser(options.data)
|
? await updateUser(options.data)
|
||||||
: await createUser(options.data);
|
: await createUser(options.data);
|
||||||
},
|
},
|
||||||
|
|
@ -127,21 +120,16 @@ export default function UserFormModal() {
|
||||||
if (formType === "detail") return;
|
if (formType === "detail") return;
|
||||||
|
|
||||||
//TODO: OPtimize this code
|
//TODO: OPtimize this code
|
||||||
if (formType === "tambah") {
|
if (formType === "create") {
|
||||||
await mutation.mutateAsync({
|
await mutation.mutateAsync({
|
||||||
action: formType,
|
action: formType,
|
||||||
data: {
|
data: {
|
||||||
email: values.email,
|
email: values.email,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
roles: values.roles,
|
roles: JSON.stringify(values.roles),
|
||||||
isEnabled: "true",
|
isEnabled: "true",
|
||||||
username: values.username,
|
username: values.username,
|
||||||
companyName: values.companyName,
|
|
||||||
position: values.position,
|
|
||||||
workExperience: values.workExperience,
|
|
||||||
address: values.address,
|
|
||||||
phoneNumber: values.phoneNumber,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -152,20 +140,15 @@ export default function UserFormModal() {
|
||||||
email: values.email,
|
email: values.email,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
roles: values.roles,
|
roles: JSON.stringify(values.roles),
|
||||||
isEnabled: "true",
|
isEnabled: "true",
|
||||||
username: values.username,
|
username: values.username,
|
||||||
companyName: values.companyName,
|
|
||||||
position: values.position,
|
|
||||||
workExperience: values.workExperience,
|
|
||||||
address: values.address,
|
|
||||||
phoneNumber: values.phoneNumber,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: `Pengguna berhasil di${formType === "tambah" ? "tambahkan" : "perbarui"}`,
|
message: `The ser is ${formType === "create" ? "created" : "edited"}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate({ search: {} });
|
navigate({ search: {} });
|
||||||
|
|
@ -196,36 +179,39 @@ export default function UserFormModal() {
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
<div className="flex items-center justify-center my-2">
|
<Stack mt="sm" gap="lg" px="lg">
|
||||||
<div className="h-120 w-120 rounded-full overflow-hidden">
|
{/* Avatar */}
|
||||||
<Avatar className="h-[120px] w-[120px] rounded-full overflow-hidden">
|
<Center>
|
||||||
<AvatarImage src={form.values.photoProfileUrl} alt={form.values.name} />
|
<Avatar
|
||||||
<AvatarFallback style={{ backgroundColor: stringToColorHex(form.values.id ?? ""), fontSize: "60px", color: "white", fontWeight: "bold" }}>
|
color={stringToColorHex(form.values.id ?? "")}
|
||||||
{form.values.name?.[0]?.toUpperCase() ?? "?"}
|
src={form.values.photoProfileUrl}
|
||||||
</AvatarFallback>
|
size={120}
|
||||||
|
>
|
||||||
|
{form.values.name?.[0]?.toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</Center>
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
<ScrollArea className="h-72 pr-4">
|
|
||||||
{createInputComponents({
|
{createInputComponents({
|
||||||
disableAll: mutation.isPending,
|
disableAll: mutation.isPending,
|
||||||
readonlyAll: formType === "detail",
|
readonlyAll: formType === "detail",
|
||||||
inputs: [
|
inputs: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Nama",
|
readOnly: true,
|
||||||
|
variant: "filled",
|
||||||
|
...form.getInputProps("id"),
|
||||||
|
hidden: !form.values.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Name",
|
||||||
...form.getInputProps("name"),
|
...form.getInputProps("name"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Jabatan",
|
label: "Username",
|
||||||
...form.getInputProps("position"),
|
...form.getInputProps("username"),
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
label: "Pengalaman Kerja",
|
|
||||||
...form.getInputProps("workExperience"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|
@ -233,21 +219,11 @@ export default function UserFormModal() {
|
||||||
...form.getInputProps("email"),
|
...form.getInputProps("email"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "password",
|
||||||
label: "Instansi/Perusahaan",
|
label: "Password",
|
||||||
...form.getInputProps("companyName"),
|
hidden: formType !== "create",
|
||||||
|
...form.getInputProps("password"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
label: "Alamat",
|
|
||||||
...form.getInputProps("address"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
label: "Nomor Telepon",
|
|
||||||
...form.getInputProps("phoneNumber"),
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "multi-select",
|
type: "multi-select",
|
||||||
label: "Roles",
|
label: "Roles",
|
||||||
|
|
@ -260,40 +236,29 @@ export default function UserFormModal() {
|
||||||
})),
|
})),
|
||||||
error: form.errors.roles,
|
error: form.errors.roles,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
label: "Username",
|
|
||||||
...form.getInputProps("username"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "password",
|
|
||||||
label: "Password",
|
|
||||||
hidden: formType !== "tambah",
|
|
||||||
...form.getInputProps("password"),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="flex justify-end align-center gap-1 mt-4">
|
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate({ search: {} })}
|
onClick={() => navigate({ search: {} })}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
>
|
>
|
||||||
Tutup
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
{formType !== "detail" && (
|
{formType !== "detail" && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="filled"
|
||||||
|
leftSection={<TbDeviceFloppy size={20} />}
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={mutation.isPending}
|
loading={mutation.isPending}
|
||||||
>
|
>
|
||||||
Simpan
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Flex>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,26 +34,26 @@ export const getUserByIdQueryOptions = (userId: string | undefined) =>
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createUser = async (
|
export const createUser = async (
|
||||||
json: InferRequestType<typeof client.users.$post>["json"]
|
form: InferRequestType<typeof client.users.$post>["form"]
|
||||||
) => {
|
) => {
|
||||||
return await fetchRPC(
|
return await fetchRPC(
|
||||||
client.users.$post({
|
client.users.$post({
|
||||||
json,
|
form,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUser = async (
|
export const updateUser = async (
|
||||||
json: InferRequestType<(typeof client.users)[":id"]["$patch"]>["json"] & {
|
form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
return await fetchRPC(
|
return await fetchRPC(
|
||||||
client.users[":id"].$patch({
|
client.users[":id"].$patch({
|
||||||
param: {
|
param: {
|
||||||
id: json.id,
|
id: form.id,
|
||||||
},
|
},
|
||||||
json,
|
form,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createColumnHelper } from "@tanstack/react-table";
|
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 { TbEye, TbPencil, TbTrash } from "react-icons/tb";
|
||||||
import { CrudPermission } from "@/types";
|
import { CrudPermission } from "@/types";
|
||||||
import stringToColorHex from "@/utils/stringToColorHex";
|
import stringToColorHex from "@/utils/stringToColorHex";
|
||||||
|
|
@ -6,8 +7,6 @@ import createActionButtons from "@/utils/createActionButton";
|
||||||
import client from "@/honoClient";
|
import client from "@/honoClient";
|
||||||
import { InferResponseType } from "hono";
|
import { InferResponseType } from "hono";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { Badge } from "@/shadcn/components/ui/badge";
|
|
||||||
import { Avatar } from "@/shadcn/components/ui/avatar";
|
|
||||||
|
|
||||||
interface ColumnOptions {
|
interface ColumnOptions {
|
||||||
permissions: Partial<CrudPermission>;
|
permissions: Partial<CrudPermission>;
|
||||||
|
|
@ -30,28 +29,31 @@ const createColumns = (options: ColumnOptions) => {
|
||||||
columnHelper.accessor("name", {
|
columnHelper.accessor("name", {
|
||||||
header: "Name",
|
header: "Name",
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<div className="items-center justify-center gap-2">
|
<Group>
|
||||||
<Avatar
|
<Avatar
|
||||||
style={{ backgroundColor: stringToColorHex(props.row.original.id), width: '26px', height: '26px' }}
|
color={stringToColorHex(props.row.original.id)}
|
||||||
// src={props.row.original.photoUrl}
|
// src={props.row.original.photoUrl}
|
||||||
|
size={26}
|
||||||
>
|
>
|
||||||
{props.getValue()?.[0].toUpperCase()}
|
{props.getValue()?.[0].toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-sm font-medium">
|
<Text size="sm" fw={500}>
|
||||||
{props.getValue()}
|
{props.getValue()}
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
</Group>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
columnHelper.accessor("email", {
|
columnHelper.accessor("email", {
|
||||||
header: "Email",
|
header: "Email",
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<Link
|
<Anchor
|
||||||
href={`mailto:${props.getValue()}`}
|
to={`mailto:${props.getValue()}`}
|
||||||
|
size="sm"
|
||||||
|
component={Link}
|
||||||
>
|
>
|
||||||
{props.getValue()}
|
{props.getValue()}
|
||||||
</Link>
|
</Anchor>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -64,7 +66,7 @@ const createColumns = (options: ColumnOptions) => {
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<Badge variant={props.row.original.isEnabled ? "default" : "secondary"}>
|
<Badge color={props.row.original.isEnabled ? "green" : "gray"}>
|
||||||
{props.row.original.isEnabled ? "Active" : "Inactive"}
|
{props.row.original.isEnabled ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
|
|
@ -78,7 +80,7 @@ const createColumns = (options: ColumnOptions) => {
|
||||||
className: "w-fit",
|
className: "w-fit",
|
||||||
},
|
},
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<div className="gap-8">
|
<Flex gap="xs">
|
||||||
{createActionButtons([
|
{createActionButtons([
|
||||||
{
|
{
|
||||||
label: "Detail",
|
label: "Detail",
|
||||||
|
|
@ -102,7 +104,7 @@ const createColumns = (options: ColumnOptions) => {
|
||||||
icon: <TbTrash />,
|
icon: <TbTrash />,
|
||||||
},
|
},
|
||||||
])}
|
])}
|
||||||
</div>
|
</Flex>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -13,93 +13,38 @@ import { createFileRoute } from '@tanstack/react-router'
|
||||||
// Import Routes
|
// Import Routes
|
||||||
|
|
||||||
import { Route as rootRoute } from './routes/__root'
|
import { Route as rootRoute } from './routes/__root'
|
||||||
import { Route as VerifyingLayoutImport } from './routes/_verifyingLayout'
|
|
||||||
import { Route as DashboardLayoutImport } from './routes/_dashboardLayout'
|
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 DashboardLayoutUsersIndexImport } from './routes/_dashboardLayout/users/index'
|
||||||
import { Route as DashboardLayoutTimetableIndexImport } from './routes/_dashboardLayout/timetable/index'
|
import { Route as DashboardLayoutTimetableIndexImport } from './routes/_dashboardLayout/timetable/index'
|
||||||
import { Route as DashboardLayoutQuestionsIndexImport } from './routes/_dashboardLayout/questions/index'
|
import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboardLayout/dashboard/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
|
// Create Virtual Routes
|
||||||
|
|
||||||
const IndexLazyImport = createFileRoute('/')()
|
const IndexLazyImport = createFileRoute('/')()
|
||||||
const RegisterIndexLazyImport = createFileRoute('/register/')()
|
|
||||||
const LogoutIndexLazyImport = createFileRoute('/logout/')()
|
const LogoutIndexLazyImport = createFileRoute('/logout/')()
|
||||||
const ForgotPasswordIndexLazyImport = createFileRoute('/forgot-password/')()
|
const LoginIndexLazyImport = createFileRoute('/login/')()
|
||||||
const ForgotPasswordVerifyLazyImport = createFileRoute(
|
|
||||||
'/forgot-password/verify',
|
|
||||||
)()
|
|
||||||
|
|
||||||
// Create/Update Routes
|
// Create/Update Routes
|
||||||
|
|
||||||
const VerifyingLayoutRoute = VerifyingLayoutImport.update({
|
|
||||||
id: '/_verifyingLayout',
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
const DashboardLayoutRoute = DashboardLayoutImport.update({
|
const DashboardLayoutRoute = DashboardLayoutImport.update({
|
||||||
id: '/_dashboardLayout',
|
id: '/_dashboardLayout',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
const AssessmentLayoutRoute = AssessmentLayoutImport.update({
|
|
||||||
id: '/_assessmentLayout',
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
const IndexLazyRoute = IndexLazyImport.update({
|
const IndexLazyRoute = IndexLazyImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route))
|
} 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({
|
const LogoutIndexLazyRoute = LogoutIndexLazyImport.update({
|
||||||
path: '/logout/',
|
path: '/logout/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any).lazy(() => import('./routes/logout/index.lazy').then((d) => d.Route))
|
} as any).lazy(() => import('./routes/logout/index.lazy').then((d) => d.Route))
|
||||||
|
|
||||||
const ForgotPasswordIndexLazyRoute = ForgotPasswordIndexLazyImport.update({
|
const LoginIndexLazyRoute = LoginIndexLazyImport.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/',
|
path: '/login/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any).lazy(() => import('./routes/login/index.lazy').then((d) => d.Route))
|
||||||
|
|
||||||
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({
|
const DashboardLayoutUsersIndexRoute = DashboardLayoutUsersIndexImport.update({
|
||||||
path: '/users/',
|
path: '/users/',
|
||||||
|
|
@ -114,74 +59,11 @@ const DashboardLayoutTimetableIndexRoute =
|
||||||
getParentRoute: () => DashboardLayoutRoute,
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
const DashboardLayoutQuestionsIndexRoute =
|
const DashboardLayoutDashboardIndexRoute =
|
||||||
DashboardLayoutQuestionsIndexImport.update({
|
DashboardLayoutDashboardIndexImport.update({
|
||||||
path: '/questions/',
|
path: '/dashboard/',
|
||||||
getParentRoute: () => DashboardLayoutRoute,
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
} as any).lazy(() =>
|
} as any)
|
||||||
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
|
// Populate the FileRoutesByPath interface
|
||||||
|
|
||||||
|
|
@ -194,13 +76,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof IndexLazyImport
|
preLoaderRoute: typeof IndexLazyImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
'/_assessmentLayout': {
|
|
||||||
id: '/_assessmentLayout'
|
|
||||||
path: ''
|
|
||||||
fullPath: ''
|
|
||||||
preLoaderRoute: typeof AssessmentLayoutImport
|
|
||||||
parentRoute: typeof rootRoute
|
|
||||||
}
|
|
||||||
'/_dashboardLayout': {
|
'/_dashboardLayout': {
|
||||||
id: '/_dashboardLayout'
|
id: '/_dashboardLayout'
|
||||||
path: ''
|
path: ''
|
||||||
|
|
@ -208,32 +83,11 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof DashboardLayoutImport
|
preLoaderRoute: typeof DashboardLayoutImport
|
||||||
parentRoute: typeof rootRoute
|
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/': {
|
'/login/': {
|
||||||
id: '/login/'
|
id: '/login/'
|
||||||
path: '/login'
|
path: '/login'
|
||||||
fullPath: '/login'
|
fullPath: '/login'
|
||||||
preLoaderRoute: typeof LoginIndexImport
|
preLoaderRoute: typeof LoginIndexLazyImport
|
||||||
parentRoute: typeof rootRoute
|
|
||||||
}
|
|
||||||
'/forgot-password/': {
|
|
||||||
id: '/forgot-password/'
|
|
||||||
path: '/forgot-password'
|
|
||||||
fullPath: '/forgot-password'
|
|
||||||
preLoaderRoute: typeof ForgotPasswordIndexLazyImport
|
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
'/logout/': {
|
'/logout/': {
|
||||||
|
|
@ -243,60 +97,11 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof LogoutIndexLazyImport
|
preLoaderRoute: typeof LogoutIndexLazyImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
'/register/': {
|
'/_dashboardLayout/dashboard/': {
|
||||||
id: '/register/'
|
id: '/_dashboardLayout/dashboard/'
|
||||||
path: '/register'
|
path: '/dashboard'
|
||||||
fullPath: '/register'
|
fullPath: '/dashboard'
|
||||||
preLoaderRoute: typeof RegisterIndexLazyImport
|
preLoaderRoute: typeof DashboardLayoutDashboardIndexImport
|
||||||
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
|
parentRoute: typeof DashboardLayoutImport
|
||||||
}
|
}
|
||||||
'/_dashboardLayout/timetable/': {
|
'/_dashboardLayout/timetable/': {
|
||||||
|
|
@ -313,13 +118,6 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof DashboardLayoutUsersIndexImport
|
preLoaderRoute: typeof DashboardLayoutUsersIndexImport
|
||||||
parentRoute: typeof DashboardLayoutImport
|
parentRoute: typeof DashboardLayoutImport
|
||||||
}
|
}
|
||||||
'/_verifyingLayout/verifying/': {
|
|
||||||
id: '/_verifyingLayout/verifying/'
|
|
||||||
path: '/verifying'
|
|
||||||
fullPath: '/verifying'
|
|
||||||
preLoaderRoute: typeof VerifyingLayoutVerifyingIndexImport
|
|
||||||
parentRoute: typeof VerifyingLayoutImport
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -327,27 +125,13 @@ declare module '@tanstack/react-router' {
|
||||||
|
|
||||||
export const routeTree = rootRoute.addChildren({
|
export const routeTree = rootRoute.addChildren({
|
||||||
IndexLazyRoute,
|
IndexLazyRoute,
|
||||||
AssessmentLayoutRoute: AssessmentLayoutRoute.addChildren({
|
|
||||||
AssessmentLayoutAssessmentIndexRoute,
|
|
||||||
}),
|
|
||||||
DashboardLayoutRoute: DashboardLayoutRoute.addChildren({
|
DashboardLayoutRoute: DashboardLayoutRoute.addChildren({
|
||||||
DashboardLayoutAspectIndexRoute,
|
DashboardLayoutDashboardIndexRoute,
|
||||||
DashboardLayoutAssessmentRequestIndexRoute,
|
|
||||||
DashboardLayoutAssessmentRequestManagementsIndexRoute,
|
|
||||||
DashboardLayoutAssessmentResultIndexRoute,
|
|
||||||
DashboardLayoutAssessmentResultsManagementIndexRoute,
|
|
||||||
DashboardLayoutQuestionsIndexRoute,
|
|
||||||
DashboardLayoutTimetableIndexRoute,
|
DashboardLayoutTimetableIndexRoute,
|
||||||
DashboardLayoutUsersIndexRoute,
|
DashboardLayoutUsersIndexRoute,
|
||||||
}),
|
}),
|
||||||
VerifyingLayoutRoute: VerifyingLayoutRoute.addChildren({
|
LoginIndexLazyRoute,
|
||||||
VerifyingLayoutVerifyingIndexRoute,
|
|
||||||
}),
|
|
||||||
ForgotPasswordVerifyLazyRoute,
|
|
||||||
LoginIndexRoute,
|
|
||||||
ForgotPasswordIndexLazyRoute,
|
|
||||||
LogoutIndexLazyRoute,
|
LogoutIndexLazyRoute,
|
||||||
RegisterIndexLazyRoute,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/* prettier-ignore-end */
|
/* prettier-ignore-end */
|
||||||
|
|
@ -359,85 +143,30 @@ export const routeTree = rootRoute.addChildren({
|
||||||
"filePath": "__root.tsx",
|
"filePath": "__root.tsx",
|
||||||
"children": [
|
"children": [
|
||||||
"/",
|
"/",
|
||||||
"/_assessmentLayout",
|
|
||||||
"/_dashboardLayout",
|
"/_dashboardLayout",
|
||||||
"/_verifyingLayout",
|
|
||||||
"/forgot-password/verify",
|
|
||||||
"/login/",
|
"/login/",
|
||||||
"/forgot-password/",
|
"/logout/"
|
||||||
"/logout/",
|
|
||||||
"/register/"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"/": {
|
"/": {
|
||||||
"filePath": "index.lazy.tsx"
|
"filePath": "index.lazy.tsx"
|
||||||
},
|
},
|
||||||
"/_assessmentLayout": {
|
|
||||||
"filePath": "_assessmentLayout.tsx",
|
|
||||||
"children": [
|
|
||||||
"/_assessmentLayout/assessment/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"/_dashboardLayout": {
|
"/_dashboardLayout": {
|
||||||
"filePath": "_dashboardLayout.tsx",
|
"filePath": "_dashboardLayout.tsx",
|
||||||
"children": [
|
"children": [
|
||||||
"/_dashboardLayout/aspect/",
|
"/_dashboardLayout/dashboard/",
|
||||||
"/_dashboardLayout/assessmentRequest/",
|
|
||||||
"/_dashboardLayout/assessmentRequestManagements/",
|
|
||||||
"/_dashboardLayout/assessmentResult/",
|
|
||||||
"/_dashboardLayout/assessmentResultsManagement/",
|
|
||||||
"/_dashboardLayout/questions/",
|
|
||||||
"/_dashboardLayout/timetable/",
|
"/_dashboardLayout/timetable/",
|
||||||
"/_dashboardLayout/users/"
|
"/_dashboardLayout/users/"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"/_verifyingLayout": {
|
|
||||||
"filePath": "_verifyingLayout.tsx",
|
|
||||||
"children": [
|
|
||||||
"/_verifyingLayout/verifying/"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"/forgot-password/verify": {
|
|
||||||
"filePath": "forgot-password/verify.lazy.tsx"
|
|
||||||
},
|
|
||||||
"/login/": {
|
"/login/": {
|
||||||
"filePath": "login/index.tsx"
|
"filePath": "login/index.lazy.tsx"
|
||||||
},
|
|
||||||
"/forgot-password/": {
|
|
||||||
"filePath": "forgot-password/index.lazy.tsx"
|
|
||||||
},
|
},
|
||||||
"/logout/": {
|
"/logout/": {
|
||||||
"filePath": "logout/index.lazy.tsx"
|
"filePath": "logout/index.lazy.tsx"
|
||||||
},
|
},
|
||||||
"/register/": {
|
"/_dashboardLayout/dashboard/": {
|
||||||
"filePath": "register/index.lazy.tsx"
|
"filePath": "_dashboardLayout/dashboard/index.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"
|
"parent": "/_dashboardLayout"
|
||||||
},
|
},
|
||||||
"/_dashboardLayout/timetable/": {
|
"/_dashboardLayout/timetable/": {
|
||||||
|
|
@ -447,10 +176,6 @@ export const routeTree = rootRoute.addChildren({
|
||||||
"/_dashboardLayout/users/": {
|
"/_dashboardLayout/users/": {
|
||||||
"filePath": "_dashboardLayout/users/index.tsx",
|
"filePath": "_dashboardLayout/users/index.tsx",
|
||||||
"parent": "/_dashboardLayout"
|
"parent": "/_dashboardLayout"
|
||||||
},
|
|
||||||
"/_verifyingLayout/verifying/": {
|
|
||||||
"filePath": "_verifyingLayout/verifying/index.tsx",
|
|
||||||
"parent": "/_verifyingLayout"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ interface RouteContext {
|
||||||
|
|
||||||
export const Route = createRootRouteWithContext<RouteContext>()({
|
export const Route = createRootRouteWithContext<RouteContext>()({
|
||||||
component: () => (
|
component: () => (
|
||||||
<div className="font-inter">
|
<div className="font-manrope">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
{/* <TanStackRouterDevtools /> */}
|
<TanStackRouterDevtools />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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
|
|
@ -1,18 +0,0 @@
|
||||||
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));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
|
import { AppShell } from "@mantine/core";
|
||||||
import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router";
|
import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import AppHeader from "../components/AppHeader";
|
import AppHeader from "../components/AppHeader";
|
||||||
import AppNavbar from "../components/AppNavbar";
|
import AppNavbar from "../components/AppNavbar";
|
||||||
import useAuth from "@/hooks/useAuth";
|
import useAuth from "@/hooks/useAuth";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import fetchRPC from "@/utils/fetchRPC";
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
import client from "@/honoClient";
|
import client from "@/honoClient";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_dashboardLayout")({
|
export const Route = createFileRoute("/_dashboardLayout")({
|
||||||
component: DashboardLayout,
|
component: DashboardLayout,
|
||||||
|
|
@ -31,7 +32,6 @@ function DashboardLayout() {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
name: response.name,
|
name: response.name,
|
||||||
permissions: response.permissions,
|
permissions: response.permissions,
|
||||||
role: response.roles[0],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -39,32 +39,29 @@ function DashboardLayout() {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [openNavbar, setNavbarOpen] = useState(true);
|
const [openNavbar, { toggle }] = useDisclosure(false);
|
||||||
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
|
||||||
const toggle = () => {
|
|
||||||
setNavbarOpen(!openNavbar);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleLeftSidebar = () => {
|
|
||||||
setIsLeftSidebarOpen(!isLeftSidebarOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
return isAuthenticated ? (
|
return isAuthenticated ? (
|
||||||
<div className="flex flex-col w-full h-screen overflow-hidden">
|
<AppShell
|
||||||
{/* Header */}
|
padding="md"
|
||||||
<AppHeader toggle={toggle} openNavbar={openNavbar} toggleLeftSidebar={toggleLeftSidebar} />
|
header={{ height: 70 }}
|
||||||
|
navbar={{
|
||||||
|
width: 300,
|
||||||
|
breakpoint: "sm",
|
||||||
|
collapsed: { mobile: !openNavbar },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppHeader openNavbar={openNavbar} toggle={toggle} />
|
||||||
|
|
||||||
{/* Main Content Area */}
|
|
||||||
<div className="flex h-full w-screen overflow-hidden">
|
|
||||||
{/* Sidebar */}
|
|
||||||
<AppNavbar />
|
<AppNavbar />
|
||||||
|
|
||||||
{/* Main Content */}
|
<AppShell.Main
|
||||||
<main className="relative w-full mt-16 p-6 bg-white overflow-auto">
|
className="bg-slate-100"
|
||||||
|
styles={{ main: { backgroundColor: "rgb(241 245 249)" } }}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</AppShell.Main>
|
||||||
</div>
|
</AppShell>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/login" />
|
<Navigate to="/login" />
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
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>
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,831 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
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
Loading…
Reference in New Issue
Block a user