Merge pull request 'Pull Request branch dev-clone to main' (#1) from dev-clone into main
Reviewed-on: #1
This commit is contained in:
commit
325306bd93
|
|
@ -1,7 +1,14 @@
|
||||||
|
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
Normal file
2
apps/backend/files/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"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"
|
||||||
|
|
@ -27,6 +28,7 @@
|
||||||
"@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,11 +4,17 @@ 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,6 +32,102 @@ 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,10 +17,18 @@ 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" | "*";
|
export type RoleCode = "super-admin" | "user" | "*";
|
||||||
|
|
||||||
const exportedRoleData = roleData;
|
const exportedRoleData = roleData;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,43 @@ import { SidebarMenu } from "../types";
|
||||||
|
|
||||||
const sidebarMenus: SidebarMenu[] = [
|
const sidebarMenus: SidebarMenu[] = [
|
||||||
{
|
{
|
||||||
label: "Dashboard",
|
label: "Manajemen Pengguna",
|
||||||
icon: { tb: "TbLayoutDashboard" },
|
icon: { tb: "TbUser" },
|
||||||
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",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
24
apps/backend/src/drizzle/schema/answerRevisions.ts
Normal file
24
apps/backend/src/drizzle/schema/answerRevisions.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { options } from "./options";
|
||||||
|
import { answers } from "./answers";
|
||||||
|
|
||||||
|
export const answerRevisions = pgTable("answer_revisions", {
|
||||||
|
id: varchar("id", { length: 50 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
answerId: varchar("answerId", { length: 50 })
|
||||||
|
.references(() => answers.id),
|
||||||
|
newOptionId: varchar("newOptionId", { length: 50 })
|
||||||
|
.references(() => options.id),
|
||||||
|
revisedBy: varchar("revisedBy").notNull(),
|
||||||
|
newValidationInformation: text("newValidationInformation").notNull(),
|
||||||
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
|
});
|
||||||
29
apps/backend/src/drizzle/schema/answers.ts
Normal file
29
apps/backend/src/drizzle/schema/answers.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { options } from "./options";
|
||||||
|
import { assessments } from "./assessments";
|
||||||
|
import { questions } from "./questions";
|
||||||
|
|
||||||
|
export const answers = pgTable("answers", {
|
||||||
|
id: varchar("id", { length: 50 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
questionId: varchar("questionId", { length: 50 })
|
||||||
|
.references(() => questions.id),
|
||||||
|
optionId: varchar("optionId", { length: 50 })
|
||||||
|
.references(() => options.id),
|
||||||
|
assessmentId: varchar("assessmentId", { length: 50 })
|
||||||
|
.references(() => assessments.id),
|
||||||
|
isFlagged: boolean("isFlagged").default(false),
|
||||||
|
filename: varchar("filename"),
|
||||||
|
validationInformation: text("validationInformation").notNull(),
|
||||||
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
|
||||||
|
});
|
||||||
16
apps/backend/src/drizzle/schema/aspects.ts
Normal file
16
apps/backend/src/drizzle/schema/aspects.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import {
|
||||||
|
pgTable,
|
||||||
|
timestamp,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
export const aspects = pgTable("aspects", {
|
||||||
|
id: varchar("id", { length: 50 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
|
||||||
|
deletedAt: timestamp("deletedAt", { mode: "date" }),
|
||||||
|
});
|
||||||
23
apps/backend/src/drizzle/schema/assessments.ts
Normal file
23
apps/backend/src/drizzle/schema/assessments.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { pgEnum, pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { respondents } from "./respondents";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
export const statusEnum = pgEnum("status", ["menunggu konfirmasi", "diterima", "ditolak",
|
||||||
|
"dalam pengerjaan", "belum diverifikasi", "selesai"]);
|
||||||
|
|
||||||
|
export const assessments = pgTable("assessments", {
|
||||||
|
id: varchar("id", { length: 50 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
respondentId: varchar("respondentId").references(() => respondents.id),
|
||||||
|
status: statusEnum("status"),
|
||||||
|
reviewedBy: varchar("reviewedBy"),
|
||||||
|
reviewedAt: timestamp("reviewedAt", { mode: "date" }),
|
||||||
|
verifiedBy: varchar("verifiedBy"),
|
||||||
|
verifiedAt: timestamp("verifiedAt", { mode: "date" }),
|
||||||
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
|
});
|
||||||
|
// Query Tools in PosgreSQL
|
||||||
|
// CREATE TYPE status AS ENUM ('menunggu konfirmasi', 'diterima', 'ditolak', 'dalam pengerjaan', 'belum diverifikasi', 'selesai');
|
||||||
23
apps/backend/src/drizzle/schema/options.ts
Normal file
23
apps/backend/src/drizzle/schema/options.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { questions } from "./questions";
|
||||||
|
|
||||||
|
export const options = pgTable("options", {
|
||||||
|
id: varchar("id", { length: 50 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
questionId: varchar("questionId", { length: 50 })
|
||||||
|
.references(() => questions.id),
|
||||||
|
text: text("text").notNull(),
|
||||||
|
score: integer("score").notNull(),
|
||||||
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
|
||||||
|
deletedAt: timestamp("deletedAt", { mode: "date" }),
|
||||||
|
});
|
||||||
|
|
@ -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: text("id")
|
id: varchar("id", { length: 50 })
|
||||||
.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("created_at").defaultNow(),
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const permissionsRelations = relations(
|
export const permissionsRelations = relations(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||||
import { permissionsSchema } from "./permissions";
|
import { 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: text("roleId")
|
roleId: varchar("roleId", { length: 50 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => rolesSchema.id),
|
.references(() => rolesSchema.id),
|
||||||
permissionId: text("permissionId")
|
permissionId: varchar("permissionId", { length: 50 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => permissionsSchema.id),
|
.references(() => permissionsSchema.id),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||||
import { users } from "./users";
|
import { 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: text("userId")
|
userId: varchar("userId", { length: 50 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
permissionId: text("permissionId")
|
permissionId: varchar("permissionId", { length: 50 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => permissionsSchema.id),
|
.references(() => permissionsSchema.id),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
21
apps/backend/src/drizzle/schema/questions.ts
Normal file
21
apps/backend/src/drizzle/schema/questions.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { subAspects } from "./subAspects"
|
||||||
|
|
||||||
|
export const questions = pgTable("questions", {
|
||||||
|
id: varchar("id", { length: 50 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
subAspectId: varchar("subAspectId").references(() => subAspects.id),
|
||||||
|
question: text("question"),
|
||||||
|
needFile: boolean("needFile").default(false),
|
||||||
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
|
||||||
|
deletedAt: timestamp("deletedAt", { mode: "date" }),
|
||||||
|
});
|
||||||
26
apps/backend/src/drizzle/schema/respondents.ts
Normal file
26
apps/backend/src/drizzle/schema/respondents.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { users } from "./users";
|
||||||
|
|
||||||
|
export const respondents = pgTable("respondents", {
|
||||||
|
id: varchar("id", { length: 50 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
userId: varchar('userId').references(() => users.id).unique(),
|
||||||
|
companyName: varchar("companyName").notNull(),
|
||||||
|
position: varchar("position").notNull(),
|
||||||
|
workExperience: varchar("workExperience").notNull(),
|
||||||
|
address: text("address").notNull(),
|
||||||
|
phoneNumber: varchar("phoneNumber", { length: 13 }).notNull(),
|
||||||
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
|
||||||
|
deletedAt: timestamp("deletedAt", { mode: "date" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const respondentsRelations = relations(respondents, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [respondents.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
@ -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: text("id")
|
id: varchar("id", { length: 50 })
|
||||||
.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("created_at").defaultNow(),
|
createdAt: timestamp("createdAt").defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updatedAt").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const rolesRelations = relations(rolesSchema, ({ many }) => ({
|
export const rolesRelations = relations(rolesSchema, ({ many }) => ({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||||
import { users } from "./users";
|
import { 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: text("userId")
|
userId: varchar("userId", { length: 50 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
roleId: text("roleId")
|
roleId: varchar("roleId", { length: 50 })
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => rolesSchema.id),
|
.references(() => rolesSchema.id),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
14
apps/backend/src/drizzle/schema/subAspects.ts
Normal file
14
apps/backend/src/drizzle/schema/subAspects.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { pgTable, timestamp, varchar } from "drizzle-orm/pg-core";
|
||||||
|
import { aspects } from "./aspects";
|
||||||
|
|
||||||
|
export const subAspects = pgTable("sub_aspects", {
|
||||||
|
id: varchar("id", { length: 50 })
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
aspectId: varchar("aspectId").references(() => aspects.id),
|
||||||
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
|
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
|
||||||
|
deletedAt: timestamp("deletedAt", { mode: "date" }),
|
||||||
|
});
|
||||||
|
|
@ -9,22 +9,28 @@ 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: text("id")
|
id: varchar("id", { length: 50 })
|
||||||
.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"),
|
email: varchar("email").notNull().unique(),
|
||||||
password: text("password").notNull(),
|
password: text("password").notNull(),
|
||||||
isEnabled: boolean("is_enable").default(true),
|
isEnabled: boolean("isEnabled").default(true),
|
||||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow(),
|
resetPasswordToken: varchar("resetPasswordToken"),
|
||||||
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow(),
|
createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(),
|
||||||
deletedAt: timestamp("deleted_at", { mode: "date" }),
|
updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(),
|
||||||
|
deletedAt: timestamp("deletedAt", { mode: "date" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const usersRelations = relations(users, ({ many }) => ({
|
export const usersRelations = relations(users, ({ many, one}) => ({
|
||||||
permissionsToUsers: many(permissionsToUsers),
|
permissionsToUsers: many(permissionsToUsers),
|
||||||
rolesToUsers: many(rolesToUsers),
|
rolesToUsers: many(rolesToUsers),
|
||||||
|
respondent: one(respondents, {
|
||||||
|
fields: [users.id],
|
||||||
|
references: [respondents.userId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@ 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");
|
||||||
|
|
@ -9,6 +16,13 @@ import userSeeder from "./seeds/userSeeder";
|
||||||
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");
|
||||||
|
|
|
||||||
62
apps/backend/src/drizzle/seeds/answersSeeder.ts
Normal file
62
apps/backend/src/drizzle/seeds/answersSeeder.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import db from "..";
|
||||||
|
import { answers } from "../schema/answers";
|
||||||
|
import { options } from "../schema/options";
|
||||||
|
import { assessments } from "../schema/assessments";
|
||||||
|
|
||||||
|
type answers = {
|
||||||
|
id: string;
|
||||||
|
optionId: string;
|
||||||
|
assessmentId: string | null;
|
||||||
|
isFlagged: boolean;
|
||||||
|
filename: string;
|
||||||
|
validationInformation: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const answersSeeder = async () => {
|
||||||
|
const optionsData = await db.select().from(options);
|
||||||
|
const assessmentData = await db.select().from(assessments);
|
||||||
|
|
||||||
|
const answersData: answers[] = [];
|
||||||
|
|
||||||
|
optionsData.forEach(option => {
|
||||||
|
// assessmentData.forEach(assessment => {
|
||||||
|
answersData.push({
|
||||||
|
id: createId(),
|
||||||
|
optionId: option.id,
|
||||||
|
assessmentId: null,
|
||||||
|
isFlagged: false,
|
||||||
|
filename: "answer.pdf",
|
||||||
|
validationInformation: "Sudah teruji dengan baik",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Seeding answers...");
|
||||||
|
|
||||||
|
for (let submitAnswers of answersData) {
|
||||||
|
try {
|
||||||
|
const insertedAnswers = (
|
||||||
|
await db
|
||||||
|
.insert(answers)
|
||||||
|
.values(submitAnswers)
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (insertedAnswers) {
|
||||||
|
console.log(`Answers created`);
|
||||||
|
} else {
|
||||||
|
console.log(`Answers already exists`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error inserting answers :`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default answersSeeder;
|
||||||
63
apps/backend/src/drizzle/seeds/aspectsSeeder.ts
Normal file
63
apps/backend/src/drizzle/seeds/aspectsSeeder.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { aspects } from "../schema/aspects";
|
||||||
|
import db from "..";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
|
||||||
|
const aspectsSeeder = async () => {
|
||||||
|
const aspectsData: (typeof aspects.$inferInsert)[] = [
|
||||||
|
{
|
||||||
|
name: "Identifikasi",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Proteksi",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Deteksi",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Penanggulangan dan Pemulihan",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log("Seeding aspects...");
|
||||||
|
|
||||||
|
for (let aspect of aspectsData) {
|
||||||
|
// Check if the aspect already exists
|
||||||
|
const existingAspect = await db
|
||||||
|
.select()
|
||||||
|
.from(aspects)
|
||||||
|
.where(eq(aspects.name, aspect.name))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingAspect.length === 0) {
|
||||||
|
// If the aspect does not exist, insert it
|
||||||
|
const insertedAspect = (
|
||||||
|
await db
|
||||||
|
.insert(aspects)
|
||||||
|
.values(aspect)
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (insertedAspect) {
|
||||||
|
console.log(`Aspect ${aspect.name} created`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Aspect ${aspect.name} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default aspectsSeeder;
|
||||||
103
apps/backend/src/drizzle/seeds/assessmentsSeeder.ts
Normal file
103
apps/backend/src/drizzle/seeds/assessmentsSeeder.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import db from "..";
|
||||||
|
import { assessments } from "../schema/assessments";
|
||||||
|
import { respondents } from "../schema/respondents";
|
||||||
|
import { users } from "../schema/users";
|
||||||
|
|
||||||
|
type AssessmentData = {
|
||||||
|
id: string;
|
||||||
|
status: "menunggu konfirmasi" | "diterima" | "ditolak" | "dalam pengerjaan" | "belum diverifikasi" | "selesai";
|
||||||
|
reviewedAt: Date;
|
||||||
|
reviewedBy: string;
|
||||||
|
verifiedBy: string;
|
||||||
|
verifiedAt: Date;
|
||||||
|
createdAt: Date,
|
||||||
|
respondentId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const assessmentsSeeder = async () => {
|
||||||
|
const respondentsData = await db.select().from(respondents);
|
||||||
|
|
||||||
|
const assessmentsData: AssessmentData[] = [];
|
||||||
|
|
||||||
|
respondentsData.forEach((respondent) => {
|
||||||
|
assessmentsData.push({
|
||||||
|
id: createId(),
|
||||||
|
status: "diterima",
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewedBy: "Reviewer B",
|
||||||
|
verifiedBy: "Reviewer A",
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
respondentId: respondent.id,
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
assessmentsData.push({
|
||||||
|
id: createId(),
|
||||||
|
status: "ditolak",
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewedBy: "Reviewer C",
|
||||||
|
verifiedBy: "Reviewer D",
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
respondentId: respondent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
assessmentsData.push({
|
||||||
|
id: createId(),
|
||||||
|
status: "menunggu konfirmasi",
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewedBy: "Reviewer W",
|
||||||
|
verifiedBy: "Reviewer S",
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
respondentId: respondent.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
assessmentsData.push({
|
||||||
|
id: createId(),
|
||||||
|
status: "selesai",
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewedBy: "Reviewer F",
|
||||||
|
verifiedBy: "Reviewer G",
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
respondentId: respondent.id,
|
||||||
|
});
|
||||||
|
assessmentsData.push({
|
||||||
|
id: createId(),
|
||||||
|
status: "selesai",
|
||||||
|
reviewedAt: new Date(),
|
||||||
|
reviewedBy: "Reviewer I",
|
||||||
|
verifiedBy: "Reviewer L",
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
respondentId: respondent.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Seeding assessments...");
|
||||||
|
|
||||||
|
for (let assessment of assessmentsData) {
|
||||||
|
try {
|
||||||
|
const insertedAssessment = (
|
||||||
|
await db
|
||||||
|
.insert(assessments)
|
||||||
|
.values(assessment)
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (insertedAssessment) {
|
||||||
|
console.log(`Assessment for respondent ${assessment.respondentId} created`);
|
||||||
|
} else {
|
||||||
|
console.log(`Assessment for respondent ${assessment.respondentId} already exists`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error inserting assessment for respondent ${assessment.respondentId}:, error`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default assessmentsSeeder;
|
||||||
9867
apps/backend/src/drizzle/seeds/optionsSeeder.ts
Normal file
9867
apps/backend/src/drizzle/seeds/optionsSeeder.ts
Normal file
File diff suppressed because it is too large
Load Diff
1168
apps/backend/src/drizzle/seeds/questionSeeder.ts
Normal file
1168
apps/backend/src/drizzle/seeds/questionSeeder.ts
Normal file
File diff suppressed because it is too large
Load Diff
110
apps/backend/src/drizzle/seeds/respondentsSeeder.ts
Normal file
110
apps/backend/src/drizzle/seeds/respondentsSeeder.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import db from "..";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { users } from "../schema/users";
|
||||||
|
import { respondents } from "../schema/respondents";
|
||||||
|
|
||||||
|
const respondentSeeder = async () => {
|
||||||
|
const respondentsData: (typeof respondents.$inferInsert & { userName: string })[] = [
|
||||||
|
{
|
||||||
|
userName: "Respondent User 2",
|
||||||
|
companyName: "Company C",
|
||||||
|
position: "Employee",
|
||||||
|
workExperience: "4 years",
|
||||||
|
address: "Address D",
|
||||||
|
phoneNumber: "1234567890123",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userName: "Respondent User 3",
|
||||||
|
companyName: "Company DD",
|
||||||
|
position: "Manager Account",
|
||||||
|
workExperience: "6 years",
|
||||||
|
address: "Address HRT",
|
||||||
|
phoneNumber: "1234567890123",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userName: "Respondent User 4",
|
||||||
|
companyName: "Company FS",
|
||||||
|
position: "Developer",
|
||||||
|
workExperience: "8 years",
|
||||||
|
address: "Address HFD",
|
||||||
|
phoneNumber: "1234567890123",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userName: "Respondent User 5",
|
||||||
|
companyName: "Company DA",
|
||||||
|
position: "Start",
|
||||||
|
workExperience: "3 years",
|
||||||
|
address: "Address JWO",
|
||||||
|
phoneNumber: "1234567890123",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log("Seeding users and respondents...");
|
||||||
|
|
||||||
|
const memoizedUserIds: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
for (let respondent of respondentsData) {
|
||||||
|
if (!memoizedUserIds.has(respondent.userName)) {
|
||||||
|
const user = (
|
||||||
|
await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.name, respondent.userName))
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User ${respondent.userName} does not exist in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
memoizedUserIds.set(respondent.userName, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserId = memoizedUserIds.get(respondent.userName)!;
|
||||||
|
|
||||||
|
const existingRespondent = await db
|
||||||
|
.select()
|
||||||
|
.from(respondents)
|
||||||
|
.where(and(eq(respondents.companyName, respondent.userName), eq(respondents.userId, UserId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingRespondent.length === 0) {
|
||||||
|
const insertedRespondent = (
|
||||||
|
await db
|
||||||
|
.insert(respondents)
|
||||||
|
.values({
|
||||||
|
companyName: respondent.companyName,
|
||||||
|
position: respondent.position,
|
||||||
|
workExperience: respondent.workExperience,
|
||||||
|
address: respondent.address,
|
||||||
|
phoneNumber: respondent.phoneNumber,
|
||||||
|
createdAt: respondent.createdAt,
|
||||||
|
updatedAt: respondent.updatedAt,
|
||||||
|
deletedAt: respondent.deletedAt,
|
||||||
|
userId: UserId,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (insertedRespondent) {
|
||||||
|
console.log(`Respondent ${respondent.companyName} created and linked to respondent ${respondent.userName}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Respondent ${respondent.companyName} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default respondentSeeder;
|
||||||
204
apps/backend/src/drizzle/seeds/subAspectsSeeder.ts
Normal file
204
apps/backend/src/drizzle/seeds/subAspectsSeeder.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { subAspects } from "../schema/subAspects";
|
||||||
|
import db from "..";
|
||||||
|
import { aspects } from "../schema/aspects";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
const subAspectSeeder = async () => {
|
||||||
|
const subAspectsData: (typeof subAspects.$inferInsert & {
|
||||||
|
aspectName: string;
|
||||||
|
})[] = [
|
||||||
|
/////// Aspect 1 identifikasi
|
||||||
|
{
|
||||||
|
name: "Mengidentifikasi Peran dan tanggung jawab organisasi",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Identifikasi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Menyusun strategi, kebijakan, dan prosedur Pelindungan IIV",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Identifikasi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Menilai dan mengelola risiko Keamanan Siber",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Identifikasi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mengelola aset informasi",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Identifikasi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mengelola aset informasi",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Identifikasi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mengelola risiko rantai pasok",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Identifikasi",
|
||||||
|
},
|
||||||
|
/////// Aspect 2 Proteksi
|
||||||
|
{
|
||||||
|
name: "Mengelola identitas, autentikasi, dan kendali akses",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Proteksi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Melindungi aset fisik",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Proteksi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Melindungi data",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Proteksi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Melindungi aplikasi",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Proteksi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Melindungi jaringan",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Proteksi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Melindungi sumber daya manusia",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Proteksi",
|
||||||
|
},
|
||||||
|
/////// Aspect 3 Deteksi
|
||||||
|
{
|
||||||
|
name: "Mengelola deteksi Peristiwa Siber",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Deteksi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Menganalisis anomali dan Peristiwa Siber",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Deteksi",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Memantau Peristiwa Siber berkelanjutan",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Deteksi",
|
||||||
|
},
|
||||||
|
/////// Aspect 4 Gulih
|
||||||
|
{
|
||||||
|
name: "Menyusun perencanaan penanggulangan dan pemulihan Insiden Siber",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Penanggulangan dan Pemulihan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Menganalisis dan melaporkan Insiden Siber",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Penanggulangan dan Pemulihan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Melaksanakan penanggulangan dan pemulihan Insiden Siber",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Penanggulangan dan Pemulihan",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Meningkatkan keamanan setelah terjadinya Insiden Siber",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
deletedAt: null,
|
||||||
|
aspectName: "Penanggulangan dan Pemulihan",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log("Seeding subAspects...");
|
||||||
|
|
||||||
|
const memoizedAspectIds: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
for (let subAspect of subAspectsData) {
|
||||||
|
// Check if aspect ID is already memoized
|
||||||
|
if (!memoizedAspectIds.has(subAspect.aspectName)) {
|
||||||
|
const aspect = (
|
||||||
|
await db
|
||||||
|
.select({ id: aspects.id })
|
||||||
|
.from(aspects)
|
||||||
|
.where(eq(aspects.name, subAspect.aspectName))
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (!aspect) {
|
||||||
|
throw new Error(`Aspect ${subAspect.aspectName} does not exist in the database`);
|
||||||
|
}
|
||||||
|
|
||||||
|
memoizedAspectIds.set(subAspect.aspectName, aspect.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectId = memoizedAspectIds.get(subAspect.aspectName)!;
|
||||||
|
|
||||||
|
// Check if the subAspect already exists
|
||||||
|
const existingSubAspect = await db
|
||||||
|
.select()
|
||||||
|
.from(subAspects)
|
||||||
|
.where(and(eq(subAspects.name, subAspect.name), eq(subAspects.aspectId, aspectId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingSubAspect.length === 0) {
|
||||||
|
// If the subAspect does not exist, insert it
|
||||||
|
const insertedSubAspect = (
|
||||||
|
await db
|
||||||
|
.insert(subAspects)
|
||||||
|
.values({
|
||||||
|
name: subAspect.name,
|
||||||
|
createdAt: subAspect.createdAt,
|
||||||
|
updatedAt: subAspect.updatedAt,
|
||||||
|
deletedAt: subAspect.deletedAt,
|
||||||
|
aspectId: aspectId
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.returning()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (insertedSubAspect) {
|
||||||
|
console.log(`SubAspect ${subAspect.name} created and linked to aspect ${subAspect.aspectName}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`SubAspect ${subAspect.name} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default subAspectSeeder;
|
||||||
|
|
@ -11,8 +11,37 @@ 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,6 +3,8 @@ 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";
|
||||||
|
|
@ -15,6 +17,12 @@ 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();
|
||||||
|
|
||||||
|
|
@ -78,11 +86,20 @@ 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,6 +71,7 @@ const authInfo = createMiddleware<HonoEnv>(async (c, next) => {
|
||||||
|
|
||||||
// Setting the currentUser with fetched data
|
// Setting the currentUser with fetched data
|
||||||
c.set("currentUser", {
|
c.set("currentUser", {
|
||||||
|
id: user[0].users.id, // Adding user ID here
|
||||||
name: user[0].users.name, // Assuming the first result is the user
|
name: user[0].users.name, // Assuming the first result is the user
|
||||||
permissions: Array.from(permissions),
|
permissions: Array.from(permissions),
|
||||||
roles: Array.from(roles),
|
roles: Array.from(roles),
|
||||||
|
|
|
||||||
278
apps/backend/src/routes/assessmentRequest/route.ts
Normal file
278
apps/backend/src/routes/assessmentRequest/route.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
import { eq, sql, ilike, and, desc} from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import db from "../../drizzle";
|
||||||
|
import { respondents } from "../../drizzle/schema/respondents";
|
||||||
|
import { assessments } from "../../drizzle/schema/assessments";
|
||||||
|
import { users } from "../../drizzle/schema/users";
|
||||||
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
|
import authInfo from "../../middlewares/authInfo";
|
||||||
|
import { forbidden, notFound } from "../../errors/DashboardError";
|
||||||
|
import checkPermission from "../../middlewares/checkPermission";
|
||||||
|
import requestValidator from "../../utils/requestValidator";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { questions } from "../../drizzle/schema/questions";
|
||||||
|
import { subAspects } from "../../drizzle/schema/subAspects";
|
||||||
|
import { aspects } from "../../drizzle/schema/aspects";
|
||||||
|
import { answers } from "../../drizzle/schema/answers";
|
||||||
|
import { options } from "../../drizzle/schema/options";
|
||||||
|
|
||||||
|
const assessmentRequestRoute = new Hono<HonoEnv>()
|
||||||
|
.use(authInfo)
|
||||||
|
|
||||||
|
// Get assessment request by user ID
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
checkPermission("assessmentRequest.read"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
page: z.coerce.number().int().min(0).default(0),
|
||||||
|
limit: z.coerce.number().int().min(1).max(1000).default(10),
|
||||||
|
q: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const currentUser = c.get("currentUser");
|
||||||
|
const userId = currentUser?.id; // Get user ID of the currently logged in currentUser
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw forbidden({
|
||||||
|
message: "User not authenticated"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
|
// Query to count total data
|
||||||
|
const totalCountQuery = db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(distinct ${assessments.id})`,
|
||||||
|
})
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.leftJoin(users, eq(respondents.userId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.id, userId),
|
||||||
|
q && q.trim() !== ""
|
||||||
|
?sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalCountResult = await totalCountQuery;
|
||||||
|
const totalItems = totalCountResult[0]?.count || 0;
|
||||||
|
|
||||||
|
// Query to get assessment data with pagination
|
||||||
|
const queryResult = await db
|
||||||
|
.select({
|
||||||
|
userId: users.id,
|
||||||
|
name: users.name,
|
||||||
|
assessmentId: assessments.id,
|
||||||
|
tanggal: assessments.createdAt,
|
||||||
|
status: assessments.status,
|
||||||
|
respondentId: respondents.id,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(respondents, eq(users.id, respondents.userId))
|
||||||
|
.leftJoin(assessments, eq(respondents.id, assessments.respondentId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.id, userId),
|
||||||
|
q && q.trim() !== ""
|
||||||
|
// ? ilike(sql`${assessments.status}::text`, `%${q}%`) // Cast status to text for ilike
|
||||||
|
?sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(assessments.createdAt))
|
||||||
|
.offset(page * limit)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
|
||||||
|
if (!queryResult[0]) throw notFound();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: queryResult,
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil(totalItems / limit),
|
||||||
|
totalItems,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Post assessment request by user ID
|
||||||
|
.post(
|
||||||
|
"/",
|
||||||
|
checkPermission("assessmentRequest.create"),
|
||||||
|
requestValidator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
respondentId: z.string().min(1), // Ensure respondentId has at least one character
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { respondentId } = c.req.valid("json");
|
||||||
|
const currentUser = c.get("currentUser");
|
||||||
|
const userId = currentUser?.id; // Get userId from currentUser stored in context
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return c.text("User not authenticated", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate if respondent with respondentId exists
|
||||||
|
const respondent = await db
|
||||||
|
.select()
|
||||||
|
.from(respondents)
|
||||||
|
.where(and(eq(respondents.id, respondentId), eq(respondents.userId, userId)));
|
||||||
|
|
||||||
|
if (!respondent.length) {
|
||||||
|
throw new HTTPException(404, { message: "Respondent not found or unauthorized." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there is an assessment request with status "in progress"
|
||||||
|
const existingAssessment = await db
|
||||||
|
.select()
|
||||||
|
.from(assessments)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(assessments.respondentId, respondentId),
|
||||||
|
eq(assessments.status, "dalam pengerjaan")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingAssessment.length) {
|
||||||
|
const newAssessment = await db
|
||||||
|
.insert(assessments)
|
||||||
|
.values({
|
||||||
|
id: createId(),
|
||||||
|
respondentId,
|
||||||
|
status: "menunggu konfirmasi",
|
||||||
|
reviewedAt: null,
|
||||||
|
reviewedBy: null,
|
||||||
|
verifiedBy: null,
|
||||||
|
verifiedAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json({ message: "Successfully submitted the assessment request", data: newAssessment }, 201);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/*Update assessment status when the user clicks the start assessment button
|
||||||
|
and Post all answers for assessment by ID */
|
||||||
|
.patch(
|
||||||
|
"/:assessmentId",
|
||||||
|
checkPermission("assessmentRequest.update"),
|
||||||
|
requestValidator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
status: z.enum([
|
||||||
|
"menunggu konfirmasi",
|
||||||
|
"diterima",
|
||||||
|
"ditolak",
|
||||||
|
"dalam pengerjaan",
|
||||||
|
"belum diverifikasi",
|
||||||
|
"selesai",
|
||||||
|
]).optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const currentUser = c.get("currentUser");
|
||||||
|
const userId = currentUser?.id;
|
||||||
|
const { assessmentId } = c.req.param();
|
||||||
|
const { status } = c.req.valid("json");
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw forbidden({
|
||||||
|
message: "User not authenticated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the assessment with the given assessmentId exists and belongs to the user
|
||||||
|
const existingAssessment = await db
|
||||||
|
.select()
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.where(and(eq(assessments.id, assessmentId), eq(respondents.userId, userId)));
|
||||||
|
|
||||||
|
if (!existingAssessment.length) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Assessment not found or unauthorized.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update assessment status to "dalam pengerjaan"
|
||||||
|
const updatedAssessment = await db
|
||||||
|
.update(assessments)
|
||||||
|
.set({
|
||||||
|
status: status || "dalam pengerjaan",
|
||||||
|
})
|
||||||
|
.where(eq(assessments.id, assessmentId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Get all questions with options related to the assessment by aspect and sub-aspect
|
||||||
|
const questionsWithOptions = await db
|
||||||
|
.select({
|
||||||
|
questionId: questions.id,
|
||||||
|
})
|
||||||
|
.from(questions)
|
||||||
|
.leftJoin(options, eq(questions.id, options.questionId))
|
||||||
|
.where(sql`${options.id} IS NOT NULL`) // Filter only questions with options
|
||||||
|
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.groupBy(questions.id);
|
||||||
|
|
||||||
|
if (!questionsWithOptions.length) {
|
||||||
|
throw notFound({
|
||||||
|
message: "No questions found for this assessment.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check answers that already have optionId
|
||||||
|
const existingAnswers = await db
|
||||||
|
.select({
|
||||||
|
questionId: answers.questionId,
|
||||||
|
})
|
||||||
|
.from(answers)
|
||||||
|
.where(
|
||||||
|
and(eq(answers.assessmentId, assessmentId), sql`${answers.optionId} IS NOT NULL`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingAnswerIds = new Set(existingAnswers.map((answer) => answer.questionId));
|
||||||
|
|
||||||
|
// Create a list of new answers only for questions that have options and do not have existing answers
|
||||||
|
const answerRecords = questionsWithOptions
|
||||||
|
.filter((q) => !existingAnswerIds.has(q.questionId))
|
||||||
|
.map((q) => ({
|
||||||
|
id: createId(),
|
||||||
|
questionId: q.questionId,
|
||||||
|
optionId: null,
|
||||||
|
validationInformation: "",
|
||||||
|
assessmentId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Insert new answers with null values
|
||||||
|
if (answerRecords.length > 0) {
|
||||||
|
await db.insert(answers).values(answerRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Assessment status updated started successfully",
|
||||||
|
data: {
|
||||||
|
updatedAssessment,
|
||||||
|
answerRecords,}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default assessmentRequestRoute;
|
||||||
217
apps/backend/src/routes/assessmentRequestManagement/route.ts
Normal file
217
apps/backend/src/routes/assessmentRequestManagement/route.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { and, eq, ilike, or, sql, asc, inArray } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import checkPermission from "../../middlewares/checkPermission";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
import db from "../../drizzle";
|
||||||
|
import { assessments } from "../../drizzle/schema/assessments";
|
||||||
|
import { respondents } from "../../drizzle/schema/respondents";
|
||||||
|
import { users } from "../../drizzle/schema/users";
|
||||||
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
|
import requestValidator from "../../utils/requestValidator";
|
||||||
|
import authInfo from "../../middlewares/authInfo";
|
||||||
|
|
||||||
|
export const assessmentFormSchema = z.object({
|
||||||
|
respondentId: z.string().min(1),
|
||||||
|
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "dalam pengerjaan", "belum diverifikasi", "selesai"]),
|
||||||
|
reviewedBy: z.string().min(1),
|
||||||
|
verifiedBy: z.string().min(1),
|
||||||
|
verifiedAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const assessmentUpdateSchema = assessmentFormSchema.extend({
|
||||||
|
verifiedAt: z.string().optional().or(z.literal("")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const assessmentsRequestManagementRoutes = new Hono<HonoEnv>()
|
||||||
|
.use(authInfo)
|
||||||
|
/**
|
||||||
|
* Get All Assessments (With Metadata)
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - withMetadata: boolean
|
||||||
|
*/
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
checkPermission("assessmentRequestManagement.readAll"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
withMetadata: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
page: z.coerce.number().int().min(0).default(0),
|
||||||
|
limit: z.coerce.number().int().min(1).max(1000).default(10),
|
||||||
|
q: z.string().default(""),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
|
const validStatuses = [
|
||||||
|
"dalam pengerjaan",
|
||||||
|
"menunggu konfirmasi",
|
||||||
|
"diterima",
|
||||||
|
"ditolak",
|
||||||
|
] as ("menunggu konfirmasi" | "diterima" | "ditolak" | "dalam pengerjaan")[];
|
||||||
|
|
||||||
|
// Query untuk menghitung total jumlah item (totalCountQuery)
|
||||||
|
const assessmentCountQuery = await db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.leftJoin(users, eq(respondents.userId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(assessments.status, validStatuses),
|
||||||
|
q
|
||||||
|
? or(
|
||||||
|
ilike(users.name, `%${q}%`),
|
||||||
|
ilike(respondents.companyName, `%${q}%`),
|
||||||
|
sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`,
|
||||||
|
eq(assessments.id, q)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalItems = Number(assessmentCountQuery[0]?.count) || 0;
|
||||||
|
|
||||||
|
// Query utama untuk mendapatkan data permohonan assessment
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
idPermohonan: assessments.id,
|
||||||
|
namaResponden: users.name,
|
||||||
|
namaPerusahaan: respondents.companyName,
|
||||||
|
status: assessments.status,
|
||||||
|
tanggal: assessments.createdAt,
|
||||||
|
})
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.leftJoin(users, eq(respondents.userId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(assessments.status, validStatuses),
|
||||||
|
q
|
||||||
|
? or(
|
||||||
|
ilike(users.name, `%${q}%`),
|
||||||
|
ilike(respondents.companyName, `%${q}%`),
|
||||||
|
sql`CAST(${assessments.status} AS TEXT) ILIKE ${'%' + q + '%'}`,
|
||||||
|
eq(assessments.id, q)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
sql`
|
||||||
|
CASE
|
||||||
|
WHEN ${assessments.status} = 'menunggu konfirmasi' THEN 1
|
||||||
|
WHEN ${assessments.status} = 'dalam pengerjaan' THEN 2
|
||||||
|
WHEN ${assessments.status} = 'diterima' THEN 3
|
||||||
|
WHEN ${assessments.status} = 'ditolak' THEN 4
|
||||||
|
ELSE 5
|
||||||
|
END
|
||||||
|
`,
|
||||||
|
asc(assessments.createdAt)
|
||||||
|
)
|
||||||
|
.offset(page * limit)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: result.map((d) => ({
|
||||||
|
idPermohonan: d.idPermohonan,
|
||||||
|
namaResponden: d.namaResponden,
|
||||||
|
namaPerusahaan: d.namaPerusahaan,
|
||||||
|
status: d.status,
|
||||||
|
tanggal: d.tanggal,
|
||||||
|
})),
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil(totalItems / limit),
|
||||||
|
totalItems,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get assessment by id
|
||||||
|
.get(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("assessmentRequestManagement.read"),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id");
|
||||||
|
|
||||||
|
const queryResult = await db
|
||||||
|
.select({
|
||||||
|
tanggal: assessments.createdAt,
|
||||||
|
nama: users.name,
|
||||||
|
posisi: respondents.position,
|
||||||
|
pengalamanKerja: respondents.workExperience,
|
||||||
|
email: users.email,
|
||||||
|
namaPerusahaan: respondents.companyName,
|
||||||
|
alamat: respondents.address,
|
||||||
|
nomorTelepon: respondents.phoneNumber,
|
||||||
|
username: users.username,
|
||||||
|
status: assessments.status,
|
||||||
|
})
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.leftJoin(users, eq(respondents.userId, users.id))
|
||||||
|
.where(eq(assessments.id, assessmentId));
|
||||||
|
|
||||||
|
if (!queryResult.length)
|
||||||
|
throw new HTTPException(404, {
|
||||||
|
message: "The assessment does not exist",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assessmentData = queryResult[0];
|
||||||
|
|
||||||
|
return c.json(assessmentData);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.patch(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("assessmentRequestManagement.update"),
|
||||||
|
requestValidator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "dalam pengerjaan", "belum diverifikasi", "selesai"]),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id");
|
||||||
|
const { status } = c.req.valid("json");
|
||||||
|
const userName = c.var.currentUser?.name;
|
||||||
|
|
||||||
|
const assessment = await db
|
||||||
|
.select()
|
||||||
|
.from(assessments)
|
||||||
|
.where(and(eq(assessments.id, assessmentId),));
|
||||||
|
|
||||||
|
if (!assessment[0]) throw new HTTPException(404, {
|
||||||
|
message: "Assessment tidak ditemukan.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(assessments)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
reviewedBy: userName,
|
||||||
|
reviewedAt: currentDate,
|
||||||
|
})
|
||||||
|
.where(eq(assessments.id, assessmentId));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Status assessment berhasil diperbarui.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default assessmentsRequestManagementRoutes;
|
||||||
620
apps/backend/src/routes/assessmentResult/route.ts
Normal file
620
apps/backend/src/routes/assessmentResult/route.ts
Normal file
|
|
@ -0,0 +1,620 @@
|
||||||
|
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import db from "../../drizzle";
|
||||||
|
import { assessments, statusEnum } from "../../drizzle/schema/assessments";
|
||||||
|
import { respondents } from "../../drizzle/schema/respondents";
|
||||||
|
import { users } from "../../drizzle/schema/users";
|
||||||
|
import { aspects } from "../../drizzle/schema/aspects";
|
||||||
|
import { subAspects } from "../../drizzle/schema/subAspects";
|
||||||
|
import { questions } from "../../drizzle/schema/questions";
|
||||||
|
import { options } from "../../drizzle/schema/options";
|
||||||
|
import { answers } from "../../drizzle/schema/answers";
|
||||||
|
import { answerRevisions } from "../../drizzle/schema/answerRevisions";
|
||||||
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
|
import authInfo from "../../middlewares/authInfo";
|
||||||
|
import checkPermission from "../../middlewares/checkPermission";
|
||||||
|
import requestValidator from "../../utils/requestValidator";
|
||||||
|
import { notFound } from "../../errors/DashboardError";
|
||||||
|
|
||||||
|
// optionFormSchema: untuk /submitOption
|
||||||
|
export const optionFormSchema = z.object({
|
||||||
|
optionId: z.string().min(1),
|
||||||
|
assessmentId: z.string().min(1),
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
isFlagged: z.boolean().optional().default(false),
|
||||||
|
filename: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// validationFormSchema: untuk /submitValidation
|
||||||
|
export const validationFormSchema = z.object({
|
||||||
|
assessmentId: z.string().min(1),
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
newValidationInformation: z.string().min(1, "Validation information is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const assessmentRoute = new Hono<HonoEnv>()
|
||||||
|
.use(authInfo)
|
||||||
|
|
||||||
|
// Get All List of Assessment Results
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
checkPermission("assessmentResult.readAll"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
withMetadata: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
page: z.coerce.number().int().min(0).default(0),
|
||||||
|
limit: z.coerce.number().int().min(1).max(1000).default(40),
|
||||||
|
q: z.string().default(""),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { page, limit, q } = c.req.valid("query");
|
||||||
|
const totalItems = await db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`COUNT(*)`,
|
||||||
|
})
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.leftJoin(users, eq(respondents.userId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
q ? or(
|
||||||
|
ilike(users.name, q),
|
||||||
|
ilike(respondents.companyName, q)
|
||||||
|
) : undefined,
|
||||||
|
),
|
||||||
|
or(
|
||||||
|
eq(assessments.status, 'belum diverifikasi'),
|
||||||
|
eq(assessments.status, 'selesai')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: assessments.id,
|
||||||
|
respondentName: users.name,
|
||||||
|
companyName: respondents.companyName,
|
||||||
|
statusAssessments: assessments.status,
|
||||||
|
statusVerification: sql<string>`
|
||||||
|
CASE
|
||||||
|
WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi'
|
||||||
|
ELSE 'belum diverifikasi'
|
||||||
|
END`
|
||||||
|
.as("statusVerification"),
|
||||||
|
assessmentsResult: sql<number>`
|
||||||
|
(SELECT ROUND(AVG(${options.score}), 2)
|
||||||
|
FROM ${answers}
|
||||||
|
JOIN ${options} ON ${options.id} = ${answers.optionId}
|
||||||
|
JOIN ${questions} ON ${questions.id} = ${options.questionId}
|
||||||
|
JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId}
|
||||||
|
JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId}
|
||||||
|
WHERE ${answers.assessmentId} = ${assessments.id})`
|
||||||
|
.as("assessmentsResult"),
|
||||||
|
})
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.leftJoin(users, eq(respondents.userId, users.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
q ? or(
|
||||||
|
ilike(users.name, q),
|
||||||
|
ilike(respondents.companyName, q),
|
||||||
|
) : undefined,
|
||||||
|
),
|
||||||
|
or(
|
||||||
|
eq(assessments.status, 'belum diverifikasi'),
|
||||||
|
eq(assessments.status, 'selesai')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
sql`CASE
|
||||||
|
WHEN ${assessments.status} = 'belum diverifikasi' THEN 1
|
||||||
|
WHEN ${assessments.status} = 'selesai' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END`
|
||||||
|
)
|
||||||
|
.offset(page * limit)
|
||||||
|
.limit(limit);
|
||||||
|
const totalCountResult = await totalItems;
|
||||||
|
const totalCount = totalCountResult[0]?.count || 0;
|
||||||
|
return c.json({
|
||||||
|
data: result,
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
|
totalItems: totalCount,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get Assessment Result by ID
|
||||||
|
.get(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("assessmentResult.read"),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id");
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
respondentName: users.name,
|
||||||
|
position: respondents.position,
|
||||||
|
workExperience: respondents.workExperience,
|
||||||
|
email: users.email,
|
||||||
|
companyName: respondents.companyName,
|
||||||
|
address: respondents.address,
|
||||||
|
phoneNumber: respondents.phoneNumber,
|
||||||
|
username: users.username,
|
||||||
|
assessmentDate: assessments.createdAt,
|
||||||
|
statusAssessment: assessments.status,
|
||||||
|
statusVerification: sql<string>`
|
||||||
|
CASE
|
||||||
|
WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi'
|
||||||
|
ELSE 'belum diverifikasi'
|
||||||
|
END`
|
||||||
|
.as("statusVerification"),
|
||||||
|
assessmentsResult: sql<number>`
|
||||||
|
(SELECT ROUND(AVG(${options.score}), 2)
|
||||||
|
FROM ${answers}
|
||||||
|
JOIN ${options} ON ${options.id} = ${answers.optionId}
|
||||||
|
JOIN ${questions} ON ${questions.id} = ${options.questionId}
|
||||||
|
JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId}
|
||||||
|
JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId}
|
||||||
|
WHERE ${answers.assessmentId} = ${assessments.id})`
|
||||||
|
.as("assessmentsResult"),
|
||||||
|
})
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.leftJoin(users, eq(respondents.userId, users.id))
|
||||||
|
.where(eq(assessments.id, assessmentId));
|
||||||
|
|
||||||
|
if (!result.length) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Assessment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result[0]);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.get(
|
||||||
|
"/verified/:id",
|
||||||
|
checkPermission("assessmentResult.read"),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id");
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
respondentName: users.name,
|
||||||
|
position: respondents.position,
|
||||||
|
workExperience: respondents.workExperience,
|
||||||
|
email: users.email,
|
||||||
|
companyName: respondents.companyName,
|
||||||
|
address: respondents.address,
|
||||||
|
phoneNumber: respondents.phoneNumber,
|
||||||
|
username: users.username,
|
||||||
|
assessmentDate: assessments.createdAt,
|
||||||
|
statusAssessment: assessments.status,
|
||||||
|
statusVerification: sql<string>`
|
||||||
|
CASE
|
||||||
|
WHEN ${assessments.verifiedAt} IS NOT NULL THEN 'sudah diverifikasi'
|
||||||
|
ELSE 'belum diverifikasi'
|
||||||
|
END`.as("statusVerification"),
|
||||||
|
verifiedAssessmentsResult: sql<number>`
|
||||||
|
(SELECT ROUND(AVG(${options.score}), 2)
|
||||||
|
FROM ${answerRevisions}
|
||||||
|
JOIN ${answers} ON ${answers.id} = ${answerRevisions.answerId}
|
||||||
|
JOIN ${options} ON ${options.id} = ${answerRevisions.newOptionId}
|
||||||
|
JOIN ${questions} ON ${questions.id} = ${options.questionId}
|
||||||
|
JOIN ${subAspects} ON ${subAspects.id} = ${questions.subAspectId}
|
||||||
|
JOIN ${aspects} ON ${aspects.id} = ${subAspects.aspectId}
|
||||||
|
WHERE ${answers.assessmentId} = ${assessments.id})`.as("verifiedAssessmentsResult"),
|
||||||
|
})
|
||||||
|
.from(assessments)
|
||||||
|
.leftJoin(respondents, eq(assessments.respondentId, respondents.id))
|
||||||
|
.leftJoin(users, eq(respondents.userId, users.id))
|
||||||
|
.where(eq(assessments.id, assessmentId));
|
||||||
|
|
||||||
|
if (!result.length) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Assessment not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result[0]);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get all Questions and Options that relate to Sub Aspects and Aspects based on Assessment ID
|
||||||
|
.get(
|
||||||
|
"getAllQuestion/:id",
|
||||||
|
checkPermission("assessmentResult.readAllQuestions"),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id");
|
||||||
|
|
||||||
|
if (!assessmentId) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Assessment ID is missing",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total count of options related to the assessment
|
||||||
|
const totalCountQuery = sql<number>`
|
||||||
|
SELECT count(*)
|
||||||
|
FROM ${options}
|
||||||
|
LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id}
|
||||||
|
LEFT JOIN ${subAspects} ON ${questions.subAspectId} = ${subAspects.id}
|
||||||
|
LEFT JOIN ${aspects} ON ${subAspects.aspectId} = ${aspects.id}
|
||||||
|
LEFT JOIN ${answers} ON ${options.id} = ${answers.optionId}
|
||||||
|
WHERE ${questions.deletedAt} IS NULL
|
||||||
|
AND ${answers.assessmentId} = ${assessmentId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Query to get detailed information about options
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
optionId: options.id,
|
||||||
|
aspectsId: aspects.id,
|
||||||
|
aspectsName: aspects.name,
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
questionId: questions.id,
|
||||||
|
questionText: questions.question,
|
||||||
|
answerId: answers.id,
|
||||||
|
answerText: options.text,
|
||||||
|
answerScore: options.score,
|
||||||
|
})
|
||||||
|
.from(options)
|
||||||
|
.leftJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.leftJoin(answers, eq(options.id, answers.optionId))
|
||||||
|
.where(sql`${questions.deletedAt} IS NULL AND ${answers.assessmentId} = ${assessmentId}`);
|
||||||
|
|
||||||
|
// Execute the total count query
|
||||||
|
const totalCountResult = await db.execute(totalCountQuery);
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Data does not exist",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: result,
|
||||||
|
totalCount: totalCountResult[0]?.count || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.get(
|
||||||
|
'/average-score/sub-aspects/assessments/:assessmentId',
|
||||||
|
checkPermission("assessments.readAverageSubAspect"),
|
||||||
|
async (c) => {
|
||||||
|
const { assessmentId } = c.req.param();
|
||||||
|
|
||||||
|
const averageScores = await db
|
||||||
|
.select({
|
||||||
|
aspectId: subAspects.aspectId,
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
average: sql`AVG(options.score)`
|
||||||
|
})
|
||||||
|
.from(answerRevisions)
|
||||||
|
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
|
||||||
|
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
|
||||||
|
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||||
|
.where(eq(assessments.id, assessmentId))
|
||||||
|
.groupBy(subAspects.id);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
assessmentId,
|
||||||
|
subAspects: averageScores.map(score => ({
|
||||||
|
subAspectId: score.subAspectId,
|
||||||
|
subAspectName: score.subAspectName,
|
||||||
|
averageScore: score.average,
|
||||||
|
aspectId: score.aspectId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.get(
|
||||||
|
'/average-score/aspects/assessments/:assessmentId',
|
||||||
|
checkPermission("assessments.readAverageAspect"),
|
||||||
|
async (c) => {
|
||||||
|
const { assessmentId } = c.req.param();
|
||||||
|
|
||||||
|
// Query untuk mendapatkan average score per aspect
|
||||||
|
const aspectScores = await db
|
||||||
|
.select({
|
||||||
|
aspectId: aspects.id,
|
||||||
|
aspectName: aspects.name,
|
||||||
|
averageScore: sql`AVG(options.score)`,
|
||||||
|
})
|
||||||
|
.from(answerRevisions)
|
||||||
|
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
|
||||||
|
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
|
||||||
|
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.innerJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||||
|
.where(eq(assessments.id, assessmentId))
|
||||||
|
.groupBy(aspects.id);
|
||||||
|
|
||||||
|
// Query untuk mendapatkan average score per sub-aspect
|
||||||
|
const subAspectScores = await db
|
||||||
|
.select({
|
||||||
|
aspectId: subAspects.aspectId,
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
averageScore: sql`AVG(options.score)`,
|
||||||
|
})
|
||||||
|
.from(answerRevisions)
|
||||||
|
.innerJoin(answers, eq(answers.id, answerRevisions.answerId))
|
||||||
|
.innerJoin(options, eq(answerRevisions.newOptionId, options.id))
|
||||||
|
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||||
|
.where(eq(assessments.id, assessmentId))
|
||||||
|
.groupBy(subAspects.id);
|
||||||
|
|
||||||
|
// Menggabungkan sub-aspects ke dalam masing-masing aspect
|
||||||
|
const aspectsWithSubAspects = aspectScores.map((aspect) => ({
|
||||||
|
aspectId: aspect.aspectId,
|
||||||
|
aspectName: aspect.aspectName,
|
||||||
|
averageScore: aspect.averageScore,
|
||||||
|
subAspects: subAspectScores
|
||||||
|
.filter((sub) => sub.aspectId === aspect.aspectId)
|
||||||
|
.map((sub) => ({
|
||||||
|
subAspectId: sub.subAspectId,
|
||||||
|
subAspectName: sub.subAspectName,
|
||||||
|
averageScore: sub.averageScore,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
assessmentId,
|
||||||
|
aspects: aspectsWithSubAspects,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get all Answers Data by Assessment Id
|
||||||
|
.get(
|
||||||
|
"/getAnswers/:id",
|
||||||
|
checkPermission("assessments.readAnswers"),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id").toString();
|
||||||
|
|
||||||
|
// Query to count total answers for the specific assessmentId
|
||||||
|
const totalCountQuery = sql<number>`(SELECT count(*)
|
||||||
|
FROM ${answerRevisions}
|
||||||
|
JOIN ${answers} ON ${answers.id} = ${answerRevisions.answerId}
|
||||||
|
JOIN ${assessments} ON ${answers.assessmentId} = ${assessments.id}
|
||||||
|
WHERE ${assessments.id} = ${assessmentId})`;
|
||||||
|
|
||||||
|
// Query to retrieve answers for the specific assessmentId
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: answerRevisions.id,
|
||||||
|
answerId: answerRevisions.answerId,
|
||||||
|
newOptionId: answerRevisions.newOptionId,
|
||||||
|
newValidationInformation: answerRevisions.newValidationInformation,
|
||||||
|
questionId: options.questionId,
|
||||||
|
fullCount: totalCountQuery,
|
||||||
|
})
|
||||||
|
.from(answerRevisions)
|
||||||
|
.leftJoin(answers, eq(answers.id, answerRevisions.answerId))
|
||||||
|
.leftJoin(options, eq(answerRevisions.newOptionId, options.id))
|
||||||
|
.leftJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||||
|
.where(eq(assessments.id, assessmentId),
|
||||||
|
)
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: result.map((d) => ({ ...d, fullCount: undefined })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST Endpoint for creating multiple answer revisions based on assessmentId
|
||||||
|
.post(
|
||||||
|
"/answer-revisions",
|
||||||
|
checkPermission("assessmentResult.create"),
|
||||||
|
requestValidator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
assessmentId: z.string(),
|
||||||
|
revisedBy: z.string(), // assuming this will come from the session or auth context
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { assessmentId, revisedBy } = c.req.valid("json");
|
||||||
|
|
||||||
|
// Fetch answers related to the given assessmentId
|
||||||
|
const existingAnswers = await db
|
||||||
|
.select()
|
||||||
|
.from(answers)
|
||||||
|
.where(eq(answers.assessmentId, assessmentId));
|
||||||
|
|
||||||
|
if (!existingAnswers.length) {
|
||||||
|
throw notFound({
|
||||||
|
message: "No answers found for the given assessment ID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch already existing revisions for the given answer IDs
|
||||||
|
const existingRevisions = await db
|
||||||
|
.select({ answerId: answerRevisions.answerId })
|
||||||
|
.from(answerRevisions)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
...existingAnswers.map((answer) =>
|
||||||
|
eq(answerRevisions.answerId, answer.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a Set of existing revision IDs for quick lookup
|
||||||
|
const existingRevisionIds = new Set(
|
||||||
|
existingRevisions.map(revision => revision.answerId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepare revisions to be inserted, excluding those that already exist
|
||||||
|
const revisions = existingAnswers
|
||||||
|
.filter(answer => !existingRevisionIds.has(answer.id)) // Filter out existing revisions
|
||||||
|
.map(answer => ({
|
||||||
|
answerId: answer.id,
|
||||||
|
newOptionId: answer.optionId, // Assuming you want to keep the existing optionId
|
||||||
|
newValidationInformation: answer.validationInformation, // Keep the existing validation information
|
||||||
|
revisedBy: revisedBy
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (revisions.length === 0) {
|
||||||
|
return c.json({
|
||||||
|
message: "No new revisions to create, as all answers are already revised.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert all new revisions in a single operation
|
||||||
|
const newRevisions = await db
|
||||||
|
.insert(answerRevisions)
|
||||||
|
.values(revisions)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "Answer revisions created successfully",
|
||||||
|
data: newRevisions,
|
||||||
|
},
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.patch(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("assessmentResult.update"),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id");
|
||||||
|
|
||||||
|
if (!assessmentId) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Assessment tidak ada",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const assessment = await db
|
||||||
|
.select()
|
||||||
|
.from(assessments)
|
||||||
|
.where(and(eq(assessments.id, assessmentId)));
|
||||||
|
|
||||||
|
if (!assessment[0]) throw notFound();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(assessments)
|
||||||
|
.set({
|
||||||
|
verifiedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(assessments.id, assessmentId));
|
||||||
|
console.log("Verified Success");
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Assessment berhasil diverifikasi",
|
||||||
|
data: assessment,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.patch(
|
||||||
|
"/submitAssessmentRevision/:id",
|
||||||
|
checkPermission("assessments.submitAssessment"),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id");
|
||||||
|
const status = "selesai";
|
||||||
|
|
||||||
|
const assessment = await db
|
||||||
|
.select()
|
||||||
|
.from(assessments)
|
||||||
|
.where(and(eq(assessments.id, assessmentId),));
|
||||||
|
|
||||||
|
if (!assessment[0]) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Assessment not found.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(assessments)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
.where(eq(assessments.id, assessmentId));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Status assessment berhasil diperbarui.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.post(
|
||||||
|
"/updateValidation",
|
||||||
|
checkPermission("assessments.submitValidation"),
|
||||||
|
requestValidator("json", validationFormSchema),
|
||||||
|
async (c) => {
|
||||||
|
const validationData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Cek apakah jawaban ada berdasarkan assessmentId dan questionId
|
||||||
|
const [targetAnswer] = await db
|
||||||
|
.select({ id: answers.id })
|
||||||
|
.from(answers)
|
||||||
|
.leftJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.where(
|
||||||
|
sql`answers."assessmentId" = ${validationData.assessmentId}
|
||||||
|
AND options."questionId" = ${validationData.questionId}`
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetAnswer) {
|
||||||
|
return c.json(
|
||||||
|
{ message: "Answer not found for given assessmentId and questionId" },
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dapatkan tanggal dan waktu saat ini
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
// Update dengan melakukan JOIN yang sama
|
||||||
|
const [updatedRevision] = await db
|
||||||
|
.update(answerRevisions)
|
||||||
|
.set({
|
||||||
|
newValidationInformation: validationData.newValidationInformation,
|
||||||
|
})
|
||||||
|
.where(sql`"answerId" = ${targetAnswer.id}`)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "Revision updated successfully",
|
||||||
|
revision: updatedRevision, // Revisi yang baru saja diperbarui
|
||||||
|
},
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default assessmentRoute;
|
||||||
779
apps/backend/src/routes/assessments/route.ts
Normal file
779
apps/backend/src/routes/assessments/route.ts
Normal file
|
|
@ -0,0 +1,779 @@
|
||||||
|
import { and, eq, ilike, isNull, inArray, or, sql, is } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import db from "../../drizzle";
|
||||||
|
import { answers } from "../../drizzle/schema/answers";
|
||||||
|
import { options } from "../../drizzle/schema/options";
|
||||||
|
import { questions } from "../../drizzle/schema/questions";
|
||||||
|
import { subAspects } from "../../drizzle/schema/subAspects";
|
||||||
|
import { aspects } from "../../drizzle/schema/aspects";
|
||||||
|
import { assessments } from "../../drizzle/schema/assessments";
|
||||||
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
|
import requestValidator from "../../utils/requestValidator";
|
||||||
|
import authInfo from "../../middlewares/authInfo";
|
||||||
|
import checkPermission from "../../middlewares/checkPermission";
|
||||||
|
import path from "path";
|
||||||
|
import fs from 'fs';
|
||||||
|
import { notFound } from "../../errors/DashboardError";
|
||||||
|
import { answerRevisions } from "../../drizzle/schema/answerRevisions";
|
||||||
|
|
||||||
|
export const answerFormSchema = z.object({
|
||||||
|
optionId: z.string().min(1),
|
||||||
|
assessmentId: z.string().min(1),
|
||||||
|
isFlagged: z.boolean().optional().default(false),
|
||||||
|
filename: z.string().optional(),
|
||||||
|
validationInformation: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// optionFormSchema: untuk /submitOption
|
||||||
|
export const optionFormSchema = z.object({
|
||||||
|
optionId: z.string().min(1),
|
||||||
|
assessmentId: z.string().min(1),
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
isFlagged: z.boolean().optional().default(false),
|
||||||
|
filename: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// newOptionFormSchema: untuk /updateOption
|
||||||
|
export const newOptionFormSchema = z.object({
|
||||||
|
newOptionId: z.string().min(1),
|
||||||
|
assessmentId: z.string().min(1),
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
// validationFormSchema: untuk /submitValidation
|
||||||
|
export const validationFormSchema = z.object({
|
||||||
|
assessmentId: z.string().min(1),
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
validationInformation: z.string().min(1, "Validation information is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// newValidationFormSchema: untuk /updateValidation
|
||||||
|
export const newValidationFormSchema = z.object({
|
||||||
|
assessmentId: z.string().min(1),
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
newValidationInformation: z.string().min(1, "Validation information is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// validationFormSchema: untuk /submitValidation
|
||||||
|
export const flagFormSchema = z.object({
|
||||||
|
assessmentId: z.string().min(1),
|
||||||
|
questionId: z.string().min(1),
|
||||||
|
isFlagged: z.boolean().optional().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const answerUpdateSchema = answerFormSchema.partial();
|
||||||
|
|
||||||
|
// Helper untuk menyimpan file
|
||||||
|
async function saveFile(filePath: string, fileBuffer: Buffer): Promise<void> {
|
||||||
|
await fs.promises.writeFile(filePath, fileBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cari answer berdasarkan assessmentId dan questionId
|
||||||
|
async function findAnswerId(assessmentId: string, questionId: string): Promise<string | null> {
|
||||||
|
const result = await db
|
||||||
|
.select({ answerId: answers.id })
|
||||||
|
.from(answers)
|
||||||
|
.leftJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(answers.assessmentId, assessmentId),
|
||||||
|
eq(options.questionId, questionId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return result.length > 0 ? result[0].answerId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update filename di tabel answers
|
||||||
|
async function updateFilename(answerId: string, filename: string): Promise<void> {
|
||||||
|
// Dapatkan tanggal dan waktu saat ini
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(answers)
|
||||||
|
.set({
|
||||||
|
filename,
|
||||||
|
updatedAt: currentDate,
|
||||||
|
})
|
||||||
|
.where(eq(answers.id, answerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const assessmentsRoute = new Hono<HonoEnv>()
|
||||||
|
.use(authInfo)
|
||||||
|
|
||||||
|
// Get all aspects
|
||||||
|
.get(
|
||||||
|
"/aspect",
|
||||||
|
checkPermission("assessments.readAspect"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
includeTrashed: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
withMetadata: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
page: z.coerce.number().int().min(0).default(0),
|
||||||
|
limit: z.coerce.number().int().min(1).max(1000).default(10),
|
||||||
|
q: z.string().default(""),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
|
const totalCountQuery = includeTrashed
|
||||||
|
? sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})`
|
||||||
|
: sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`;
|
||||||
|
|
||||||
|
const aspectIdsQuery = await db
|
||||||
|
.select({
|
||||||
|
id: aspects.id,
|
||||||
|
})
|
||||||
|
.from(aspects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
includeTrashed ? undefined : isNull(aspects.deletedAt),
|
||||||
|
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.offset(page * limit)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const aspectIds = aspectIdsQuery.map(a => a.id);
|
||||||
|
|
||||||
|
if (aspectIds.length === 0) {
|
||||||
|
return c.json({
|
||||||
|
data: [],
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main query to get aspects, sub-aspects, and number of questions
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: aspects.id,
|
||||||
|
name: aspects.name,
|
||||||
|
createdAt: aspects.createdAt,
|
||||||
|
updatedAt: aspects.updatedAt,
|
||||||
|
...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}),
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
// Increase the number of questions related to sub aspects
|
||||||
|
questionCount: sql<number>`(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM ${questions}
|
||||||
|
WHERE ${questions.subAspectId} = ${subAspects.id}
|
||||||
|
)`.as('questionCount'),
|
||||||
|
fullCount: totalCountQuery,
|
||||||
|
})
|
||||||
|
.from(aspects)
|
||||||
|
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.where(inArray(aspects.id, aspectIds));
|
||||||
|
|
||||||
|
// Grouping sub aspects by aspect ID
|
||||||
|
const groupedResult = result.reduce((acc, curr) => {
|
||||||
|
const aspectId = curr.id;
|
||||||
|
|
||||||
|
if (!acc[aspectId]) {
|
||||||
|
acc[aspectId] = {
|
||||||
|
id: curr.id,
|
||||||
|
name: curr.name,
|
||||||
|
createdAt: curr.createdAt ? new Date(curr.createdAt).toISOString() : null,
|
||||||
|
updatedAt: curr.updatedAt ? new Date(curr.updatedAt).toISOString() : null,
|
||||||
|
subAspects: curr.subAspectName
|
||||||
|
? [{ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount }]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (curr.subAspectName) {
|
||||||
|
const exists = acc[aspectId].subAspects.some(sub => sub.id === curr.subAspectId);
|
||||||
|
if (!exists) {
|
||||||
|
acc[aspectId].subAspects.push({
|
||||||
|
id: curr.subAspectId!,
|
||||||
|
name: curr.subAspectName,
|
||||||
|
questionCount: curr.questionCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
subAspects: { id: string; name: string; questionCount: number }[];
|
||||||
|
}>);
|
||||||
|
|
||||||
|
const groupedArray = Object.values(groupedResult);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: groupedArray,
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit),
|
||||||
|
totalItems: Number(result[0]?.fullCount) ?? 0,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get all Questions and Options that relate to Sub Aspects and Aspects
|
||||||
|
.get(
|
||||||
|
"/getAllQuestions",
|
||||||
|
checkPermission("assessments.readAllQuestions"),
|
||||||
|
async (c) => {
|
||||||
|
// Definisikan tipe untuk hasil query dan izinkan nilai null
|
||||||
|
type QuestionWithOptions = {
|
||||||
|
aspectsId: string | null;
|
||||||
|
aspectsName: string | null;
|
||||||
|
subAspectId: string | null;
|
||||||
|
subAspectName: string | null;
|
||||||
|
questionId: string | null;
|
||||||
|
questionText: string | null;
|
||||||
|
optionId: string;
|
||||||
|
optionText: string;
|
||||||
|
needFile: boolean | null;
|
||||||
|
optionScore: number;
|
||||||
|
fullCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCountQuery =
|
||||||
|
sql<number>`(SELECT count(*)
|
||||||
|
FROM ${options}
|
||||||
|
LEFT JOIN ${questions} ON ${options.questionId} = ${questions.id}
|
||||||
|
LEFT JOIN ${subAspects} ON ${questions.subAspectId} = ${subAspects.id}
|
||||||
|
LEFT JOIN ${aspects} ON ${subAspects.aspectId} = ${aspects.id}
|
||||||
|
WHERE ${questions.deletedAt} IS NULL
|
||||||
|
)`;
|
||||||
|
|
||||||
|
// Sesuaikan tipe hasil query
|
||||||
|
const result: QuestionWithOptions[] = await db
|
||||||
|
.select({
|
||||||
|
aspectsId: aspects.id,
|
||||||
|
aspectsName: aspects.name,
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
questionId: questions.id,
|
||||||
|
questionText: questions.question,
|
||||||
|
optionId: options.id,
|
||||||
|
optionText: options.text,
|
||||||
|
needFile: questions.needFile,
|
||||||
|
optionScore: options.score,
|
||||||
|
fullCount: totalCountQuery,
|
||||||
|
})
|
||||||
|
.from(options)
|
||||||
|
.leftJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.where(sql`${questions.deletedAt} IS NULL`);
|
||||||
|
|
||||||
|
// Definisikan tipe untuk hasil pengelompokan
|
||||||
|
type GroupedQuestion = {
|
||||||
|
questionId: string | null;
|
||||||
|
questionText: string | null;
|
||||||
|
needFile: boolean | null;
|
||||||
|
aspectsId: string | null;
|
||||||
|
aspectsName: string | null;
|
||||||
|
subAspectId: string | null;
|
||||||
|
subAspectName: string | null;
|
||||||
|
options: {
|
||||||
|
optionId: string;
|
||||||
|
optionText: string;
|
||||||
|
optionScore: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mengelompokkan berdasarkan questionId
|
||||||
|
const groupedResult: GroupedQuestion[] = result.reduce((acc, current) => {
|
||||||
|
const { questionId, questionText, needFile, aspectsId, aspectsName, subAspectId, subAspectName, optionId, optionText, optionScore } = current;
|
||||||
|
|
||||||
|
// Cek apakah questionId sudah ada dalam accumulator
|
||||||
|
const existingQuestion = acc.find(q => q.questionId === questionId);
|
||||||
|
|
||||||
|
if (existingQuestion) {
|
||||||
|
// Tambahkan opsi baru ke array options dari pertanyaan yang ada
|
||||||
|
existingQuestion.options.push({
|
||||||
|
optionId,
|
||||||
|
optionText,
|
||||||
|
optionScore
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Jika pertanyaan belum ada, tambahkan objek baru
|
||||||
|
acc.push({
|
||||||
|
questionId,
|
||||||
|
questionText,
|
||||||
|
needFile,
|
||||||
|
aspectsId,
|
||||||
|
aspectsName,
|
||||||
|
subAspectId,
|
||||||
|
subAspectName,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
optionId,
|
||||||
|
optionText,
|
||||||
|
optionScore
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, [] as GroupedQuestion[]); // Pastikan tipe untuk accumulator didefinisikan
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: groupedResult,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get all Answers Data by Assessment Id
|
||||||
|
.get(
|
||||||
|
"/getAnswers",
|
||||||
|
checkPermission("assessments.readAnswers"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
assessmentId: z.string(), // Require assessmentId as a query parameter
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { assessmentId } = c.req.valid("query");
|
||||||
|
|
||||||
|
// Query to count total answers for the specific assessmentId
|
||||||
|
const totalCountQuery =
|
||||||
|
sql<number>`(SELECT count(*)
|
||||||
|
FROM ${answers}
|
||||||
|
WHERE ${answers.assessmentId} = ${assessmentId})`;
|
||||||
|
|
||||||
|
// Query to retrieve answers for the specific assessmentId
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: answers.id,
|
||||||
|
assessmentId: answers.assessmentId,
|
||||||
|
questionId: options.questionId,
|
||||||
|
optionId: answers.optionId,
|
||||||
|
isFlagged: answers.isFlagged,
|
||||||
|
filename: answers.filename,
|
||||||
|
validationInformation: answers.validationInformation,
|
||||||
|
fullCount: totalCountQuery,
|
||||||
|
})
|
||||||
|
.from(answers)
|
||||||
|
.leftJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(answers.assessmentId, assessmentId), // Filter by assessmentId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: result.map((d) => ({ ...d, fullCount: undefined })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toggles the isFlagged field between true and false
|
||||||
|
.patch(
|
||||||
|
"/toggleFlag",
|
||||||
|
checkPermission("assessments.toggleFlag"),
|
||||||
|
requestValidator("json", flagFormSchema),
|
||||||
|
async (c) => {
|
||||||
|
const flagData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Update jawaban yang ada berdasarkan assessmentId dan questionId
|
||||||
|
const answer = await db
|
||||||
|
.update(answers)
|
||||||
|
.set({
|
||||||
|
isFlagged: flagData.isFlagged, // Ubah ke pilihan baru
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(answers.assessmentId, flagData.assessmentId),
|
||||||
|
eq(answers.questionId, flagData.questionId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "Flag changed successfully",
|
||||||
|
answer: answer[0],
|
||||||
|
},
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Upload filename to the table answers and save the file on the local storage
|
||||||
|
.post(
|
||||||
|
"/uploadFile",
|
||||||
|
checkPermission("assessments.uploadFile"),
|
||||||
|
async (c) => {
|
||||||
|
const contentType = c.req.header('content-type');
|
||||||
|
if (!contentType || !contentType.includes('multipart/form-data')) {
|
||||||
|
return c.json({ message: "Invalid Content-Type" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = contentType.split('boundary=')[1];
|
||||||
|
if (!boundary) {
|
||||||
|
return c.json({ message: "Boundary not found" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await c.req.arrayBuffer();
|
||||||
|
const bodyString = Buffer.from(body).toString();
|
||||||
|
const parts = bodyString.split(`--${boundary}`);
|
||||||
|
|
||||||
|
let fileUrl: string | null = null;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.includes('Content-Disposition: form-data;')) {
|
||||||
|
const match = /filename="(.+?)"/.exec(part);
|
||||||
|
if (match) {
|
||||||
|
const fileName = match[1];
|
||||||
|
const fileContentStart = part.indexOf('\r\n\r\n') + 4;
|
||||||
|
const fileContentEnd = part.lastIndexOf('\r\n');
|
||||||
|
const fileBuffer = Buffer.from(part.slice(fileContentStart, fileContentEnd), 'binary');
|
||||||
|
|
||||||
|
const filePath = path.join('files', `${Date.now()}-${fileName}`);
|
||||||
|
await saveFile(filePath, fileBuffer);
|
||||||
|
|
||||||
|
const assessmentId = c.req.query('assessmentId');
|
||||||
|
const questionId = c.req.query('questionId');
|
||||||
|
|
||||||
|
if (!assessmentId || !questionId) {
|
||||||
|
return c.json({ message: "assessmentId and questionId are required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const answerId = await findAnswerId(assessmentId, questionId);
|
||||||
|
|
||||||
|
if (!answerId) {
|
||||||
|
return c.json({ message: 'Answer not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateFilename(answerId, path.basename(filePath));
|
||||||
|
fileUrl = `/files/${path.basename(filePath)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileUrl) {
|
||||||
|
return c.json({ message: 'No file uploaded' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, imageUrl: fileUrl });
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.post(
|
||||||
|
"/submitOption",
|
||||||
|
checkPermission("assessments.submitOption"),
|
||||||
|
requestValidator("json", optionFormSchema),
|
||||||
|
async (c) => {
|
||||||
|
const optionData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Update jawaban yang ada berdasarkan assessmentId dan questionId
|
||||||
|
const answer = await db
|
||||||
|
.update(answers)
|
||||||
|
.set({
|
||||||
|
optionId: optionData.optionId, // Ubah ke pilihan baru
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(answers.assessmentId, optionData.assessmentId),
|
||||||
|
eq(answers.questionId, optionData.questionId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "Option submitted successfully",
|
||||||
|
answer: answer[0],
|
||||||
|
},
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.post(
|
||||||
|
"/submitValidation",
|
||||||
|
checkPermission("assessments.submitValidation"),
|
||||||
|
requestValidator("json", validationFormSchema),
|
||||||
|
async (c) => {
|
||||||
|
const validationData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Cek apakah jawaban ada berdasarkan assessmentId dan questionId
|
||||||
|
const existingAnswer = await db
|
||||||
|
.select()
|
||||||
|
.from(answers)
|
||||||
|
.leftJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.leftJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.where(
|
||||||
|
sql`answers."assessmentId" = ${validationData.assessmentId}
|
||||||
|
AND questions.id = ${validationData.questionId}`
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingAnswer.length === 0) {
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "No existing answer found for the given assessmentId and questionId.",
|
||||||
|
},
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dapatkan tanggal dan waktu saat ini
|
||||||
|
const currentDate = new Date();
|
||||||
|
|
||||||
|
// Update dengan melakukan JOIN yang sama
|
||||||
|
const updatedAnswer = await db
|
||||||
|
.update(answers)
|
||||||
|
.set({
|
||||||
|
validationInformation: validationData.validationInformation,
|
||||||
|
updatedAt: currentDate,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
sql`answers."assessmentId" = ${validationData.assessmentId}
|
||||||
|
AND answers."optionId" IN (
|
||||||
|
SELECT id FROM options WHERE "questionId" = ${validationData.questionId}
|
||||||
|
)`
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "Validation information updated successfully",
|
||||||
|
answer: updatedAnswer[0],
|
||||||
|
},
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.patch(
|
||||||
|
"/submitAssessment/:id",
|
||||||
|
checkPermission("assessments.submitAssessment"),
|
||||||
|
async (c) => {
|
||||||
|
const assessmentId = c.req.param("id");
|
||||||
|
const status = "belum diverifikasi";
|
||||||
|
|
||||||
|
const assessment = await db
|
||||||
|
.select()
|
||||||
|
.from(assessments)
|
||||||
|
.where(and(eq(assessments.id, assessmentId),));
|
||||||
|
|
||||||
|
if (!assessment[0]) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Assessment not found.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(assessments)
|
||||||
|
.set({
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
.where(eq(assessments.id, assessmentId));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Status assessment berhasil diperbarui.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get data for All Sub Aspects average score By Assessment Id
|
||||||
|
.get(
|
||||||
|
'/average-score/sub-aspects/assessments/:assessmentId',
|
||||||
|
checkPermission("assessments.readAverageSubAspect"),
|
||||||
|
async (c) => {
|
||||||
|
const { assessmentId } = c.req.param();
|
||||||
|
|
||||||
|
const averageScores = await db
|
||||||
|
.select({
|
||||||
|
aspectId: subAspects.aspectId,
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
average: sql`AVG(options.score)`
|
||||||
|
})
|
||||||
|
.from(answers)
|
||||||
|
.innerJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||||
|
.where(eq(assessments.id, assessmentId))
|
||||||
|
.groupBy(subAspects.id);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
assessmentId,
|
||||||
|
subAspects: averageScores.map(score => ({
|
||||||
|
subAspectId: score.subAspectId,
|
||||||
|
subAspectName: score.subAspectName,
|
||||||
|
averageScore: score.average,
|
||||||
|
aspectId: score.aspectId
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get data for Aspects average score and all related Sub Aspects average score By Assessment Id
|
||||||
|
.get(
|
||||||
|
'/average-score/aspects/assessments/:assessmentId',
|
||||||
|
checkPermission("assessments.readAverageAspect"),
|
||||||
|
async (c) => {
|
||||||
|
const { assessmentId } = c.req.param();
|
||||||
|
|
||||||
|
// Query untuk mendapatkan average score per aspect
|
||||||
|
const aspectScores = await db
|
||||||
|
.select({
|
||||||
|
aspectId: aspects.id,
|
||||||
|
aspectName: aspects.name,
|
||||||
|
averageScore: sql`AVG(options.score)`,
|
||||||
|
})
|
||||||
|
.from(answers)
|
||||||
|
.innerJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.innerJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||||
|
.where(eq(assessments.id, assessmentId))
|
||||||
|
.groupBy(aspects.id);
|
||||||
|
|
||||||
|
// Query untuk mendapatkan average score per sub-aspect
|
||||||
|
const subAspectScores = await db
|
||||||
|
.select({
|
||||||
|
aspectId: subAspects.aspectId,
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
averageScore: sql`AVG(options.score)`,
|
||||||
|
})
|
||||||
|
.from(answers)
|
||||||
|
.innerJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.innerJoin(questions, eq(options.questionId, questions.id))
|
||||||
|
.innerJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.innerJoin(assessments, eq(answers.assessmentId, assessments.id))
|
||||||
|
.where(eq(assessments.id, assessmentId))
|
||||||
|
.groupBy(subAspects.id);
|
||||||
|
|
||||||
|
// Menggabungkan sub-aspects ke dalam masing-masing aspect
|
||||||
|
const aspectsWithSubAspects = aspectScores.map((aspect) => ({
|
||||||
|
aspectId: aspect.aspectId,
|
||||||
|
aspectName: aspect.aspectName,
|
||||||
|
averageScore: aspect.averageScore,
|
||||||
|
subAspects: subAspectScores
|
||||||
|
.filter((sub) => sub.aspectId === aspect.aspectId)
|
||||||
|
.map((sub) => ({
|
||||||
|
subAspectId: sub.subAspectId,
|
||||||
|
subAspectName: sub.subAspectName,
|
||||||
|
averageScore: sub.averageScore,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
assessmentId,
|
||||||
|
aspects: aspectsWithSubAspects,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.patch(
|
||||||
|
"/updateOption",
|
||||||
|
checkPermission("assessments.updateOption"),
|
||||||
|
requestValidator("json", newOptionFormSchema),
|
||||||
|
async (c) => {
|
||||||
|
const optionData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Temukan answerId yang sesuai berdasarkan assessmentId dan questionId
|
||||||
|
const [targetAnswer] = await db
|
||||||
|
.select({ id: answers.id })
|
||||||
|
.from(answers)
|
||||||
|
.leftJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.where(
|
||||||
|
sql`answers."assessmentId" = ${optionData.assessmentId}
|
||||||
|
AND options."questionId" = ${optionData.questionId}`
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetAnswer) {
|
||||||
|
return c.json(
|
||||||
|
{ message: "Answer not found for given assessmentId and questionId" },
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lakukan update pada answer_revisions menggunakan answerId yang ditemukan
|
||||||
|
const [updatedRevision] = await db
|
||||||
|
.update(answerRevisions)
|
||||||
|
.set({
|
||||||
|
newOptionId: optionData.newOptionId,
|
||||||
|
})
|
||||||
|
.where(sql`"answerId" = ${targetAnswer.id}`)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "Revision updated successfully",
|
||||||
|
revision: updatedRevision, // Revisi yang baru saja diperbarui
|
||||||
|
},
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
.patch(
|
||||||
|
"/updateOption",
|
||||||
|
checkPermission("assessments.updateOption"),
|
||||||
|
requestValidator("json", newOptionFormSchema),
|
||||||
|
async (c) => {
|
||||||
|
const optionData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Temukan answerId yang sesuai berdasarkan assessmentId dan questionId
|
||||||
|
const [targetAnswer] = await db
|
||||||
|
.select({ id: answers.id })
|
||||||
|
.from(answers)
|
||||||
|
.leftJoin(options, eq(answers.optionId, options.id))
|
||||||
|
.where(
|
||||||
|
sql`answers."assessmentId" = ${optionData.assessmentId}
|
||||||
|
AND options."questionId" = ${optionData.questionId}`
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetAnswer) {
|
||||||
|
return c.json(
|
||||||
|
{ message: "Answer not found for given assessmentId and questionId" },
|
||||||
|
404
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lakukan update pada answer_revisions menggunakan answerId yang ditemukan
|
||||||
|
const [updatedRevision] = await db
|
||||||
|
.update(answerRevisions)
|
||||||
|
.set({
|
||||||
|
newOptionId: optionData.newOptionId,
|
||||||
|
})
|
||||||
|
.where(sql`"answerId" = ${targetAnswer.id}`)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "Revision updated successfully",
|
||||||
|
revision: updatedRevision, // Revisi yang baru saja diperbarui
|
||||||
|
},
|
||||||
|
200
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default assessmentsRoute;
|
||||||
|
|
@ -134,6 +134,7 @@ 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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
111
apps/backend/src/routes/forgotPassword/route.ts
Normal file
111
apps/backend/src/routes/forgotPassword/route.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { zValidator } from "@hono/zod-validator";
|
||||||
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { and, eq, isNull } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import db from "../../drizzle";
|
||||||
|
import { users } from "../../drizzle/schema/users";
|
||||||
|
import { notFound, unauthorized } from "../../errors/DashboardError";
|
||||||
|
import { generateResetPasswordToken, verifyResetPasswordToken } from "../../utils/authUtils";
|
||||||
|
import { sendResetPasswordEmail } from "../../utils/mailerUtils";
|
||||||
|
import { hashPassword } from "../../utils/passwordUtils";
|
||||||
|
|
||||||
|
const forgotPasswordRoutes = new Hono<HonoEnv>()
|
||||||
|
/**
|
||||||
|
* Forgot Password
|
||||||
|
*
|
||||||
|
* Checking emails in the database, generating tokens, and sending emails occurs.
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
'/',
|
||||||
|
zValidator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { email } = c.req.valid('json');
|
||||||
|
|
||||||
|
const user = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
isNull(users.deletedAt),
|
||||||
|
eq(users.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user.length) throw notFound();
|
||||||
|
|
||||||
|
// Generate reset password token
|
||||||
|
const resetPasswordToken = await generateResetPasswordToken({
|
||||||
|
uid: user[0].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
resetPasswordToken: resetPasswordToken
|
||||||
|
})
|
||||||
|
.where(eq(users.email, email));
|
||||||
|
|
||||||
|
// Send email with reset password token
|
||||||
|
await sendResetPasswordEmail(email, resetPasswordToken);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: 'Email has been sent successfully',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Reset Password
|
||||||
|
*/
|
||||||
|
.patch(
|
||||||
|
'/verify',
|
||||||
|
zValidator(
|
||||||
|
'json',
|
||||||
|
z.object({
|
||||||
|
password: z.string(),
|
||||||
|
confirm_password: z.string()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const formData = c.req.valid('json');
|
||||||
|
const token = c.req.query('token')
|
||||||
|
|
||||||
|
// Token validation
|
||||||
|
if (!token) {
|
||||||
|
return c.json({ message: 'Token is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password validation
|
||||||
|
if (formData.password !== formData.confirm_password) {
|
||||||
|
return c.json({ message: 'Passwords do not match' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoded = await verifyResetPasswordToken(token);
|
||||||
|
if (!decoded) {
|
||||||
|
return c.json({ message: 'Invalid or expired token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decoded) throw unauthorized();
|
||||||
|
|
||||||
|
// Hash the password
|
||||||
|
const hashedPassword = await hashPassword(formData.password);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
password: hashedPassword,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, decoded.uid));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: 'Password has been reset successfully'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default forgotPasswordRoutes;
|
||||||
530
apps/backend/src/routes/managementAspect/route.ts
Normal file
530
apps/backend/src/routes/managementAspect/route.ts
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
import { and, eq, ilike, isNull, or, sql, inArray } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { questions } from "../../drizzle/schema/questions";
|
||||||
|
import { z } from "zod";
|
||||||
|
import db from "../../drizzle";
|
||||||
|
import { aspects } from "../../drizzle/schema/aspects";
|
||||||
|
import { subAspects } from "../../drizzle/schema/subAspects";
|
||||||
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
|
import requestValidator from "../../utils/requestValidator";
|
||||||
|
import authInfo from "../../middlewares/authInfo";
|
||||||
|
import checkPermission from "../../middlewares/checkPermission";
|
||||||
|
import { forbidden } from "../../errors/DashboardError";
|
||||||
|
import { notFound } from "../../errors/DashboardError";
|
||||||
|
|
||||||
|
// Schema for creating and updating aspects
|
||||||
|
export const aspectFormSchema = z.object({
|
||||||
|
name: z.string().min(1).max(50),
|
||||||
|
subAspects: z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
return Array.isArray(parsed);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Sub Aspects must be an array",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema for creating and updating subAspects
|
||||||
|
export const subAspectFormSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1).max(50),
|
||||||
|
aspectId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aspectUpdateSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
subAspects: z.array(subAspectFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const subAspectUpdateSchema = subAspectFormSchema.extend({});
|
||||||
|
|
||||||
|
const managementAspectRoute = new Hono<HonoEnv>()
|
||||||
|
.use(authInfo)
|
||||||
|
/**
|
||||||
|
* Get All Aspects (With Metadata)
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - includeTrashed: boolean (default: false)
|
||||||
|
* - withMetadata: boolean
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Get all aspects
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
checkPermission("managementAspect.readAll"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
includeTrashed: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
withMetadata: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
page: z.coerce.number().int().min(0).default(0),
|
||||||
|
limit: z.coerce.number().int().min(1).max(1000).default(10),
|
||||||
|
q: z.string().default(""),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
|
const aspectCountQuery = await db
|
||||||
|
.select({
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
})
|
||||||
|
.from(aspects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
includeTrashed ? undefined : isNull(aspects.deletedAt),
|
||||||
|
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalItems = Number(aspectCountQuery[0]?.count) || 0;
|
||||||
|
|
||||||
|
const aspectIdsQuery = await db
|
||||||
|
.select({
|
||||||
|
id: aspects.id,
|
||||||
|
})
|
||||||
|
.from(aspects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
includeTrashed ? undefined : isNull(aspects.deletedAt),
|
||||||
|
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(aspects.name)
|
||||||
|
.offset(page * limit)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
const aspectIds = aspectIdsQuery.map(a => a.id);
|
||||||
|
|
||||||
|
if (aspectIds.length === 0) {
|
||||||
|
return c.json({
|
||||||
|
data: [],
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: 0,
|
||||||
|
totalItems: 0,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main query to get aspects, sub-aspects, and number of questions
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: aspects.id,
|
||||||
|
name: aspects.name,
|
||||||
|
createdAt: aspects.createdAt,
|
||||||
|
updatedAt: aspects.updatedAt,
|
||||||
|
...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}),
|
||||||
|
subAspectId: subAspects.id,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
// Increase the number of questions related to sub aspects
|
||||||
|
questionCount: sql<number>`(
|
||||||
|
SELECT count(*)
|
||||||
|
FROM ${questions}
|
||||||
|
WHERE ${questions.subAspectId} = ${subAspects.id}
|
||||||
|
)`.as('questionCount'),
|
||||||
|
})
|
||||||
|
.from(aspects)
|
||||||
|
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.where(inArray(aspects.id, aspectIds))
|
||||||
|
.orderBy(aspects.name);
|
||||||
|
|
||||||
|
// Grouping sub aspects by aspect ID
|
||||||
|
const groupedResult = result.reduce((acc, curr) => {
|
||||||
|
const aspectId = curr.id;
|
||||||
|
|
||||||
|
if (!acc[aspectId]) {
|
||||||
|
acc[aspectId] = {
|
||||||
|
id: curr.id,
|
||||||
|
name: curr.name,
|
||||||
|
createdAt: curr.createdAt ? new Date(curr.createdAt).toISOString() : null,
|
||||||
|
updatedAt: curr.updatedAt ? new Date(curr.updatedAt).toISOString() : null,
|
||||||
|
subAspects: curr.subAspectName
|
||||||
|
? [{ id: curr.subAspectId!, name: curr.subAspectName, questionCount: curr.questionCount }]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
if (curr.subAspectName) {
|
||||||
|
const exists = acc[aspectId].subAspects.some(sub => sub.id === curr.subAspectId);
|
||||||
|
if (!exists) {
|
||||||
|
acc[aspectId].subAspects.push({
|
||||||
|
id: curr.subAspectId!,
|
||||||
|
name: curr.subAspectName,
|
||||||
|
questionCount: curr.questionCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
subAspects: { id: string; name: string; questionCount: number }[];
|
||||||
|
}>);
|
||||||
|
|
||||||
|
const groupedArray = Object.values(groupedResult);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: groupedArray,
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil(totalItems / limit),
|
||||||
|
totalItems,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get aspect by id
|
||||||
|
.get(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("managementAspect.readAll"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
includeTrashed: z.string().default("false"),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const aspectId = c.req.param("id");
|
||||||
|
|
||||||
|
if (!aspectId)
|
||||||
|
throw notFound({
|
||||||
|
message: "Missing id",
|
||||||
|
});
|
||||||
|
|
||||||
|
const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true";
|
||||||
|
|
||||||
|
const queryResult = await db
|
||||||
|
.select({
|
||||||
|
id: aspects.id,
|
||||||
|
name: aspects.name,
|
||||||
|
createdAt: aspects.createdAt,
|
||||||
|
updatedAt: aspects.updatedAt,
|
||||||
|
...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}),
|
||||||
|
subAspect: {
|
||||||
|
name: subAspects.name,
|
||||||
|
id: subAspects.id,
|
||||||
|
questionCount: sql`COUNT(${questions.id})`.as("questionCount"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(aspects)
|
||||||
|
.leftJoin(subAspects, eq(aspects.id, subAspects.aspectId))
|
||||||
|
.leftJoin(questions, eq(subAspects.id, questions.subAspectId))
|
||||||
|
.where(and(eq(aspects.id, aspectId), !includeTrashed ? isNull(aspects.deletedAt) : undefined))
|
||||||
|
.groupBy(aspects.id, aspects.name, aspects.createdAt, aspects.updatedAt, subAspects.id, subAspects.name);
|
||||||
|
|
||||||
|
if (!queryResult.length)
|
||||||
|
throw notFound({
|
||||||
|
message: "The aspect does not exist",
|
||||||
|
});
|
||||||
|
|
||||||
|
const subAspectsList = queryResult.reduce((prev, curr) => {
|
||||||
|
if (!curr.subAspect) return prev;
|
||||||
|
prev.set(curr.subAspect.id, {
|
||||||
|
name: curr.subAspect.name,
|
||||||
|
questionCount: Number(curr.subAspect.questionCount),
|
||||||
|
});
|
||||||
|
return prev;
|
||||||
|
}, new Map<string, { name: string; questionCount: number }>());
|
||||||
|
|
||||||
|
const aspectData = {
|
||||||
|
...queryResult[0],
|
||||||
|
subAspect: undefined,
|
||||||
|
subAspects: Array.from(subAspectsList, ([id, { name, questionCount }]) => ({ id, name, questionCount })),
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(aspectData);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create aspect
|
||||||
|
.post("/",
|
||||||
|
checkPermission("managementAspect.create"),
|
||||||
|
requestValidator("json", aspectFormSchema),
|
||||||
|
async (c) => {
|
||||||
|
const aspectData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Check if aspect name already exists and is not deleted
|
||||||
|
const existingAspect = await db
|
||||||
|
.select()
|
||||||
|
.from(aspects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aspects.name, aspectData.name),
|
||||||
|
isNull(aspects.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingAspect.length > 0) {
|
||||||
|
// Return an error if the aspect name already exists
|
||||||
|
return c.json(
|
||||||
|
{ message: "Aspect name already exists" },
|
||||||
|
400 // Bad Request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it doesn't exist, create a new aspect
|
||||||
|
const aspect = await db
|
||||||
|
.insert(aspects)
|
||||||
|
.values({
|
||||||
|
name: aspectData.name,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const aspectId = aspect[0].id;
|
||||||
|
|
||||||
|
// If there is sub aspect data, parse and insert into the database.
|
||||||
|
if (aspectData.subAspects) {
|
||||||
|
const subAspectsArray = JSON.parse(aspectData.subAspects) as string[];
|
||||||
|
|
||||||
|
// Create a Set to check for duplicates
|
||||||
|
const uniqueSubAspects = new Set<string>();
|
||||||
|
|
||||||
|
// Filter out duplicates
|
||||||
|
const filteredSubAspects = subAspectsArray.filter((subAspect) => {
|
||||||
|
if (uniqueSubAspects.has(subAspect)) {
|
||||||
|
return false; // Skip duplicates
|
||||||
|
}
|
||||||
|
uniqueSubAspects.add(subAspect);
|
||||||
|
return true; // Keep unique sub-aspects
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if there are any unique sub aspects to insert
|
||||||
|
if (filteredSubAspects.length) {
|
||||||
|
// Insert new sub aspects into the database
|
||||||
|
await db.insert(subAspects).values(
|
||||||
|
filteredSubAspects.map((subAspect) => ({
|
||||||
|
aspectId,
|
||||||
|
name: subAspect,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "Aspect and sub aspects created successfully",
|
||||||
|
},
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update aspect
|
||||||
|
.patch(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("managementAspect.update"),
|
||||||
|
requestValidator("json", aspectUpdateSchema),
|
||||||
|
async (c) => {
|
||||||
|
const aspectId = c.req.param("id");
|
||||||
|
const aspectData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Check if new aspect name already exists
|
||||||
|
const existingAspect = await db
|
||||||
|
.select()
|
||||||
|
.from(aspects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(aspects.name, aspectData.name),
|
||||||
|
isNull(aspects.deletedAt),
|
||||||
|
sql`${aspects.id} <> ${aspectId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingAspect.length > 0) {
|
||||||
|
throw notFound({
|
||||||
|
message: "Aspect name already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the aspect in question exists
|
||||||
|
const aspect = await db
|
||||||
|
.select()
|
||||||
|
.from(aspects)
|
||||||
|
.where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt)));
|
||||||
|
|
||||||
|
if (!aspect[0]) throw notFound();
|
||||||
|
|
||||||
|
// Update aspect name
|
||||||
|
await db
|
||||||
|
.update(aspects)
|
||||||
|
.set({
|
||||||
|
name: aspectData.name,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(aspects.id, aspectId));
|
||||||
|
|
||||||
|
// Get new sub aspect data from request
|
||||||
|
const newSubAspects = aspectData.subAspects || [];
|
||||||
|
|
||||||
|
// Take the existing sub aspects
|
||||||
|
const currentSubAspects = await db
|
||||||
|
.select({ id: subAspects.id, name: subAspects.name })
|
||||||
|
.from(subAspects)
|
||||||
|
.where(eq(subAspects.aspectId, aspectId));
|
||||||
|
|
||||||
|
const currentSubAspectMap = new Map(currentSubAspects.map(sub => [sub.id, sub.name]));
|
||||||
|
|
||||||
|
// Sub aspects to be removed
|
||||||
|
const subAspectsToDelete = currentSubAspects
|
||||||
|
.filter(sub => !newSubAspects.some(newSub => newSub.id === sub.id))
|
||||||
|
.map(sub => sub.id);
|
||||||
|
|
||||||
|
// Delete sub aspects that do not exist in the new data
|
||||||
|
if (subAspectsToDelete.length) {
|
||||||
|
await db
|
||||||
|
.delete(subAspects)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(subAspects.aspectId, aspectId),
|
||||||
|
inArray(subAspects.id, subAspectsToDelete)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a Set to check for duplicate sub-aspects
|
||||||
|
const uniqueSubAspectNames = new Set(currentSubAspects.map(sub => sub.name));
|
||||||
|
|
||||||
|
// Update or add new sub aspects
|
||||||
|
for (const subAspect of newSubAspects) {
|
||||||
|
const existingSubAspect = currentSubAspectMap.has(subAspect.id);
|
||||||
|
|
||||||
|
// Check for duplicate sub-aspect names
|
||||||
|
if (uniqueSubAspectNames.has(subAspect.name) && !existingSubAspect) {
|
||||||
|
throw notFound({
|
||||||
|
message: `Sub aspect name "${subAspect.name}" already exists for this aspect.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSubAspect) {
|
||||||
|
// Update if sub aspect already exists
|
||||||
|
await db
|
||||||
|
.update(subAspects)
|
||||||
|
.set({
|
||||||
|
name: subAspect.name,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(subAspects.id, subAspect.id),
|
||||||
|
eq(subAspects.aspectId, aspectId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Add if new sub aspect
|
||||||
|
await db
|
||||||
|
.insert(subAspects)
|
||||||
|
.values({
|
||||||
|
aspectId,
|
||||||
|
name: subAspect.name,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the name to the Set after processing
|
||||||
|
uniqueSubAspectNames.add(subAspect.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Aspect and sub aspects updated successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete aspect
|
||||||
|
.delete(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("managementAspect.delete"),
|
||||||
|
async (c) => {
|
||||||
|
const aspectId = c.req.param("id");
|
||||||
|
|
||||||
|
// Check if aspect exists before deleting
|
||||||
|
const aspect = await db
|
||||||
|
.select()
|
||||||
|
.from(aspects)
|
||||||
|
.where(and(eq(aspects.id, aspectId), isNull(aspects.deletedAt)));
|
||||||
|
|
||||||
|
if (!aspect[0])
|
||||||
|
throw notFound({
|
||||||
|
message: "The aspect is not found",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update deletedAt column on aspect (soft delete)
|
||||||
|
await db
|
||||||
|
.update(aspects)
|
||||||
|
.set({
|
||||||
|
deletedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(aspects.id, aspectId));
|
||||||
|
|
||||||
|
// Soft delete related sub aspects (update deletedAt on the sub-aspect)
|
||||||
|
await db
|
||||||
|
.update(subAspects)
|
||||||
|
.set({
|
||||||
|
deletedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(subAspects.aspectId, aspectId));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Aspect and sub aspects soft deleted successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Undo delete
|
||||||
|
.patch(
|
||||||
|
"/restore/:id",
|
||||||
|
checkPermission("managementAspect.restore"),
|
||||||
|
async (c) => {
|
||||||
|
const aspectId = c.req.param("id");
|
||||||
|
|
||||||
|
// Check if the desired aspects are present
|
||||||
|
const aspect = (await db.select().from(aspects).where(eq(aspects.id, aspectId)))[0];
|
||||||
|
|
||||||
|
if (!aspect) {
|
||||||
|
throw notFound({
|
||||||
|
message: "The aspect is not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the aspect has been deleted (there is deletedAt)
|
||||||
|
if (!aspect.deletedAt) {
|
||||||
|
throw notFound({
|
||||||
|
message: "The aspect is not deleted",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore aspects (remove deletedAt mark)
|
||||||
|
await db.update(aspects).set({ deletedAt: null }).where(eq(aspects.id, aspectId));
|
||||||
|
|
||||||
|
// Restore all related sub aspects that have also been deleted (if any)
|
||||||
|
await db.update(subAspects).set({ deletedAt: null }).where(eq(subAspects.aspectId, aspectId));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Aspect and sub aspects restored successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default managementAspectRoute;
|
||||||
519
apps/backend/src/routes/questions/route.ts
Normal file
519
apps/backend/src/routes/questions/route.ts
Normal file
|
|
@ -0,0 +1,519 @@
|
||||||
|
import { and, eq, ne, ilike, isNull, or, sql } from "drizzle-orm";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
import db from "../../drizzle";
|
||||||
|
import { questions } from "../../drizzle/schema/questions";
|
||||||
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
|
import requestValidator from "../../utils/requestValidator";
|
||||||
|
import authInfo from "../../middlewares/authInfo";
|
||||||
|
import checkPermission from "../../middlewares/checkPermission";
|
||||||
|
import { aspects } from "../../drizzle/schema/aspects";
|
||||||
|
import { subAspects } from "../../drizzle/schema/subAspects";
|
||||||
|
import { notFound } from "../../errors/DashboardError";
|
||||||
|
import { options } from "../../drizzle/schema/options";
|
||||||
|
|
||||||
|
// Schema for creating and updating options
|
||||||
|
export const optionFormSchema = z.object({
|
||||||
|
text: z.string().min(1).max(255),
|
||||||
|
score: z.number().min(0).max(999),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schema for creating and updating questions
|
||||||
|
export const questionFormSchema = z.object({
|
||||||
|
subAspectId: z.string().min(1).max(255),
|
||||||
|
question: z.string().min(1).max(510),
|
||||||
|
needFile: z.boolean().default(false),
|
||||||
|
options: z.array(optionFormSchema).optional(), // Allow options to be included
|
||||||
|
});
|
||||||
|
|
||||||
|
export const questionUpdateSchema = questionFormSchema.extend({
|
||||||
|
question: z.string().min(1).max(510).or(z.literal("")),
|
||||||
|
subAspectId: z.string().min(1).max(255).or(z.literal("")),
|
||||||
|
needFile: z.boolean().default(false).or(z.boolean()),
|
||||||
|
});
|
||||||
|
|
||||||
|
const questionsRoute = new Hono<HonoEnv>()
|
||||||
|
.use(authInfo)
|
||||||
|
/**
|
||||||
|
* Get All Aspects
|
||||||
|
*/
|
||||||
|
.get("/aspects",
|
||||||
|
checkPermission("questions.readAll"),
|
||||||
|
async (c) => {
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: aspects.id,
|
||||||
|
name: aspects.name,
|
||||||
|
createdAt: aspects.createdAt,
|
||||||
|
updatedAt: aspects.updatedAt,
|
||||||
|
})
|
||||||
|
.from(aspects);
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Get All Sub Aspects
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - aspectId: string (optional)
|
||||||
|
*/
|
||||||
|
.get("/subAspects",
|
||||||
|
checkPermission("questions.readAll"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
aspectId: z.string().optional(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { aspectId } = c.req.valid("query");
|
||||||
|
|
||||||
|
const query = db
|
||||||
|
.select({
|
||||||
|
id: subAspects.id,
|
||||||
|
name: subAspects.name,
|
||||||
|
aspectId: subAspects.aspectId,
|
||||||
|
createdAt: subAspects.createdAt,
|
||||||
|
updatedAt: subAspects.updatedAt,
|
||||||
|
})
|
||||||
|
.from(subAspects);
|
||||||
|
|
||||||
|
if (aspectId) {
|
||||||
|
query.where(eq(subAspects.aspectId, aspectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query;
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Get All Questions (With Metadata)
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - includeTrashed: boolean (default: false)
|
||||||
|
* - withMetadata: boolean
|
||||||
|
*/
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
checkPermission("questions.readAll"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
includeTrashed: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
withMetadata: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((v) => v?.toLowerCase() === "true"),
|
||||||
|
page: z.coerce.number().int().min(0).default(0),
|
||||||
|
limit: z.coerce.number().int().min(1).max(1000).default(30),
|
||||||
|
q: z.string().default(""),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
|
const totalCountQuery = includeTrashed
|
||||||
|
? sql<number>`(SELECT count(*) FROM ${questions})`
|
||||||
|
: sql<number>`(SELECT count(*) FROM ${questions} WHERE ${questions.deletedAt} IS NULL)`;
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
id: questions.id,
|
||||||
|
question: questions.question,
|
||||||
|
needFile: questions.needFile,
|
||||||
|
aspectName: aspects.name,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
createdAt: questions.createdAt,
|
||||||
|
updatedAt: questions.updatedAt,
|
||||||
|
...(includeTrashed ? { deletedAt: questions.deletedAt } : {}),
|
||||||
|
averageScore: sql<number | null>`(
|
||||||
|
SELECT ROUND(AVG(${options.score}), 2)
|
||||||
|
FROM ${options}
|
||||||
|
WHERE ${options.questionId} = ${questions.id}
|
||||||
|
AND ${options.deletedAt} IS NULL -- Include only non-deleted options
|
||||||
|
)`,
|
||||||
|
fullCount: totalCountQuery,
|
||||||
|
})
|
||||||
|
.from(questions)
|
||||||
|
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
includeTrashed ? undefined : isNull(questions.deletedAt),
|
||||||
|
q
|
||||||
|
? or(
|
||||||
|
ilike(questions.question, `%${q}%`),
|
||||||
|
ilike(aspects.name, `%${q}%`),
|
||||||
|
ilike(subAspects.name, `%${q}%`),
|
||||||
|
eq(questions.id, q)
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(questions.question)
|
||||||
|
.offset(page * limit)
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
data: result.map((d) => ({ ...d, fullCount: undefined })),
|
||||||
|
_metadata: {
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil(
|
||||||
|
(Number(result[0]?.fullCount) ?? 0) / limit
|
||||||
|
),
|
||||||
|
totalItems: Number(result[0]?.fullCount) ?? 0,
|
||||||
|
perPage: limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Get Question by ID
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - id: string
|
||||||
|
* - includeTrashed: boolean (default: false)
|
||||||
|
*/
|
||||||
|
.get(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("questions.readAll"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
includeTrashed: z.string().default("false"),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const questionId = c.req.param("id");
|
||||||
|
|
||||||
|
if (!questionId)
|
||||||
|
throw notFound({
|
||||||
|
message: "Missing id",
|
||||||
|
});
|
||||||
|
|
||||||
|
const includeTrashed =
|
||||||
|
c.req.query("includeTrashed")?.toLowerCase() === "true";
|
||||||
|
|
||||||
|
const queryResult = await db
|
||||||
|
.select({
|
||||||
|
id: questions.id,
|
||||||
|
question: questions.question,
|
||||||
|
needFile: questions.needFile,
|
||||||
|
subAspectId: questions.subAspectId,
|
||||||
|
subAspectName: subAspects.name,
|
||||||
|
aspectId: subAspects.aspectId,
|
||||||
|
aspectName: aspects.name,
|
||||||
|
createdAt: questions.createdAt,
|
||||||
|
updatedAt: questions.updatedAt,
|
||||||
|
...(includeTrashed ? { deletedAt: questions.deletedAt } : {}),
|
||||||
|
options: {
|
||||||
|
id: options.id,
|
||||||
|
text: options.text,
|
||||||
|
score: options.score,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(questions)
|
||||||
|
.leftJoin(subAspects, eq(questions.subAspectId, subAspects.id))
|
||||||
|
.leftJoin(aspects, eq(subAspects.aspectId, aspects.id))
|
||||||
|
.leftJoin(options, and(eq(questions.id, options.questionId), isNull(options.deletedAt))) // Filter out soft-deleted options
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(questions.id, questionId),
|
||||||
|
!includeTrashed ? isNull(questions.deletedAt) : undefined
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(questions.id, questions.question, questions.needFile, subAspects.aspectId,
|
||||||
|
aspects.name, questions.subAspectId, subAspects.name, questions.createdAt,
|
||||||
|
questions.updatedAt, options.id, options.text, options.score);
|
||||||
|
|
||||||
|
if (!queryResult[0]) throw notFound();
|
||||||
|
|
||||||
|
const optionsList = queryResult.reduce((prev, curr) => {
|
||||||
|
if (!curr.options) return prev;
|
||||||
|
prev.set(curr.options.id,
|
||||||
|
{
|
||||||
|
text: curr.options.text,
|
||||||
|
score: curr.options.score,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return prev;
|
||||||
|
}, new Map<string, { text: string; score: number }>());
|
||||||
|
|
||||||
|
// Convert Map to Array and sort by the score field in ascending order
|
||||||
|
const sortedOptions = Array.from(optionsList, ([id, { text, score }]) => ({ id, text, score }))
|
||||||
|
.sort((a, b) => a.score - b.score); // Sort based on score field in ascending order
|
||||||
|
|
||||||
|
const questionData = {
|
||||||
|
...queryResult[0],
|
||||||
|
options: sortedOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(questionData);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Create Question
|
||||||
|
*
|
||||||
|
* JSON:
|
||||||
|
* - questionFormSchema: object
|
||||||
|
*/
|
||||||
|
.post(
|
||||||
|
"/",
|
||||||
|
checkPermission("questions.create"),
|
||||||
|
requestValidator("json", questionFormSchema),
|
||||||
|
async (c) => {
|
||||||
|
const questionData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Check if the sub aspect exists
|
||||||
|
const existingSubAspect = await db
|
||||||
|
.select()
|
||||||
|
.from(subAspects)
|
||||||
|
.where(eq(subAspects.id, questionData.subAspectId));
|
||||||
|
|
||||||
|
if (existingSubAspect.length === 0) {
|
||||||
|
return c.json({ message: "Sub aspect not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cek apakah question dengan subAspectId yang sama sudah ada
|
||||||
|
const duplicateQuestion = await db
|
||||||
|
.select()
|
||||||
|
.from(questions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(questions.subAspectId, questionData.subAspectId),
|
||||||
|
eq(questions.question, questionData.question)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateQuestion.length > 0) {
|
||||||
|
return c.json(
|
||||||
|
{ message: "Pertanyaan dengan sub-aspek yang sama sudah ada" },
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert question data into the questions table
|
||||||
|
const question = await db
|
||||||
|
.insert(questions)
|
||||||
|
.values({
|
||||||
|
question: questionData.question,
|
||||||
|
needFile: questionData.needFile,
|
||||||
|
subAspectId: questionData.subAspectId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const questionId = question[0].id;
|
||||||
|
|
||||||
|
// Insert options data if provided
|
||||||
|
if (questionData.options && questionData.options.length > 0) {
|
||||||
|
const optionsData = questionData.options.map((option) => ({
|
||||||
|
questionId: questionId,
|
||||||
|
text: option.text,
|
||||||
|
score: option.score,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db.insert(options).values(optionsData);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Question and options created successfully",
|
||||||
|
data: question[0],
|
||||||
|
},
|
||||||
|
201
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Update Question
|
||||||
|
*
|
||||||
|
* JSON:
|
||||||
|
* - questionUpdateSchema: object
|
||||||
|
*/
|
||||||
|
.patch(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("questions.update"),
|
||||||
|
requestValidator("json", questionUpdateSchema),
|
||||||
|
async (c) => {
|
||||||
|
const questionId = c.req.param("id");
|
||||||
|
const questionData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Check if the question exists and is not soft deleted
|
||||||
|
const question = await db
|
||||||
|
.select()
|
||||||
|
.from(questions)
|
||||||
|
.where(and(eq(questions.id, questionId), isNull(questions.deletedAt)));
|
||||||
|
|
||||||
|
if (!question[0]) throw notFound();
|
||||||
|
|
||||||
|
// Check if the combination of subAspectId and question already exists (except for this question)
|
||||||
|
const duplicateQuestion = await db
|
||||||
|
.select()
|
||||||
|
.from(questions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(questions.subAspectId, questionData.subAspectId),
|
||||||
|
eq(questions.question, questionData.question),
|
||||||
|
ne(questions.id, questionId), // Ignore questions that are being updated
|
||||||
|
isNull(questions.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicateQuestion.length > 0) {
|
||||||
|
return c.json(
|
||||||
|
{ message: "Pertanyaan dengan sub-aspek yang sama sudah ada" },
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update question data
|
||||||
|
await db
|
||||||
|
.update(questions)
|
||||||
|
.set({
|
||||||
|
...questionData,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(questions.id, questionId));
|
||||||
|
|
||||||
|
// Check if options data is provided
|
||||||
|
if (questionData.options !== undefined) {
|
||||||
|
// Fetch existing options from the database for this question
|
||||||
|
const existingOptions = await db
|
||||||
|
.select()
|
||||||
|
.from(options)
|
||||||
|
.where(and(eq(options.questionId, questionId), isNull(options.deletedAt)));
|
||||||
|
|
||||||
|
// Prepare new options data for comparison
|
||||||
|
const newOptionsData = questionData.options.map((option) => ({
|
||||||
|
questionId: questionId,
|
||||||
|
text: option.text,
|
||||||
|
score: option.score,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Iterate through existing options and perform updates or soft deletes if needed
|
||||||
|
for (const existingOption of existingOptions) {
|
||||||
|
const matchingOption = newOptionsData.find(
|
||||||
|
(newOption) => newOption.text === existingOption.text
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!matchingOption) {
|
||||||
|
// If the existing option is not in the new options data, soft delete it
|
||||||
|
await db
|
||||||
|
.update(options)
|
||||||
|
.set({ deletedAt: new Date() })
|
||||||
|
.where(eq(options.id, existingOption.id));
|
||||||
|
} else {
|
||||||
|
// If the option is found, update it if the score has changed
|
||||||
|
if (existingOption.score !== matchingOption.score) {
|
||||||
|
await db
|
||||||
|
.update(options)
|
||||||
|
.set({
|
||||||
|
score: matchingOption.score,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(options.id, existingOption.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert new options that do not exist in the database
|
||||||
|
const existingOptionTexts = existingOptions.map((opt) => opt.text);
|
||||||
|
const optionsToInsert = newOptionsData.filter((newOption) => !existingOptionTexts.includes(newOption.text));
|
||||||
|
|
||||||
|
if (optionsToInsert.length > 0) {
|
||||||
|
await db.insert(options).values(optionsToInsert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Question and options updated successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Delete Question
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - id: string
|
||||||
|
* - skipTrash: string (default: false)
|
||||||
|
*/
|
||||||
|
.delete(
|
||||||
|
"/:id",
|
||||||
|
checkPermission("questions.delete"),
|
||||||
|
requestValidator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
skipTrash: z.string().default("false"),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const questionId = c.req.param("id");
|
||||||
|
|
||||||
|
const skipTrash =
|
||||||
|
c.req.valid("query").skipTrash.toLowerCase() === "true";
|
||||||
|
|
||||||
|
const question = await db
|
||||||
|
.select()
|
||||||
|
.from(questions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(questions.id, questionId),
|
||||||
|
skipTrash ? undefined : isNull(questions.deletedAt)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!question[0]) throw notFound();
|
||||||
|
|
||||||
|
if (skipTrash) {
|
||||||
|
await db.delete(questions).where(eq(questions.id, questionId));
|
||||||
|
} else {
|
||||||
|
await db
|
||||||
|
.update(questions)
|
||||||
|
.set({
|
||||||
|
deletedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(questions.id, questionId), isNull(questions.deletedAt)));
|
||||||
|
}
|
||||||
|
return c.json({
|
||||||
|
message: "Question deleted successfully",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Restore Question
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - id: string
|
||||||
|
*/
|
||||||
|
.patch("/restore/:id",
|
||||||
|
checkPermission("questions.restore"),
|
||||||
|
async (c) => {
|
||||||
|
const questionId = c.req.param("id");
|
||||||
|
|
||||||
|
const question = (
|
||||||
|
await db.select().from(questions).where(eq(questions.id, questionId))
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (!question) throw notFound();
|
||||||
|
|
||||||
|
if (!question.deletedAt) {
|
||||||
|
throw new HTTPException(400, {
|
||||||
|
message: "The question is not deleted",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(questions)
|
||||||
|
.set({ deletedAt: null })
|
||||||
|
.where(eq(questions.id, questionId));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
message: "Question restored successfully",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default questionsRoute;
|
||||||
131
apps/backend/src/routes/register/route.ts
Normal file
131
apps/backend/src/routes/register/route.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
|
import db from "../../drizzle";
|
||||||
|
import { respondents } from "../../drizzle/schema/respondents";
|
||||||
|
import { users } from "../../drizzle/schema/users";
|
||||||
|
import { rolesSchema } from "../../drizzle/schema/roles";
|
||||||
|
import { rolesToUsers } from "../../drizzle/schema/rolesToUsers";
|
||||||
|
import { hashPassword } from "../../utils/passwordUtils";
|
||||||
|
import requestValidator from "../../utils/requestValidator";
|
||||||
|
import authInfo from "../../middlewares/authInfo";
|
||||||
|
import { or, eq } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import HonoEnv from "../../types/HonoEnv";
|
||||||
|
import { notFound } from "../../errors/DashboardError";
|
||||||
|
|
||||||
|
const registerFormSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
username: z.string().min(1).max(255),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
companyName: z.string().min(1).max(255),
|
||||||
|
position: z.string().min(1).max(255),
|
||||||
|
workExperience: z.string().min(1).max(255),
|
||||||
|
address: z.string().min(1),
|
||||||
|
phoneNumber: z.string().min(1).max(13),
|
||||||
|
isEnabled: z.string().default("false"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const respondentsRoute = new Hono<HonoEnv>()
|
||||||
|
.use(authInfo)
|
||||||
|
//post user
|
||||||
|
.post("/", requestValidator("json", registerFormSchema), async (c) => {
|
||||||
|
const formData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Check if the provided email or username is already exists in database
|
||||||
|
const conditions = [];
|
||||||
|
if (formData.email) {
|
||||||
|
conditions.push(eq(users.email, formData.email));
|
||||||
|
}
|
||||||
|
conditions.push(eq(users.username, formData.username));
|
||||||
|
|
||||||
|
const existingUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(users.email, formData.email),
|
||||||
|
eq(users.username, formData.username)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingRespondent = await db
|
||||||
|
.select()
|
||||||
|
.from(respondents)
|
||||||
|
.where(eq(respondents.phoneNumber, formData.phoneNumber));
|
||||||
|
|
||||||
|
if (existingUser.length > 0) {
|
||||||
|
throw new HTTPException(400, {
|
||||||
|
message: "Email or username has been registered",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRespondent.length > 0) {
|
||||||
|
throw new HTTPException(400, {
|
||||||
|
message: "Phone number has been registered",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password
|
||||||
|
const hashedPassword = await hashPassword(formData.password);
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
const result = await db.transaction(async (trx) => {
|
||||||
|
// Create user
|
||||||
|
const [newUser] = await trx
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
name: formData.name,
|
||||||
|
username: formData.username,
|
||||||
|
email: formData.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
isEnabled: formData.isEnabled?.toLowerCase() === "true" || true,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.catch(() => {
|
||||||
|
throw new HTTPException(500, { message: "Error creating user" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create respondent
|
||||||
|
await trx
|
||||||
|
.insert(respondents)
|
||||||
|
.values({
|
||||||
|
companyName: formData.companyName,
|
||||||
|
position: formData.position,
|
||||||
|
workExperience: formData.workExperience,
|
||||||
|
address: formData.address,
|
||||||
|
phoneNumber: formData.phoneNumber,
|
||||||
|
userId: newUser.id,
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
throw new HTTPException(500, {
|
||||||
|
message: "Error creating respondent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically assign "user" role to the new user
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(rolesSchema)
|
||||||
|
.where(eq(rolesSchema.code, "user"))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role) throw notFound();
|
||||||
|
|
||||||
|
await trx.insert(rolesToUsers).values({
|
||||||
|
userId: newUser.id,
|
||||||
|
roleId: role.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return newUser;
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
message: "User created successfully",
|
||||||
|
},
|
||||||
|
201
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default respondentsRoute;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
import { and, eq, ilike, isNull, or, sql, not, inArray } from "drizzle-orm";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -12,30 +12,21 @@ 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).max(255),
|
name: z.string().min(1, "Nama wajib diisi").max(255),
|
||||||
username: z.string().min(1).max(255),
|
username: z.string().min(1, "Username wajib diisi").max(255),
|
||||||
email: z.string().email().optional().or(z.literal("")),
|
email: z.string().min(1, "Email wajib diisi").max(255),
|
||||||
password: z.string().min(6),
|
password: z.string().min(6, "Password wajib diisi"),
|
||||||
|
companyName: z.string().min(1, "Nama Perusahaan wajib diisi").max(255),
|
||||||
|
position: z.string().min(1, "Jabatan wajib diisi").max(255),
|
||||||
|
workExperience: z.string().min(1, "Pengalaman Kerja wajib diisi").max(255),
|
||||||
|
address: z.string().min(1, "Alamat wajib diisi"),
|
||||||
|
phoneNumber: z.string().min(1, "Nomor Telepon wajib diisi").max(13),
|
||||||
isEnabled: z.string().default("false"),
|
isEnabled: z.string().default("false"),
|
||||||
roles: z
|
roles: z.array(z.string().min(1, "Role wajib diisi")),
|
||||||
.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({
|
||||||
|
|
@ -51,6 +42,8 @@ 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"),
|
||||||
|
|
@ -66,17 +59,69 @@ 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(1),
|
limit: z.coerce.number().int().min(1).max(1000).default(10),
|
||||||
q: z.string().default(""),
|
q: z.string().default(""), // Keyword search
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
const { includeTrashed, page, limit, q } = c.req.valid("query");
|
||||||
|
|
||||||
const totalCountQuery = includeTrashed
|
// Query to count total data without duplicates
|
||||||
? sql<number>`(SELECT count(*) FROM ${users})`
|
const totalCountQuery = db
|
||||||
: sql<number>`(SELECT count(*) FROM ${users} WHERE ${users.deletedAt} IS NULL)`;
|
.select({
|
||||||
|
count: sql<number>`count(distinct ${users.id})`,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.leftJoin(respondents, eq(users.id, respondents.userId))
|
||||||
|
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
|
||||||
|
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
includeTrashed ? undefined : isNull(users.deletedAt),
|
||||||
|
q
|
||||||
|
? or(
|
||||||
|
ilike(users.name, `%${q}%`), // Search by name
|
||||||
|
ilike(users.username, `%${q}%`), // Search by username
|
||||||
|
ilike(users.email, `%${q}%`), // Search by email
|
||||||
|
ilike(respondents.companyName, `%${q}%`), // Search by companyName (from respondents)
|
||||||
|
ilike(rolesSchema.name, `%${q}%`) // Search by role name (from rolesSchema)
|
||||||
|
)
|
||||||
|
: 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,
|
||||||
|
|
@ -87,38 +132,78 @@ 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 } : {}),
|
||||||
fullCount: totalCountQuery,
|
company: respondents.companyName,
|
||||||
|
role: {
|
||||||
|
name: rolesSchema.name,
|
||||||
|
id: rolesSchema.id,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(
|
.leftJoin(respondents, eq(users.id, respondents.userId))
|
||||||
and(
|
.leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId))
|
||||||
includeTrashed ? undefined : isNull(users.deletedAt),
|
.leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id))
|
||||||
q
|
.where(inArray(users.id, userIdsQuery)) // Only take data based on IDs from subquery
|
||||||
? or(
|
.orderBy(users.createdAt);
|
||||||
ilike(users.name, q),
|
|
||||||
ilike(users.username, q),
|
// Group roles for each user to avoid duplication
|
||||||
ilike(users.email, q),
|
const userMap = new Map<
|
||||||
eq(users.id, q)
|
string,
|
||||||
)
|
{
|
||||||
: undefined
|
id: string;
|
||||||
)
|
name: string;
|
||||||
)
|
email: string | null;
|
||||||
.offset(page * limit)
|
username: string;
|
||||||
.limit(limit);
|
isEnabled: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
company: string | null;
|
||||||
|
roles: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
result.forEach((item) => {
|
||||||
|
if (!userMap.has(item.id)) {
|
||||||
|
userMap.set(item.id, {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
email: item.email ?? null,
|
||||||
|
username: item.username,
|
||||||
|
isEnabled: item.isEnabled ?? false,
|
||||||
|
createdAt: item.createdAt ?? new Date(),
|
||||||
|
updatedAt: item.updatedAt ?? new Date(),
|
||||||
|
deletedAt: item.deletedAt ?? undefined,
|
||||||
|
company: item.company,
|
||||||
|
roles: item.role
|
||||||
|
? [{ id: item.role.id, name: item.role.name }]
|
||||||
|
: [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const existingUser = userMap.get(item.id);
|
||||||
|
if (item.role) {
|
||||||
|
existingUser?.roles.push({
|
||||||
|
id: item.role.id,
|
||||||
|
name: item.role.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return user data without duplicates, with roles array
|
||||||
|
const groupedData = Array.from(userMap.values());
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
data: result.map((d) => ({ ...d, fullCount: undefined })),
|
data: groupedData,
|
||||||
_metadata: {
|
_metadata: {
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
totalPages: Math.ceil(
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
(Number(result[0]?.fullCount) ?? 0) / limit
|
totalItems: totalCount,
|
||||||
),
|
|
||||||
totalItems: Number(result[0]?.fullCount) ?? 0,
|
|
||||||
perPage: limit,
|
perPage: limit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
//get user by id
|
//get user by id
|
||||||
.get(
|
.get(
|
||||||
"/:id",
|
"/:id",
|
||||||
|
|
@ -139,7 +224,12 @@ 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,
|
||||||
|
|
@ -151,6 +241,7 @@ 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(
|
||||||
|
|
@ -161,9 +252,9 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!queryResult.length)
|
if (!queryResult.length)
|
||||||
throw new HTTPException(404, {
|
throw notFound({
|
||||||
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;
|
||||||
|
|
@ -180,38 +271,121 @@ 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("form", userFormSchema),
|
requestValidator("json", userFormSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const userData = c.req.valid("form");
|
const userData = c.req.valid("json");
|
||||||
|
|
||||||
const user = await db
|
// Check if the provided email or username is already exists in database
|
||||||
|
const conditions = [];
|
||||||
|
if (userData.email) {
|
||||||
|
conditions.push(eq(users.email, userData.email));
|
||||||
|
}
|
||||||
|
conditions.push(eq(users.username, userData.username));
|
||||||
|
|
||||||
|
const existingUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
or(
|
||||||
|
eq(users.email, userData.email),
|
||||||
|
eq(users.username, userData.username)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingRespondent = await db
|
||||||
|
.select()
|
||||||
|
.from(respondents)
|
||||||
|
.where(eq(respondents.phoneNumber, userData.phoneNumber));
|
||||||
|
|
||||||
|
if (existingUser.length > 0) {
|
||||||
|
throw forbidden({
|
||||||
|
message: "Email or username has been registered",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingRespondent.length > 0) {
|
||||||
|
throw forbidden({
|
||||||
|
message: "Phone number has been registered",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the password
|
||||||
|
const hashedPassword = await hashPassword(userData.password);
|
||||||
|
|
||||||
|
// Start a transaction
|
||||||
|
const result = await db.transaction(async (trx) => {
|
||||||
|
// Create user
|
||||||
|
const [newUser] = await trx
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
name: userData.name,
|
name: userData.name,
|
||||||
username: userData.username,
|
username: userData.username,
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
password: await hashPassword(userData.password),
|
password: hashedPassword,
|
||||||
isEnabled: userData.isEnabled.toLowerCase() === "true",
|
isEnabled: userData.isEnabled?.toLowerCase() === "true" || true,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning()
|
||||||
|
.catch(() => {
|
||||||
|
throw forbidden({
|
||||||
|
message: "Error creating user",
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
if (userData.roles) {
|
// Create respondent
|
||||||
const roles = JSON.parse(userData.roles) as string[];
|
const [newRespondent] = await trx
|
||||||
console.log(roles);
|
.insert(respondents)
|
||||||
|
.values({
|
||||||
|
companyName: userData.companyName,
|
||||||
|
position: userData.position,
|
||||||
|
workExperience: userData.workExperience + " Tahun",
|
||||||
|
address: userData.address,
|
||||||
|
phoneNumber: userData.phoneNumber,
|
||||||
|
userId: newUser.id,
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
.catch((err) => {
|
||||||
|
throw new HTTPException(500, {
|
||||||
|
message: "Error creating respondent: " + err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
if (roles.length) {
|
// Add other roles if provided
|
||||||
await db.insert(rolesToUsers).values(
|
if (userData.roles && userData.roles.length > 0) {
|
||||||
roles.map((role) => ({
|
const roles = userData.roles;
|
||||||
userId: user[0].id,
|
|
||||||
roleId: role,
|
for (let roleId of roles) {
|
||||||
}))
|
const role = (
|
||||||
);
|
await trx
|
||||||
|
.select()
|
||||||
|
.from(rolesSchema)
|
||||||
|
.where(eq(rolesSchema.id, roleId))
|
||||||
|
.limit(1)
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
await trx.insert(rolesToUsers).values({
|
||||||
|
userId: newUser.id,
|
||||||
|
roleId: role.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new HTTPException(404, {
|
||||||
|
message: `Role ${roleId} does not exists`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw forbidden({
|
||||||
|
message: "Harap pilih minimal satu role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newUser;
|
||||||
|
});
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
|
|
@ -226,10 +400,32 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
.patch(
|
.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
checkPermission("users.update"),
|
checkPermission("users.update"),
|
||||||
requestValidator("form", userUpdateSchema),
|
requestValidator("json", userUpdateSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const userId = c.req.param("id");
|
const userId = c.req.param("id");
|
||||||
const userData = c.req.valid("form");
|
const userData = c.req.valid("json");
|
||||||
|
|
||||||
|
// Check if the provided email or username is already exists in the database (excluding the current user)
|
||||||
|
if (userData.email || userData.username) {
|
||||||
|
const existingUser = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(
|
||||||
|
eq(users.email, userData.email),
|
||||||
|
eq(users.username, userData.username)
|
||||||
|
),
|
||||||
|
not(eq(users.id, userId))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser.length > 0) {
|
||||||
|
throw forbidden({
|
||||||
|
message: "Email or username has been registered by another user",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await db
|
const user = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
@ -238,18 +434,70 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
|
|
||||||
if (!user[0]) return c.notFound();
|
if (!user[0]) return c.notFound();
|
||||||
|
|
||||||
await db
|
// Start transaction to update both user and respondent
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
// Update user
|
||||||
|
await trx
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
...userData,
|
name: userData.name,
|
||||||
...(userData.password
|
username: userData.username,
|
||||||
? { password: await hashPassword(userData.password) }
|
email: userData.email,
|
||||||
: {}),
|
|
||||||
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",
|
||||||
});
|
});
|
||||||
|
|
@ -273,6 +521,7 @@ 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)
|
||||||
|
|
@ -283,17 +532,20 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Throw error if the user does not exist
|
||||||
if (!user[0])
|
if (!user[0])
|
||||||
throw new HTTPException(404, {
|
throw notFound ({
|
||||||
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 new HTTPException(400, {
|
throw forbidden ({
|
||||||
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 {
|
||||||
|
|
@ -311,21 +563,27 @@ const usersRoute = new Hono<HonoEnv>()
|
||||||
)
|
)
|
||||||
|
|
||||||
//undo delete
|
//undo delete
|
||||||
.patch("/restore/:id", checkPermission("users.restore"), async (c) => {
|
.patch(
|
||||||
|
"/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 new HTTPException(400, {
|
throw forbidden({
|
||||||
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,6 +5,7 @@ type HonoEnv = {
|
||||||
Variables: {
|
Variables: {
|
||||||
uid?: string;
|
uid?: string;
|
||||||
currentUser?: {
|
currentUser?: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
permissions: SpecificPermissionCode[];
|
permissions: SpecificPermissionCode[];
|
||||||
roles: RoleCode[];
|
roles: RoleCode[];
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ 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";
|
||||||
|
|
@ -11,6 +12,7 @@ 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 {
|
||||||
|
|
@ -21,6 +23,10 @@ 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.
|
||||||
*
|
*
|
||||||
|
|
@ -84,3 +90,35 @@ 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
33
apps/backend/src/utils/mailerUtils.ts
Normal file
33
apps/backend/src/utils/mailerUtils.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import appEnv from '../appEnv';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nodemailer configuration
|
||||||
|
*/
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: appEnv.SMTP_HOST,
|
||||||
|
port: appEnv.SMTP_PORT,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: appEnv.SMTP_USERNAME,
|
||||||
|
pass: appEnv.SMTP_PASSWORD,
|
||||||
|
},
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function sendResetPasswordEmail(to: string, token: string) {
|
||||||
|
const resetUrl = appEnv.BASE_URL + '/forgot-password/verify?token=' + token;
|
||||||
|
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"Your App" <${appEnv.SMTP_USERNAME}>`,
|
||||||
|
to,
|
||||||
|
subject: 'Password Reset Request',
|
||||||
|
text: `You requested a password reset. Click this link to reset your password: ${resetUrl}`,
|
||||||
|
html: `<p>You requested a password reset. Click this link to reset your password:<br><a href="${resetUrl}">${resetUrl}</a></p>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Email sent: %s', info.messageId);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
@ -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="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/src/assets/logos/amati-icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Vite + React + TS</title>
|
<title>Amati</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,22 @@
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|
@ -24,11 +35,14 @@
|
||||||
"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"
|
||||||
|
|
|
||||||
BIN
apps/frontend/src/assets/backgrounds/backgroundLogin.png
Normal file
BIN
apps/frontend/src/assets/backgrounds/backgroundLogin.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 447 KiB |
BIN
apps/frontend/src/assets/backgrounds/backgroundLoginMobile.png
Normal file
BIN
apps/frontend/src/assets/backgrounds/backgroundLoginMobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
apps/frontend/src/assets/logos/amati-icon.png
Normal file
BIN
apps/frontend/src/assets/logos/amati-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
apps/frontend/src/assets/logos/amati-logo-old.png
Normal file
BIN
apps/frontend/src/assets/logos/amati-logo-old.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
BIN
apps/frontend/src/assets/logos/amati-logo-old2.png
Normal file
BIN
apps/frontend/src/assets/logos/amati-logo-old2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
BIN
apps/frontend/src/assets/logos/amati-logo.png
Normal file
BIN
apps/frontend/src/assets/logos/amati-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
|
|
@ -1,96 +1,195 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import logo from "@/assets/logos/amati-logo.png";
|
||||||
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 { TbChevronDown } from "react-icons/tb";
|
import { IoMdMenu } from "react-icons/io";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link, useLocation } 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",
|
||||||
// };
|
// };
|
||||||
|
|
||||||
export default function AppHeader(props: Props) {
|
interface Props {
|
||||||
const [userMenuOpened, setUserMenuOpened] = useState(false);
|
toggle: () => void;
|
||||||
|
toggleLeftSidebar: () => void; // Add this prop
|
||||||
|
}
|
||||||
|
|
||||||
const { user } = useAuth();
|
export default function AppHeader({ toggle, toggleLeftSidebar }: Props) {
|
||||||
|
const [userMenuOpened, setUserMenuOpened] = useState(false);
|
||||||
|
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const { user }: { user: User | null } = useAuth();
|
||||||
|
const isSuperAdmin = user?.role === "super-admin";
|
||||||
|
|
||||||
// const userMenus = getUserMenus().map((item, i) => (
|
// 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 (
|
||||||
<AppShell.Header>
|
<header className="fixed top-0 left-0 w-full h-16 bg-white z-50 border">
|
||||||
<Group h="100%" px="md" justify="space-between">
|
<div className="flex h-full justify-between w-full items-center">
|
||||||
<Burger
|
<div className="flex items-center">
|
||||||
opened={props.openNavbar}
|
{shouldShowButton && (
|
||||||
onClick={props.toggle}
|
<Button
|
||||||
hiddenFrom="sm"
|
onClick={toggle}
|
||||||
size="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"
|
||||||
/>
|
|
||||||
<img src={logo} alt="" className="h-8" />
|
|
||||||
<Menu
|
|
||||||
width={260}
|
|
||||||
position="bottom-end"
|
|
||||||
transitionProps={{ transition: "pop-top-right" }}
|
|
||||||
onOpen={() => setUserMenuOpened(true)}
|
|
||||||
onClose={() => setUserMenuOpened(false)}
|
|
||||||
withinPortal
|
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<IoMdMenu />
|
||||||
<UnstyledButton
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(showAssessmentLinks || showVerifyingAssessmentLinks || showAssessmentResultLinks) && (
|
||||||
|
<TbMenu2 onClick={toggleLeftSidebar} className="md:ml-0 ml-6 mt-8 w-6 h-fit pb-4 mb-4 cursor-pointer lg:hidden" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-44 h-fit px-8 py-5 object-cover hidden lg:block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conditional Navlinks */}
|
||||||
|
{!isSuperAdmin && (
|
||||||
|
<div className="flex space-x-4 justify-center w-full ml-14">
|
||||||
|
{showAssessmentResultLinks && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/assessmentRequest"
|
||||||
|
className={cx("text-sm font-medium", {
|
||||||
|
"text-blue-600":
|
||||||
|
assessmentRequestsLinks, // warna aktif
|
||||||
|
"text-gray-700":
|
||||||
|
!assessmentRequestsLinks, // warna default
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.opener) {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Permohonan Asesmen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/assessmentResult"
|
||||||
|
className={cx("text-sm font-medium", {
|
||||||
|
"text-blue-600":
|
||||||
|
showAssessmentResultLinks, // warna aktif
|
||||||
|
"text-gray-700":
|
||||||
|
!showAssessmentResultLinks, // warna default
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Hasil Asesmen
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAssessmentLinks && (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
to="/assessmentRequest"
|
||||||
|
className={cx("text-sm font-medium", {
|
||||||
|
"text-blue-600":
|
||||||
|
assessmentRequestsLinks, // warna aktif
|
||||||
|
"text-gray-700":
|
||||||
|
!assessmentRequestsLinks, // warna default
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.opener) {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Permohonan Asesmen
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/assessment"
|
||||||
|
className={cx("text-sm font-medium", {
|
||||||
|
"text-blue-600": showAssessmentLinks, // warna aktif
|
||||||
|
"text-gray-700": !showAssessmentLinks, // warna default
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Asesmen
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenu
|
||||||
|
modal={false}
|
||||||
|
open={userMenuOpened}
|
||||||
|
onOpenChange={setUserMenuOpened}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild className="flex">
|
||||||
|
<button
|
||||||
className={cx(classNames.user, {
|
className={cx(classNames.user, {
|
||||||
[classNames.userActive]: userMenuOpened,
|
[classNames.userActive]: userMenuOpened,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Group gap={7}>
|
<div className="flex items-center">
|
||||||
<Avatar
|
<Avatar>
|
||||||
// src={user?.photoProfile}
|
{user?.photoProfile ? (
|
||||||
// alt={user?.name}
|
<AvatarImage src={user.photoProfile} />
|
||||||
radius="xl"
|
) : (
|
||||||
size={30}
|
<AvatarFallback>
|
||||||
/>
|
{user?.name?.charAt(0) ?? "A"}
|
||||||
<Text fw={500} size="sm" lh={1} mr={3}>
|
</AvatarFallback>
|
||||||
{/* {user?.name} */}
|
)}
|
||||||
{user?.name ?? "Anonymous"}
|
</Avatar>
|
||||||
</Text>
|
</div>
|
||||||
<TbChevronDown
|
</button>
|
||||||
style={{ width: rem(12), height: rem(12) }}
|
</DropdownMenuTrigger>
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</UnstyledButton>
|
|
||||||
</Menu.Target>
|
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<DropdownMenuContent
|
||||||
<Menu.Item component={Link} to="/logout">
|
align="end"
|
||||||
Logout
|
className="transition-all duration-200 z-50 border bg-white w-64"
|
||||||
</Menu.Item>
|
>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
{/* {userMenus} */}
|
<Link to="/logout">Logout</Link>
|
||||||
</Menu.Dropdown>
|
</DropdownMenuItem>
|
||||||
</Menu>
|
</DropdownMenuContent>
|
||||||
</Group>
|
</DropdownMenu>
|
||||||
</AppShell.Header>
|
</div>
|
||||||
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
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";
|
||||||
|
|
@ -13,7 +17,21 @@ import MenuItem from "./NavbarMenuItem";
|
||||||
* @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"],
|
||||||
|
|
@ -21,24 +39,81 @@ 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">
|
<>
|
||||||
<ScrollArea style={{ flex: "1" }}>
|
<div>
|
||||||
{data?.map((menu, i) => <MenuItem menu={menu} key={i} />)}
|
{/* Header */}
|
||||||
{/* {user?.sidebarMenus.map((menu, i) => (
|
<AppHeader toggle={toggleSidebar} openNavbar={isSidebarOpen} toggleLeftSidebar={toggleLeftSidebar} />
|
||||||
<MenuItem menu={menu} key={i} />
|
|
||||||
)) ?? null} */}
|
{/* Sidebar */}
|
||||||
|
{!pathsThatCloseSidebar.includes(pathname) && (
|
||||||
|
<div
|
||||||
|
className={`fixed lg:relative w-64 bg-white top-16 left-0 h-full z-40 px-3 py-4 transition-transform border-x
|
||||||
|
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
||||||
|
>
|
||||||
|
<ScrollArea className="flex flex-1 h-full">
|
||||||
|
{filteredData?.map((menu, i) => (
|
||||||
|
<MenuItem
|
||||||
|
key={i}
|
||||||
|
menu={menu}
|
||||||
|
isActive={pathname === menu.link}
|
||||||
|
onClick={handleMenuItemClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</AppShell.Navbar>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { Table, Center, ScrollArea } from "@mantine/core";
|
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
|
||||||
import { Table as ReactTable, flexRender } from "@tanstack/react-table";
|
import {
|
||||||
|
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>;
|
||||||
|
|
@ -7,22 +15,15 @@ interface Props<TData> {
|
||||||
|
|
||||||
export default function DashboardTable<T>({ table }: Props<T>) {
|
export default function DashboardTable<T>({ table }: Props<T>) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea.Autosize>
|
<div className="w-full max-w-full overflow-x-auto border rounded-lg">
|
||||||
<Table
|
<Table className="min-w-full divide-y divide-muted-foreground bg-white">
|
||||||
verticalSpacing="xs"
|
<TableHeader>
|
||||||
horizontalSpacing="xs"
|
|
||||||
striped
|
|
||||||
highlightOnHover
|
|
||||||
withColumnBorders
|
|
||||||
withRowBorders
|
|
||||||
>
|
|
||||||
{/* Thead */}
|
|
||||||
<Table.Thead>
|
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<Table.Tr key={headerGroup.id}>
|
<TableRow key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<Table.Th
|
<TableHead
|
||||||
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()}`,
|
||||||
|
|
@ -30,45 +31,41 @@ export default function DashboardTable<T>({ table }: Props<T>) {
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
header.column.columnDef.header,
|
</TableHead>
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</Table.Th>
|
|
||||||
))}
|
))}
|
||||||
</Table.Tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</Table.Thead>
|
</TableHeader>
|
||||||
|
|
||||||
{/* Tbody */}
|
<TableBody>
|
||||||
<Table.Tbody>
|
|
||||||
{table.getRowModel().rows.length > 0 ? (
|
{table.getRowModel().rows.length > 0 ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<Table.Tr key={row.id}>
|
<TableRow key={row.id}>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<Table.Td
|
<TableCell
|
||||||
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(
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
cell.column.columnDef.cell,
|
</TableCell>
|
||||||
cell.getContext()
|
|
||||||
)}
|
|
||||||
</Table.Td>
|
|
||||||
))}
|
))}
|
||||||
</Table.Tr>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<Table.Tr>
|
<TableRow>
|
||||||
<Table.Td colSpan={table.getAllColumns().length}>
|
<TableCell colSpan={table.getAllColumns().length} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||||
<Center>- No Data -</Center>
|
- No Data -
|
||||||
</Table.Td>
|
</TableCell>
|
||||||
</Table.Tr>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
</Table.Tbody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea.Autosize>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -22,13 +20,10 @@ export default function ChildMenu(props: Props) {
|
||||||
: `/${props.item.link}`;
|
: `/${props.item.link}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text<"a">
|
<a href={`${linkPath}`}
|
||||||
component="a"
|
className={`${props.active ? "font-bold" : "font-normal"} ${classNames.link} text-blue-600 hover:underline`}
|
||||||
className={classNames.link}
|
|
||||||
href={`${linkPath}`}
|
|
||||||
fw={props.active ? "bold" : "normal"}
|
|
||||||
>
|
>
|
||||||
{props.item.label}
|
{props.item.label}
|
||||||
</Text>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,6 @@
|
||||||
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";
|
||||||
|
|
@ -19,9 +8,14 @@ import classNames from "./styles/navbarMenuItem.module.css";
|
||||||
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
|
||||||
|
|
@ -34,7 +28,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 }: Props) {
|
export default function MenuItem({ menu, isActive, onClick }: Props) {
|
||||||
const hasChildren = Array.isArray(menu.children);
|
const hasChildren = Array.isArray(menu.children);
|
||||||
|
|
||||||
// const pathname = usePathname();
|
// const pathname = usePathname();
|
||||||
|
|
@ -50,6 +44,13 @@ export default function MenuItem({ menu }: 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} />
|
||||||
|
|
@ -69,43 +70,41 @@ export default function MenuItem({ menu }: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Main Menu Item */}
|
{/* Main Menu Item */}
|
||||||
<UnstyledButton<typeof Link | "button">
|
<Button
|
||||||
onClick={toggleOpenMenu}
|
variant="ghost"
|
||||||
className={`${classNames.control} py-2`}
|
className={cn(
|
||||||
to={menu.link}
|
"w-full p-2 rounded-md justify-between focus:outline-none",
|
||||||
component={menu.link ? Link : "button"}
|
isActive ? "bg-[--primary-color] text-white" : "text-black"
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
asChild
|
||||||
>
|
>
|
||||||
<Group justify="space-between" gap={0}>
|
<Link to={menu.link ?? "#"}>
|
||||||
{/* Icon and Label */}
|
<div className="flex items-center">
|
||||||
<Box style={{ display: "flex", alignItems: "center" }}>
|
{/* Icon */}
|
||||||
<ThemeIcon variant="light" size={30} color={menu.color}>
|
<span className="mr-3">
|
||||||
<Icon style={{ width: rem(18), height: rem(18) }} />
|
<Icon className="w-4 h-4" />
|
||||||
</ThemeIcon>
|
</span>
|
||||||
|
{/* Label */}
|
||||||
<Box ml="md" fw={500}>
|
<span className="text-xs font-normal whitespace-normal">{menu.label}</span>
|
||||||
{menu.label}
|
</div>
|
||||||
</Box>
|
{/* Chevron Icon */}
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Chevron Icon for collapsible items */}
|
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<TbChevronRight
|
<ChevronRightIcon
|
||||||
strokeWidth={1.5}
|
className={`w-4 h-4 transition-transform ${
|
||||||
style={{
|
opened ? "rotate-90" : "rotate-0"
|
||||||
width: rem(16),
|
}`}
|
||||||
height: rem(16),
|
|
||||||
transform: opened
|
|
||||||
? "rotate(-90deg)"
|
|
||||||
: "rotate(90deg)",
|
|
||||||
}}
|
|
||||||
className={classNames.chevron}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Link>
|
||||||
</UnstyledButton>
|
</Button>
|
||||||
|
|
||||||
{/* Collapsible Sub-Menu */}
|
{/* Collapsible Sub-Menu */}
|
||||||
{hasChildren && <Collapse in={opened}>{subItems}</Collapse>}
|
{hasChildren && (
|
||||||
|
<div className={cn("transition-all", opened ? "block" : "hidden")}>
|
||||||
|
{subItems}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,4 @@
|
||||||
/* 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";
|
||||||
|
|
@ -25,7 +13,25 @@ import {
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { Card } from "@/shadcn/components/ui/card";
|
||||||
|
import { Input } from "@/shadcn/components/ui/input";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
} from "@/shadcn/components/ui/pagination";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shadcn/components/ui/select";
|
||||||
|
import { HiChevronLeft, HiChevronRight } from "react-icons/hi";
|
||||||
|
|
||||||
type PaginatedResponse<T extends Record<string, unknown>> = {
|
type PaginatedResponse<T extends Record<string, unknown>> = {
|
||||||
data: Array<T>;
|
data: Array<T>;
|
||||||
|
|
@ -70,24 +76,36 @@ 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
|
||||||
leftSection={<TbPlus />}
|
className="gap-2"
|
||||||
component={Link}
|
variant={isAssessmentRequestPage ? "request" : "outline"}
|
||||||
search={{ create: true }}
|
onClick={addQuery}
|
||||||
>
|
>
|
||||||
Create New
|
{isAssessmentRequestPage ? "Ajukan Permohonan" : "Tambah Data"}
|
||||||
|
<TbPlus />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
} else if (typeof property === "string") {
|
} else if (typeof property === "string") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
leftSection={<TbPlus />}
|
className="gap-2"
|
||||||
component={Link}
|
variant={"outline"}
|
||||||
search={{ create: true }}
|
onClick={addQuery}
|
||||||
>
|
>
|
||||||
{property}
|
{property}
|
||||||
|
<TbPlus />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -95,6 +113,109 @@ const createCreateButton = (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pagination component for handling page navigation.
|
||||||
|
*
|
||||||
|
* @param props - The properties object.
|
||||||
|
* @returns The rendered Pagination component.
|
||||||
|
*/
|
||||||
|
const CustomPagination = ({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onChange: (page: number) => void;
|
||||||
|
}) => {
|
||||||
|
const getPaginationItems = () => {
|
||||||
|
let items = [];
|
||||||
|
|
||||||
|
// Determine start and end pages
|
||||||
|
let startPage =
|
||||||
|
currentPage == totalPages && currentPage > 3 ?
|
||||||
|
Math.max(1, currentPage - 2) :
|
||||||
|
Math.max(1, currentPage - 1);
|
||||||
|
let endPage =
|
||||||
|
currentPage == 1 ?
|
||||||
|
Math.min(totalPages, currentPage + 2) :
|
||||||
|
Math.min(totalPages, currentPage + 1);
|
||||||
|
|
||||||
|
// Add ellipsis if needed
|
||||||
|
if (startPage > 2) {
|
||||||
|
items.push(<PaginationEllipsis key="start-ellipsis" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add page numbers
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
items.push(
|
||||||
|
<Button className='cursor-pointer' key={i} onClick={() => onChange(i)} variant={currentPage == i ? "outline" : "ghost"}>
|
||||||
|
{i}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ellipsis after
|
||||||
|
if (endPage < totalPages - 1) {
|
||||||
|
items.push(<PaginationEllipsis key="end-ellipsis" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add last page
|
||||||
|
if (endPage < totalPages) {
|
||||||
|
items.push(
|
||||||
|
<Button className='cursor-pointer' key={totalPages} onClick={() => onChange(totalPages)} variant={"ghost"}>
|
||||||
|
{totalPages}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (currentPage > 2) {
|
||||||
|
items.unshift(
|
||||||
|
<Button className='cursor-pointer' key={1} onClick={() => onChange(1)} variant={"ghost"}>
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent className="flex flex-col items-center gap-4 md:flex-row">
|
||||||
|
<PaginationItem className="w-full md:w-auto">
|
||||||
|
<Button
|
||||||
|
onClick={() => onChange(Math.max(1, currentPage - 1))}
|
||||||
|
disabled={currentPage - 1 == 0 ? true : false}
|
||||||
|
className="w-full gap-2 md:w-auto"
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
|
<HiChevronLeft />
|
||||||
|
Sebelumnya
|
||||||
|
</Button>
|
||||||
|
</PaginationItem>
|
||||||
|
<div className="flex flex-wrap justify-center gap-2">
|
||||||
|
{getPaginationItems().map((item) => (
|
||||||
|
<PaginationItem key={item.key}>
|
||||||
|
{item}
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<PaginationItem className="w-full md:w-auto">
|
||||||
|
<Button
|
||||||
|
onClick={() => onChange(Math.min(totalPages, currentPage + 1))}
|
||||||
|
disabled={currentPage == totalPages ? true : false}
|
||||||
|
className="w-full gap-2 md:w-auto"
|
||||||
|
variant={"ghost"}
|
||||||
|
>
|
||||||
|
Selanjutnya
|
||||||
|
<HiChevronRight />
|
||||||
|
</Button>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PageTemplate component for displaying a paginated table with search and filter functionality.
|
* PageTemplate component for displaying a paginated table with search and filter functionality.
|
||||||
|
|
||||||
|
|
@ -113,14 +234,14 @@ export default function PageTemplate<
|
||||||
q: "",
|
q: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// const [deboucedSearchQuery] = useDebouncedValue(filterOptions.q, 500);
|
const [debouncedSearchQuery] = 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,
|
||||||
filterOptions.q
|
debouncedSearchQuery
|
||||||
)
|
)
|
||||||
: props.queryOptions),
|
: props.queryOptions),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|
@ -131,7 +252,11 @@ export default function PageTemplate<
|
||||||
columns: props.columnDefs,
|
columns: props.columnDefs,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
defaultColumn: {
|
defaultColumn: {
|
||||||
cell: (props) => <Text>{props.getValue() as ReactNode}</Text>,
|
cell: (props) => (
|
||||||
|
<span>
|
||||||
|
{props.getValue() as ReactNode}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -140,13 +265,13 @@ export default function PageTemplate<
|
||||||
*
|
*
|
||||||
* @param value - The new search query value.
|
* @param value - The new search query value.
|
||||||
*/
|
*/
|
||||||
const handleSearchQueryChange = useDebouncedCallback((value: string) => {
|
const handleSearchQueryChange = (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.
|
||||||
|
|
@ -155,75 +280,85 @@ export default function PageTemplate<
|
||||||
*/
|
*/
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setFilterOptions((prev) => ({
|
setFilterOptions((prev) => ({
|
||||||
page: page - 1,
|
page: page - 1, // Adjust for zero-based index
|
||||||
limit: prev.limit,
|
limit: prev.limit,
|
||||||
q: prev.q,
|
q: prev.q,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<div className="flex flex-col space-y-4">
|
||||||
<Title order={1}>{props.title}</Title>
|
<p className="text-2xl font-bold">{props.title}</p>
|
||||||
<Card>
|
<Card className="p-4 border-hidden">
|
||||||
{/* 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 */}
|
{/* Search and Create Button */}
|
||||||
<div className="flex pb-4">
|
<div className="flex flex-col md:flex-row lg:flex-row pb-4 justify-between gap-4">
|
||||||
<TextInput
|
<div className="relative w-full">
|
||||||
leftSection={<TbSearch />}
|
<TbSearch className="absolute top-1/2 left-3 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
id="search"
|
||||||
|
name="search"
|
||||||
|
className="w-full max-w-xs pl-10"
|
||||||
value={filterOptions.q}
|
value={filterOptions.q}
|
||||||
onChange={(e) =>
|
onChange={(e) => handleSearchQueryChange(e.target.value)}
|
||||||
handleSearchQueryChange(e.target.value)
|
placeholder="Pencarian..."
|
||||||
}
|
|
||||||
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-wrap flex items-center gap-4">
|
<div className="pt-4 flex flex-col md:flex-row lg:flex-row items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-row lg:flex-col items-center w-fit gap-2">
|
||||||
|
<span className="block text-sm font-medium text-muted-foreground whitespace-nowrap">Per Halaman</span>
|
||||||
<Select
|
<Select
|
||||||
label="Per Page"
|
onValueChange={(value) =>
|
||||||
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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
checkIconPosition="right"
|
defaultValue="10"
|
||||||
className="w-20"
|
>
|
||||||
/>
|
<SelectTrigger className="w-fit p-4 gap-4">
|
||||||
<Pagination
|
<SelectValue placeholder="Per Page" />
|
||||||
value={filterOptions.page + 1}
|
</SelectTrigger>
|
||||||
total={query.data._metadata.totalPages}
|
<SelectContent>
|
||||||
|
<SelectItem value="5">5</SelectItem>
|
||||||
|
<SelectItem value="10">10</SelectItem>
|
||||||
|
<SelectItem value="50">50</SelectItem>
|
||||||
|
<SelectItem value="100">100</SelectItem>
|
||||||
|
<SelectItem value="500">500</SelectItem>
|
||||||
|
<SelectItem value="1000">1000</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<CustomPagination
|
||||||
|
currentPage={filterOptions.page + 1}
|
||||||
|
totalPages={query.data._metadata.totalPages}
|
||||||
onChange={handlePageChange}
|
onChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
<Text c="dimmed" size="sm">
|
<div className="flex items-center gap-4">
|
||||||
Showing {query.data.data.length} of{" "}
|
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{query.data._metadata.totalItems}
|
Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems}
|
||||||
</Text>
|
</span>
|
||||||
|
</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>
|
||||||
</Stack>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@ 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: NonNullable<AuthContextType["user"]>,
|
userData: { id: string; name: string; permissions: string[]; role: string },
|
||||||
accessToken?: NonNullable<AuthContextType["accessToken"]>
|
accessToken?: string
|
||||||
) => void;
|
) => void;
|
||||||
clearAuthData: () => void;
|
clearAuthData: () => void;
|
||||||
checkPermission: (permission: string) => boolean;
|
checkPermission: (permission: string) => boolean;
|
||||||
|
|
@ -25,6 +26,7 @@ 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")
|
||||||
);
|
);
|
||||||
|
|
@ -36,6 +38,7 @@ 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);
|
||||||
|
|
@ -46,6 +49,7 @@ 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");
|
||||||
};
|
};
|
||||||
|
|
@ -60,7 +64,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user: userId
|
user: userId
|
||||||
? { id: userId, name: userName!, permissions: permissions! }
|
? { id: userId, name: userName!, permissions: permissions!, role: role! }
|
||||||
: null,
|
: null,
|
||||||
accessToken,
|
accessToken,
|
||||||
saveAuthData,
|
saveAuthData,
|
||||||
|
|
|
||||||
|
|
@ -67,3 +67,13 @@
|
||||||
@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/manrope.css";
|
import "./styles/fonts/inter.css";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shadcn/components/ui/alert-dialog";
|
||||||
|
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import { TextInput } from "@mantine/core";
|
||||||
|
import { Label } from "@/shadcn/components/ui/label";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
|
import { createAspect, updateAspect, getAspectByIdQueryOptions } from "../queries/aspectQueries";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import FormResponseError from "@/errors/FormResponseError";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
|
||||||
|
// Initialize route API
|
||||||
|
const routeApi = getRouteApi("/_dashboardLayout/aspect/");
|
||||||
|
|
||||||
|
export default function AspectFormModal() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = routeApi.useNavigate();
|
||||||
|
const searchParams = routeApi.useSearch();
|
||||||
|
const dataId = searchParams.detail || searchParams.edit;
|
||||||
|
const isDialogOpen = Boolean(dataId || searchParams.create);
|
||||||
|
const formType = searchParams.detail ? "detail" : searchParams.edit ? "ubah" : "tambah";
|
||||||
|
|
||||||
|
// Fetch aspect data if ubahing or viewing details
|
||||||
|
const aspectQuery = useQuery(getAspectByIdQueryOptions(dataId));
|
||||||
|
|
||||||
|
const modalTitle = `${formType.charAt(0).toUpperCase() + formType.slice(1)} Aspek`;
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
id: "",
|
||||||
|
name: "",
|
||||||
|
subAspects: [{ id: "", name: "", questionCount: 0 }] as { id: string; name: string; questionCount: number }[],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = aspectQuery.data;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
form.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
subAspects: data.subAspects?.map(subAspect => ({
|
||||||
|
id: subAspect.id || "",
|
||||||
|
name: subAspect.name,
|
||||||
|
questionCount: subAspect.questionCount || 0,
|
||||||
|
})) || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
form.setErrors({});
|
||||||
|
}, [aspectQuery.data]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationKey: ["aspectMutation"],
|
||||||
|
mutationFn: async (
|
||||||
|
options:
|
||||||
|
| { action: "ubah"; data: Parameters<typeof updateAspect>[0] }
|
||||||
|
| { action: "tambah"; data: Parameters<typeof createAspect>[0] }
|
||||||
|
) => {
|
||||||
|
return options.action === "ubah"
|
||||||
|
? await updateAspect(options.data)
|
||||||
|
: await createAspect(options.data);
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
if (error instanceof FormResponseError) {
|
||||||
|
form.setErrors(error.formErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
notifications.show({
|
||||||
|
message: error.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
type CreateAspectPayload = {
|
||||||
|
name: string;
|
||||||
|
subAspects?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditAspectPayload = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subAspects?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const values = form.values;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start submit process
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Name field validation
|
||||||
|
if (values.name.trim() === "") {
|
||||||
|
form.setErrors({ name: "Nama aspek harus diisi" });
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: CreateAspectPayload | EditAspectPayload;
|
||||||
|
|
||||||
|
if (formType === "tambah") {
|
||||||
|
payload = {
|
||||||
|
name: values.name,
|
||||||
|
subAspects: values.subAspects.length > 0
|
||||||
|
? JSON.stringify(
|
||||||
|
values.subAspects
|
||||||
|
.filter(subAspect => subAspect.name.trim() !== "")
|
||||||
|
.map(subAspect => subAspect.name)
|
||||||
|
)
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
await createAspect(payload);
|
||||||
|
} else if (formType === "ubah") {
|
||||||
|
payload = {
|
||||||
|
id: values.id,
|
||||||
|
name: values.name,
|
||||||
|
subAspects: values.subAspects.length > 0
|
||||||
|
? JSON.stringify(
|
||||||
|
values.subAspects
|
||||||
|
.filter(subAspect => subAspect.name.trim() !== "")
|
||||||
|
.map(subAspect => ({
|
||||||
|
id: subAspect.id || "",
|
||||||
|
name: subAspect.name,
|
||||||
|
questionCount: subAspect.questionCount || 0,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
: "",
|
||||||
|
};
|
||||||
|
await updateAspect(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["management-aspect"] });
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
message: `Aspek ${formType === "tambah" ? "berhasil dibuat" : "berhasil diubah"}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate({ search: {} });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error during submit:", error);
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === "Aspect name already exists") {
|
||||||
|
notifications.show({
|
||||||
|
message: "Nama aspek sudah ada. Silakan gunakan nama lain.",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
notifications.show({
|
||||||
|
message: "Nama Sub Aspek sudah ada. Silakan gunakan nama lain.",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={isDialogOpen} onOpenChange={isOpen => !isOpen && navigate({ search: {} })}>
|
||||||
|
<AlertDialogContent className="max-h-[80vh] overflow-hidden">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{modalTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{formType === "detail" ? "Detail dari aspek." : "Silakan isi data aspek di bawah ini."}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<ScrollArea className="h-[300px] p-4">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Label htmlFor="name" className="block text-left mb-1">Nama Aspek</Label>
|
||||||
|
<TextInput
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
disabled={formType === "detail" || isSubmitting}
|
||||||
|
error={form.errors.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.values.subAspects.map((subAspect, index) => (
|
||||||
|
<div className="flex justify-between items-center mb-4" key={subAspect.id}>
|
||||||
|
<div className="flex-grow">
|
||||||
|
<Label htmlFor={`subAspect-${index}`} className="block text-left mb-1">
|
||||||
|
Sub Aspek {index + 1}
|
||||||
|
</Label>
|
||||||
|
<TextInput
|
||||||
|
id={`subAspect-${index}`}
|
||||||
|
value={subAspect.name}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (formType !== "detail" && !mutation.isPending) {
|
||||||
|
const newSubAspects = [...form.values.subAspects];
|
||||||
|
newSubAspects[index] = { ...newSubAspects[index], name: event.target.value };
|
||||||
|
form.setValues({ subAspects: newSubAspects });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={formType === "detail" || isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formType === "detail" && (
|
||||||
|
<div className="ml-4 flex-shrink-0">
|
||||||
|
<Label className="block text-left mb-1">Jumlah Soal</Label>
|
||||||
|
<TextInput
|
||||||
|
value={subAspect.questionCount}
|
||||||
|
readOnly
|
||||||
|
className="w-full"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{formType !== "detail" && (
|
||||||
|
<Button
|
||||||
|
className="ml-4 mt-4"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newSubAspects = form.values.subAspects.filter((_, i) => i !== index);
|
||||||
|
form.setValues({ subAspects: newSubAspects });
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Hapus
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{formType !== "detail" && (
|
||||||
|
<Button className="mb-4"
|
||||||
|
variant="outline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newSubAspects = [
|
||||||
|
...form.values.subAspects,
|
||||||
|
{ id: createId(), name: "", questionCount: 0 }
|
||||||
|
];
|
||||||
|
form.setValues({ subAspects: newSubAspects });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tambah Sub Aspek
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isSubmitting}>Tutup</AlertDialogCancel>
|
||||||
|
{formType !== "detail" && (
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Menyimpan..." : modalTitle}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</form>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { InferRequestType } from "hono";
|
||||||
|
|
||||||
|
export const aspectQueryOptions = (page: number, limit: number, q?: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["management-aspect", { page, limit, q }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetchRPC(
|
||||||
|
client["management-aspect"].$get({
|
||||||
|
query: {
|
||||||
|
limit: String(limit),
|
||||||
|
page: String(page),
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAspectByIdQueryOptions = (aspectId: string | undefined) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["management-aspect", aspectId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client["management-aspect"][":id"].$get({
|
||||||
|
param: {
|
||||||
|
id: aspectId!,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(aspectId),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createAspect = async (
|
||||||
|
json: { name: string; subAspects?: string }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
return await fetchRPC(
|
||||||
|
client["management-aspect"].$post({
|
||||||
|
json,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating aspect:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateAspect = async (
|
||||||
|
form: { id: string; name: string; subAspects?: string }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name,
|
||||||
|
subAspects: form.subAspects
|
||||||
|
? JSON.parse(form.subAspects)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return await fetchRPC(
|
||||||
|
client["management-aspect"][":id"].$patch({
|
||||||
|
param: {
|
||||||
|
id: form.id,
|
||||||
|
},
|
||||||
|
json: payload,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating aspect:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAspect = async (id: string) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
(client["management-aspect"] as { [key: string]: any })[id].$delete()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { TbUpload } from 'react-icons/tb';
|
||||||
|
import { Text, Stack, Group, Flex } from '@mantine/core';
|
||||||
|
import FileSizeValidationModal from "@/modules/assessmentManagement/modals/FileSizeValidationModal";
|
||||||
|
|
||||||
|
interface Question {
|
||||||
|
questionId: string;
|
||||||
|
needFile: boolean;
|
||||||
|
// Add any other properties needed for question
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
question: Question;
|
||||||
|
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>, question: Question) => void;
|
||||||
|
handleRemoveFile: (question: Question) => void;
|
||||||
|
uploadedFiles: Record<string, File | null>;
|
||||||
|
dragActive: boolean;
|
||||||
|
handleDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
handleDragLeave: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||||
|
handleDrop: (event: React.DragEvent<HTMLDivElement>, question: Question) => void;
|
||||||
|
modalOpenFileSize: boolean;
|
||||||
|
setModalOpenFileSize: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
exceededFileName: string;
|
||||||
|
handleClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileUpload: React.FC<FileUploadProps> = ({
|
||||||
|
question,
|
||||||
|
handleFileChange,
|
||||||
|
handleRemoveFile,
|
||||||
|
uploadedFiles,
|
||||||
|
dragActive,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDrop,
|
||||||
|
modalOpenFileSize,
|
||||||
|
setModalOpenFileSize,
|
||||||
|
exceededFileName,
|
||||||
|
handleClick
|
||||||
|
}) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-11 sm:mx-8 md:mx-11">
|
||||||
|
{question.needFile && (
|
||||||
|
<div
|
||||||
|
className={`pt-5 pb-5 pr-5 pl-5 border-2 rounded-lg border-dashed ${dragActive ? "bg-gray-100" : "bg-transparent"} shadow-lg`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(event) => handleDrop(event, question)}
|
||||||
|
onClick={() => {
|
||||||
|
handleClick();
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Flex align="center" justify="space-between" gap="sm">
|
||||||
|
{/* Upload Icon */}
|
||||||
|
<TbUpload size={24} className="text-gray-500 md:w-6 md:h-6" />
|
||||||
|
<div className="flex-grow text-right">
|
||||||
|
<Text className="font-bold text-xs sm:text-sm md:text-base">
|
||||||
|
Klik untuk unggah atau geser file disini
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs sm:text-sm text-gray-400">PNG, JPG, PDF</Text>
|
||||||
|
<Text className="text-xs sm:text-sm text-gray-400">(Max.File size : 64 MB)</Text>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={(event) => handleFileChange(event, question)}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
accept="image/png, image/jpeg, application/pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-2">
|
||||||
|
{uploadedFiles[question.questionId] && (
|
||||||
|
<Stack gap="sm" mt="sm">
|
||||||
|
<Text className="font-bold">File yang diunggah:</Text>
|
||||||
|
<Group align="center">
|
||||||
|
<Text>{uploadedFiles[question.questionId]?.name}</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File Size Validation Modal */}
|
||||||
|
<FileSizeValidationModal
|
||||||
|
opened={modalOpenFileSize}
|
||||||
|
onClose={() => setModalOpenFileSize(false)}
|
||||||
|
fileName={exceededFileName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileUpload;
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shadcn/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface FinishAssessmentModalProps {
|
||||||
|
assessmentId: string;
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (assessmentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FinishAssessmentModal({
|
||||||
|
assessmentId,
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: FinishAssessmentModalProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={opened} onOpenChange={onClose}>
|
||||||
|
<AlertDialogContent className="max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Konfirmasi Selesai Asesmen</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Apakah Anda yakin ingin mengakhiri assessment ini? Pastikan semua jawaban sudah lengkap sebelum melanjutkan. Jika Anda sudah siap, klik 'Ya' untuk menyelesaikan.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="flex justify-end gap-2">
|
||||||
|
<AlertDialogCancel asChild>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Tidak
|
||||||
|
</Button>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild>
|
||||||
|
<Button onClick={() => onConfirm(assessmentId)}>Ya</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shadcn/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface FileSizeValidationModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileSizeValidationModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
fileName,
|
||||||
|
}: FileSizeValidationModalProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={opened} onOpenChange={onClose}>
|
||||||
|
<AlertDialogContent className="max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Peringatan Ukuran File</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Ukuran file {fileName} melebihi batas maksimum 64 MB! Silakan pilih file yang lebih kecil.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="flex justify-end gap-2">
|
||||||
|
<AlertDialogCancel asChild>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shadcn/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
interface ValidationModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
unansweredQuestions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ValidationModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
unansweredQuestions,
|
||||||
|
}: ValidationModalProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={opened} onOpenChange={onClose}>
|
||||||
|
<AlertDialogContent className="max-w-md">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Peringatan</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Anda mempunyai {unansweredQuestions} pertanyaan yang belum terjawab! Pastikan semua jawaban sudah lengkap sebelum melanjutkan.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="flex justify-end gap-2">
|
||||||
|
<AlertDialogCancel asChild>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import { queryOptions, UseMutationOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type SubmitOptionResponse = {
|
||||||
|
message: string;
|
||||||
|
answer: {
|
||||||
|
id: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
validationInformation: string;
|
||||||
|
isFlagged: boolean | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAspects = async () => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments.aspect.$get({
|
||||||
|
query: {}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query untuk mendapatkan semua pertanyaan berdasarkan halaman dan limit
|
||||||
|
export const getQuestionsAllQueryOptions = (page: number, limit: number, q?: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["assessment", { page, limit, q }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments.getAllQuestions.$get({
|
||||||
|
query: {
|
||||||
|
limit: String(limit),
|
||||||
|
page: String(page),
|
||||||
|
q: q || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Query untuk mendapatkan jawaban berdasarkan assessment ID
|
||||||
|
export const getAnswersQueryOptions = (
|
||||||
|
assessmentId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
q: string = "",
|
||||||
|
enabled: boolean = true
|
||||||
|
) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ["assessment", { assessmentId, page, limit, q }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments.getAnswers.$get({
|
||||||
|
query: {
|
||||||
|
assessmentId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).then((res) => {
|
||||||
|
return res;
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query untuk toggle flag jawaban berdasarkan questionId
|
||||||
|
export const toggleFlagAnswer = async (form: {
|
||||||
|
assessmentId: string;
|
||||||
|
questionId: string;
|
||||||
|
isFlagged: boolean;
|
||||||
|
}): Promise<SubmitOptionResponse> => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments.toggleFlag.$patch({
|
||||||
|
json: form,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query untuk mendapatkan rata-rata skor berdasarkan aspectId dan assessmentId
|
||||||
|
export const getAverageScoreQueryOptions = (assessmentId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["averageScore", { assessmentId }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments["average-score"].aspects.assessments[":assessmentId"].$get({
|
||||||
|
param: {
|
||||||
|
assessmentId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const submitOption = async (form: {
|
||||||
|
optionId: string;
|
||||||
|
assessmentId: string;
|
||||||
|
questionId: string;
|
||||||
|
isFlagged?: boolean;
|
||||||
|
filename?: string;
|
||||||
|
}): Promise<SubmitOptionResponse> => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments.submitOption.$post({
|
||||||
|
json: form,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitOptionMutationOptions: UseMutationOptions<
|
||||||
|
SubmitOptionResponse,
|
||||||
|
Error,
|
||||||
|
Parameters<typeof submitOption>[0]
|
||||||
|
> = {
|
||||||
|
mutationFn: submitOption,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitValidationQuery = async (
|
||||||
|
form: {
|
||||||
|
assessmentId: string;
|
||||||
|
questionId: string;
|
||||||
|
validationInformation: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments.submitValidation.$post({
|
||||||
|
json: {
|
||||||
|
...form,
|
||||||
|
assessmentId: String(form.assessmentId),
|
||||||
|
questionId: String(form.questionId),
|
||||||
|
validationInformation: form.validationInformation,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitValidationMutationOptions = () => ({
|
||||||
|
mutationFn: submitValidationQuery,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to upload a file
|
||||||
|
const uploadFile = async (formData: FormData, assessmentId: string, questionId: string) => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_BACKEND_BASE_URL}/assessments/uploadFile?assessmentId=${assessmentId}&questionId=${questionId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.message || 'Error uploading file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
return responseData; // Return the JSON response with file URL
|
||||||
|
};
|
||||||
|
|
||||||
|
// Options for the mutation
|
||||||
|
export const uploadFileMutationOptions = (): UseMutationOptions<{ imageUrl: string }, Error, FormData> => ({
|
||||||
|
mutationFn: (formData: FormData) => {
|
||||||
|
const assessmentId = formData.get('assessmentId') as string;
|
||||||
|
const questionId = formData.get('questionId') as string;
|
||||||
|
return uploadFile(formData, assessmentId, questionId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const submitAssessment = async (assessmentId: string): Promise<{ message: string }> => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments.submitAssessment[":id"].$patch({
|
||||||
|
param: { id: assessmentId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitAssessmentMutationOptions = (assessmentId: string) => ({
|
||||||
|
mutationFn: () => submitAssessment(assessmentId),
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Modal, Text, Flex } from "@mantine/core";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
|
||||||
|
interface StartAssessmentModalProps {
|
||||||
|
assessmentId: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (assessmentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StartAssessmentModal({
|
||||||
|
assessmentId,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
}: StartAssessmentModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal opened={isOpen} onClose={onClose} title="Konfirmasi Mulai Asesmen">
|
||||||
|
<Text>Apakah Anda yakin ingin memulai asesmen ini?</Text>
|
||||||
|
<Flex gap="sm" justify="flex-end" mt="md">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Batal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onConfirm(assessmentId); // Use assessmentId when confirming
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mulai Asesmen
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
import {
|
||||||
|
Flex,
|
||||||
|
Modal,
|
||||||
|
Text
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import FormResponseError from "@/errors/FormResponseError";
|
||||||
|
import createInputComponents from "@/utils/createInputComponents";
|
||||||
|
import { assessmentRequestQueryOptions, createAssessmentRequest } from "../queries/assessmentRequestQueries";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change this
|
||||||
|
*/
|
||||||
|
const routeApi = getRouteApi("/_dashboardLayout/assessmentRequest/");
|
||||||
|
|
||||||
|
export default function UserFormModal() {
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const navigate = routeApi.useNavigate();
|
||||||
|
|
||||||
|
const searchParams = routeApi.useSearch();
|
||||||
|
|
||||||
|
const isModalOpen = Boolean(searchParams.create);
|
||||||
|
|
||||||
|
const formType = "create";
|
||||||
|
|
||||||
|
const userQuery = useQuery(assessmentRequestQueryOptions(0, 10));
|
||||||
|
|
||||||
|
const modalTitle = <b>Konfirmasi</b>
|
||||||
|
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
respondentsId: "",
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// used to get the respondentId of the currently logged in user
|
||||||
|
// and then set respondentsId in the form to create an assessment request
|
||||||
|
useEffect(() => {
|
||||||
|
const data = userQuery.data;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
form.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
respondentsId: data.data[0].respondentId ?? "",
|
||||||
|
name: data.data[0].name ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
form.setErrors({});
|
||||||
|
}, [userQuery.data]);
|
||||||
|
|
||||||
|
// Mutation function to create a new assessment request and refresh query after success
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationKey: ["usersMutation"],
|
||||||
|
mutationFn: async (options: { action: "create"; data: { respondentsId: string } }) => {
|
||||||
|
console.log("called");
|
||||||
|
if (options.action === "create") {
|
||||||
|
return await createAssessmentRequest(options.data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// auto refresh after mutation
|
||||||
|
onSuccess: () => {
|
||||||
|
// force a query-reaction to retrieve the latest data
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assessmentRequest"] });
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
message: "Permohonan Asesmen berhasil dibuat!",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
|
||||||
|
// close modal
|
||||||
|
navigate({ search: {} });
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
console.log(error);
|
||||||
|
|
||||||
|
if (error instanceof FormResponseError) {
|
||||||
|
form.setErrors(error.formErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
notifications.show({
|
||||||
|
message: error.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle submit form, mutate data to server and close modal after success
|
||||||
|
const handleSubmit = async (values: typeof form.values) => {
|
||||||
|
|
||||||
|
if (formType === "create") {
|
||||||
|
try {
|
||||||
|
await mutation.mutateAsync({
|
||||||
|
action: "create",
|
||||||
|
data: {
|
||||||
|
respondentsId: values.respondentsId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
navigate({ search: {} });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={isModalOpen}
|
||||||
|
onClose={() => navigate({ search: {} })}
|
||||||
|
title= {modalTitle}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
|
|
||||||
|
<Text>Apakah anda yakin ingin membuat Permohonan Asesmen Baru?</Text>
|
||||||
|
|
||||||
|
{/* Fields to display data will be sent but only respondentId */}
|
||||||
|
{createInputComponents({
|
||||||
|
disableAll: mutation.isPending,
|
||||||
|
readonlyAll: formType === "create",
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Respondent ID",
|
||||||
|
...form.getInputProps("respondentsId"),
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Name",
|
||||||
|
...form.getInputProps("name"),
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<Flex justify="flex-end" align="center" gap="sm" mt="lg">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate({ search: {} })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Tidak
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isLoading={mutation.isPending}
|
||||||
|
>
|
||||||
|
Buat Permohonan
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const assessmentRequestQueryOptions = (page: number, limit: number, q?: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["assessmentRequest", { page, limit, q }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentRequest.$get({
|
||||||
|
query: {
|
||||||
|
limit: String(limit),
|
||||||
|
page: String(page),
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createAssessmentRequest = async ({ respondentsId }: { respondentsId: string }) => {
|
||||||
|
const response = await client.assessmentRequest.$post({
|
||||||
|
json: { respondentId: respondentsId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Asesmen sedang berlangsung, Selesaikan terlebih dahulu.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query to update assessment status when user start assessment
|
||||||
|
export const updateAssessmentRequest = async ({
|
||||||
|
assessmentId,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
assessmentId: string;
|
||||||
|
status: "menunggu konfirmasi" | "diterima" | "ditolak" | "dalam pengerjaan" | "belum diverifikasi" | "selesai";
|
||||||
|
}) => {
|
||||||
|
const response = await client.assessmentRequest[":assessmentId"].$patch({
|
||||||
|
json: { status },
|
||||||
|
param: { assessmentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Gagal memperbarui status permohonan asesmen");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,236 @@
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/shadcn/components/ui/dialog";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/shadcn/components/ui/scroll-area";
|
||||||
|
// import { Button, Flex, Modal, ScrollArea } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { fetchAssessmentRequestManagementById, updateAssessmentRequestManagementStatus } from "../queries/assessmentRequestManagementQueries";
|
||||||
|
import createInputComponents from "@/utils/createInputComponents"; // Assuming you have this utility
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
// Define the API route for navigation
|
||||||
|
const routeApi = getRouteApi("/_dashboardLayout/assessmentRequestManagements/");
|
||||||
|
|
||||||
|
// Define allowed status values
|
||||||
|
type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "dalam pengerjaan" | "belum diverifikasi" | "selesai";
|
||||||
|
|
||||||
|
interface AssessmentRequestManagementFormModalProps {
|
||||||
|
assessmentId: string | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssessmentRequestManagementFormModal({
|
||||||
|
assessmentId,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
}: AssessmentRequestManagementFormModalProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = routeApi.useNavigate();
|
||||||
|
|
||||||
|
const AssessmentRequestManagementQuery = useQuery({
|
||||||
|
queryKey: ["assessmentRequestManagements", assessmentId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!assessmentId) return null;
|
||||||
|
return await fetchAssessmentRequestManagementById(assessmentId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
tanggal: "",
|
||||||
|
nama: "",
|
||||||
|
posisi: "",
|
||||||
|
pengalamanKerja: "",
|
||||||
|
email: "",
|
||||||
|
namaPerusahaan: "",
|
||||||
|
alamat: "",
|
||||||
|
nomorTelepon: "",
|
||||||
|
username: "",
|
||||||
|
status: "menunggu konfirmasi" as AssessmentStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate the form once data is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (AssessmentRequestManagementQuery.data) {
|
||||||
|
form.setValues({
|
||||||
|
tanggal: formatDate(AssessmentRequestManagementQuery.data.tanggal || "Data Kosong"),
|
||||||
|
nama: AssessmentRequestManagementQuery.data.nama || "Data Kosong",
|
||||||
|
posisi: AssessmentRequestManagementQuery.data.posisi || "Data Kosong",
|
||||||
|
pengalamanKerja: AssessmentRequestManagementQuery.data.pengalamanKerja || "Data Kosong",
|
||||||
|
email: AssessmentRequestManagementQuery.data.email || "Data Kosong",
|
||||||
|
namaPerusahaan: AssessmentRequestManagementQuery.data.namaPerusahaan || "Data Kosong",
|
||||||
|
alamat: AssessmentRequestManagementQuery.data.alamat || "Data Kosong",
|
||||||
|
nomorTelepon: AssessmentRequestManagementQuery.data.nomorTelepon || "Data Kosong",
|
||||||
|
username: AssessmentRequestManagementQuery.data.username || "Data Kosong",
|
||||||
|
status: AssessmentRequestManagementQuery.data.status || "menunggu konfirmasi",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [AssessmentRequestManagementQuery.data, form]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationKey: ["updateAssessmentRequestManagementStatusMutation"],
|
||||||
|
mutationFn: async ({
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
status: AssessmentStatus;
|
||||||
|
}) => {
|
||||||
|
return await updateAssessmentRequestManagementStatus(id, status);
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
notifications.show({
|
||||||
|
message: error.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: "Status Permohonan Asesmen berhasil diperbarui.",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["assessmentRequestManagements", assessmentId],
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleStatusChange = (status: AssessmentStatus) => {
|
||||||
|
if (assessmentId) {
|
||||||
|
mutation.mutate({ id: assessmentId, status });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) return "Tanggal tidak tersedia";
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
if (isNaN(date.getTime())) return "Tanggal tidak valid";
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("id-ID", {
|
||||||
|
hour12: true,
|
||||||
|
minute: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { status } = form.values;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Detail Permohonan Asesmen</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogDescription>
|
||||||
|
<ScrollArea className="h-[400px] w-full rounded-md p-4">
|
||||||
|
{createInputComponents({
|
||||||
|
disableAll: mutation.isPending,
|
||||||
|
readonlyAll: true,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Tanggal",
|
||||||
|
...form.getInputProps("tanggal"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Nama",
|
||||||
|
...form.getInputProps("nama"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Posisi",
|
||||||
|
...form.getInputProps("posisi"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Pengalaman Kerja",
|
||||||
|
...form.getInputProps("pengalamanKerja"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Email",
|
||||||
|
...form.getInputProps("email"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Nama Perusahaan",
|
||||||
|
...form.getInputProps("namaPerusahaan"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Alamat",
|
||||||
|
...form.getInputProps("alamat"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Nomor Telepon",
|
||||||
|
...form.getInputProps("nomorTelepon"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Username",
|
||||||
|
...form.getInputProps("username"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Status",
|
||||||
|
...form.getInputProps("status"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
|
||||||
|
</ScrollArea>
|
||||||
|
<div className="flex justify-end gap-4 mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="outline"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="min-w-20"
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
{status !== "selesai" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleStatusChange("ditolak")}
|
||||||
|
variant="destructive"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="min-w-20"
|
||||||
|
>
|
||||||
|
Tolak
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleStatusChange("diterima")}
|
||||||
|
variant="default"
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
className="min-w-20"
|
||||||
|
>
|
||||||
|
Terima
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Define allowed status values
|
||||||
|
type AssessmentStatus = "menunggu konfirmasi" | "diterima" | "ditolak" | "dalam pengerjaan" | "belum diverifikasi" | "selesai";
|
||||||
|
|
||||||
|
export const assessmentRequestManagementQueryOptions = (page: number, limit: number, q?: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["assessmentRequestManagements", { page, limit, q }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentRequestManagement.$get({
|
||||||
|
query: {
|
||||||
|
limit: String(limit),
|
||||||
|
page: String(page),
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateAssessmentRequestManagementStatus(id: string, status: AssessmentStatus) {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessmentRequestManagement[":id"].$patch({
|
||||||
|
param: { id },
|
||||||
|
json: { status },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAssessmentRequestManagementById(id: string) {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessmentRequestManagement[":id"].$get({
|
||||||
|
param: { id },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const getAllSubAspectsAverageScore = (assessmentId: string | undefined) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["allSubAspectsAverage", assessmentId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments["average-score"]["sub-aspects"]["assessments"][":assessmentId"].$get({
|
||||||
|
param: {
|
||||||
|
assessmentId: assessmentId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(assessmentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAllAspectsAverageScore = (assessmentId: string | undefined) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["allAspectsAverage", assessmentId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessments["average-score"]["aspects"]["assessments"][":assessmentId"].$get({
|
||||||
|
param: {
|
||||||
|
assessmentId: assessmentId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(assessmentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAllVerifiedSubAspectsAverageScore = (assessmentId: string | undefined) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["allVerifiedSubAspectsAverage", assessmentId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentResult["average-score"]["sub-aspects"]["assessments"][":assessmentId"].$get({
|
||||||
|
param: {
|
||||||
|
assessmentId: assessmentId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(assessmentId),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAllVerifiedAspectsAverageScore = (assessmentId: string | undefined) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["allVerifiedAspectsAverage", assessmentId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentResult["average-score"]["aspects"]["assessments"][":assessmentId"].$get({
|
||||||
|
param: {
|
||||||
|
assessmentId: assessmentId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(assessmentId),
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,240 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Modal,
|
||||||
|
ScrollArea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import createInputComponents from "@/utils/createInputComponents";
|
||||||
|
import { getAssessmentResultByIdQueryOptions } from "../queries/assessmentResultsManagaementQueries";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change this
|
||||||
|
*/
|
||||||
|
const routeApi = getRouteApi("/_dashboardLayout/assessmentResultsManagement/");
|
||||||
|
|
||||||
|
export default function UserFormModal() {
|
||||||
|
/**
|
||||||
|
* DON'T CHANGE FOLLOWING:
|
||||||
|
*/
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const navigate = routeApi.useNavigate();
|
||||||
|
|
||||||
|
const searchParams = routeApi.useSearch();
|
||||||
|
|
||||||
|
const detailId = (searchParams as { detail?: string }).detail;
|
||||||
|
|
||||||
|
const isModalOpen = Boolean(detailId);
|
||||||
|
|
||||||
|
const formType = detailId ? "detail" : "tambah";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CHANGE FOLLOWING:
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assessmentResultQuery = useQuery(getAssessmentResultByIdQueryOptions(detailId));
|
||||||
|
|
||||||
|
const modalTitle =
|
||||||
|
formType.charAt(0).toUpperCase() + formType.slice(1) + " Hasil Assessment";
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
respondentName: "",
|
||||||
|
position: "",
|
||||||
|
workExperience: "",
|
||||||
|
email: "",
|
||||||
|
companyName: "",
|
||||||
|
address: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
username: "",
|
||||||
|
assessmentDate: "",
|
||||||
|
statusAssessment: "",
|
||||||
|
assessmentsResult: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = assessmentResultQuery.data;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
form.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to format the date
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
// Format only the date, hour, and minute
|
||||||
|
return date.toLocaleString('id-ID', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
respondentName: data.respondentName ?? "",
|
||||||
|
position: data.position ?? "",
|
||||||
|
workExperience: data.workExperience ?? "",
|
||||||
|
email: data.email ?? "",
|
||||||
|
companyName: data.companyName ?? "",
|
||||||
|
address: data.address ?? "",
|
||||||
|
phoneNumber: data.phoneNumber ?? "",
|
||||||
|
username: data.username ?? "",
|
||||||
|
assessmentDate: data.assessmentDate ? formatDate(data.assessmentDate) : "",
|
||||||
|
statusAssessment: data.statusAssessment ?? "",
|
||||||
|
assessmentsResult: String(data.assessmentsResult ?? ""),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.setErrors({});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [assessmentResultQuery.data]);
|
||||||
|
|
||||||
|
// const mutation = useMutation({
|
||||||
|
// mutationKey: ["assessmentResultMutation"],
|
||||||
|
// mutationFn: async (
|
||||||
|
// options:
|
||||||
|
// | { action: "ubah"; data: Parameters<typeof updateUser>[0] }
|
||||||
|
// | { action: "tambah"; data: Parameters<typeof createUser>[0] }
|
||||||
|
// ) => {
|
||||||
|
// console.log("called");
|
||||||
|
// return options.action === "ubah"
|
||||||
|
// ? await updateUser(options.data)
|
||||||
|
// : await createUser(options.data);
|
||||||
|
// },
|
||||||
|
// onError: (error: unknown) => {
|
||||||
|
// console.log(error);
|
||||||
|
|
||||||
|
// if (error instanceof FormResponseError) {
|
||||||
|
// form.setErrors(error.formErrors);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// if (error instanceof Error) {
|
||||||
|
// notifications.show({
|
||||||
|
// message: error.message,
|
||||||
|
// color: "red",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
const handleSubmit = async (values: typeof form.values) => {
|
||||||
|
if (formType === "detail") {
|
||||||
|
({
|
||||||
|
action: formType,
|
||||||
|
data: {
|
||||||
|
respondentName: values.respondentName,
|
||||||
|
position: values.position,
|
||||||
|
workExperience: values.workExperience,
|
||||||
|
email: values.email,
|
||||||
|
companyName: values.companyName,
|
||||||
|
address: values.address,
|
||||||
|
phoneNumber: values.phoneNumber,
|
||||||
|
username: values.username,
|
||||||
|
assessmentDate: values.assessmentDate,
|
||||||
|
statusAssessment: values.statusAssessment,
|
||||||
|
assessmentsResult: values.assessmentsResult,
|
||||||
|
isEnabled: "true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||||
|
navigate({ search: {} });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YOU MIGHT NOT NEED FOLLOWING:
|
||||||
|
*/
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={isModalOpen}
|
||||||
|
onClose={() => navigate({ search: {} })}
|
||||||
|
title={modalTitle} //Uppercase first letter
|
||||||
|
scrollAreaComponent={ScrollArea.Autosize}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
|
{createInputComponents({
|
||||||
|
readonlyAll: formType === "detail",
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Nama Respondent",
|
||||||
|
...form.getInputProps("respondentName"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Jabatan",
|
||||||
|
...form.getInputProps("position"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Pengalaman Kerja",
|
||||||
|
...form.getInputProps("workExperience"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Email",
|
||||||
|
...form.getInputProps("email"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Instansi/Perusahaan",
|
||||||
|
...form.getInputProps("companyName"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Alamat",
|
||||||
|
...form.getInputProps("address"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Nomor Telepon",
|
||||||
|
...form.getInputProps("phoneNumber"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Username",
|
||||||
|
...form.getInputProps("username"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Tanggal Assessment",
|
||||||
|
...form.getInputProps("assessmentDate"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Status Assessment",
|
||||||
|
...form.getInputProps("statusAssessment"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Hasil Assessment",
|
||||||
|
...form.getInputProps("assessmentsResult"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate({ search: {} })}
|
||||||
|
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import { queryOptions, useMutation } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const assessmentResultsQueryOptions = (page: number, limit: number, q?: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["assessmentResults", { page, limit, q }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentResult.$get({
|
||||||
|
query: {
|
||||||
|
limit: String(limit),
|
||||||
|
page: String(page),
|
||||||
|
q: q || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAssessmentResultByIdQueryOptions = (assessmentResultId: string | undefined) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["assessmentResults", assessmentResultId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentResult[":id"].$get({
|
||||||
|
param: {
|
||||||
|
id: assessmentResultId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(assessmentResultId),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getVerifiedAssessmentResultByIdQueryOptions = (assessmentResultId: string | undefined) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["verifiedAssessmentResult", assessmentResultId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentResult.verified[":id"].$get({
|
||||||
|
param: {
|
||||||
|
id: assessmentResultId!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(assessmentResultId),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postAnswerRevisionQueryOptions = (
|
||||||
|
assessmentId: string,
|
||||||
|
revisedBy: string,
|
||||||
|
) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["answerRevisions", assessmentId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentResult["answer-revisions"].$post({
|
||||||
|
json: {
|
||||||
|
assessmentId,
|
||||||
|
revisedBy,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(assessmentId && revisedBy),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const postAnswerRevisionMutation = () => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ assessmentId, revisedBy }: { assessmentId: string; revisedBy: string }) => {
|
||||||
|
return fetchRPC(
|
||||||
|
client.assessmentResult["answer-revisions"].$post({
|
||||||
|
json: {
|
||||||
|
assessmentId,
|
||||||
|
revisedBy,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
console.log("Revision posted successfully.");
|
||||||
|
// Optionally, you could trigger a refetch of relevant data here
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
console.error("Error posting revision:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query untuk mendapatkan jawaban berdasarkan assessment ID
|
||||||
|
export const getAnswersRevisionQueryOptions = (
|
||||||
|
assessmentId: string,
|
||||||
|
) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ["answerRevision", { assessmentId }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentResult.getAnswers[":id"].$get({
|
||||||
|
param: { id: assessmentId },
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateValidationQueryOptions = (assessmentId: string, questionId: string, newValidationInformation: string) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: ["updateValidation", { assessmentId, questionId }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.assessmentResult.updateValidation.$post({
|
||||||
|
json: {
|
||||||
|
assessmentId,
|
||||||
|
questionId,
|
||||||
|
newValidationInformation,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(assessmentId && questionId && newValidationInformation),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateValidationQuery = async (
|
||||||
|
form: {
|
||||||
|
assessmentId: string;
|
||||||
|
questionId: string;
|
||||||
|
newValidationInformation: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessmentResult.updateValidation.$post({
|
||||||
|
json: {
|
||||||
|
...form,
|
||||||
|
assessmentId: String(form.assessmentId),
|
||||||
|
questionId: String(form.questionId),
|
||||||
|
newValidationInformation: form.newValidationInformation,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateOptionQuery = async (
|
||||||
|
form: {
|
||||||
|
assessmentId: string;
|
||||||
|
questionId: string;
|
||||||
|
optionId: string;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessments.updateOption.$patch({
|
||||||
|
json: {
|
||||||
|
...form,
|
||||||
|
assessmentId: String(form.assessmentId),
|
||||||
|
questionId: String(form.questionId),
|
||||||
|
newOptionId: form.optionId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitAssessmentRevision = async (assessmentId: string): Promise<{ message: string }> => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.assessmentResult.submitAssessmentRevision[":id"].$patch({
|
||||||
|
param: { id: assessmentId },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitAssessmentRevisionMutationOptions = (assessmentId: string) => ({
|
||||||
|
mutationFn: () => submitAssessmentRevision(assessmentId),
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shadcn/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getRouteApi, useSearch } from "@tanstack/react-router";
|
||||||
|
import { deleteQuestion } from "../queries/questionQueries";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
|
||||||
|
const routeApi = getRouteApi("/_dashboardLayout/questions/");
|
||||||
|
|
||||||
|
export default function QuestionDeleteModal() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const searchParams = useSearch({ from: "/_dashboardLayout/questions/" }) as {
|
||||||
|
delete: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const questionId = searchParams.delete;
|
||||||
|
const navigate = routeApi.useNavigate();
|
||||||
|
|
||||||
|
const questionQuery = useQuery({
|
||||||
|
queryKey: ["questions", questionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!questionId) return null;
|
||||||
|
return await fetchRPC(
|
||||||
|
client.questions[":id"].$get({
|
||||||
|
param: {
|
||||||
|
id: questionId,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationKey: ["deleteQuestionMutation"],
|
||||||
|
mutationFn: async ({ id }: { id: string }) => {
|
||||||
|
return await deleteQuestion(id);
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
notifications.show({
|
||||||
|
message: error.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
message: "Question deleted successfully.",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
queryClient.removeQueries({ queryKey: ["question", questionId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["questions"] });
|
||||||
|
navigate({ search: {} });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isModalOpen = Boolean(searchParams.delete && questionQuery.data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={isModalOpen} onOpenChange={() => navigate({ search: {} })}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Konfirmasi Hapus</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Apakah Anda yakin ingin menghapus pertanyaan ini? Tindakan ini tidak dapat diubah.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => navigate({ search: {} })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Batal
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => mutation.mutate({ id: questionId })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Menghapus..." : "Hapus Pertanyaan"}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
ActionIcon,
|
||||||
|
ScrollArea,
|
||||||
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
|
Group,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
|
import { TbDeviceFloppy, TbPlus, TbTrash } from "react-icons/tb";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import FormResponseError from "@/errors/FormResponseError";
|
||||||
|
import createInputComponents from "@/utils/createInputComponents";
|
||||||
|
import {
|
||||||
|
createQuestion,
|
||||||
|
getQuestionByIdQueryOptions,
|
||||||
|
updateQuestion,
|
||||||
|
fetchAspects,
|
||||||
|
fetchSubAspects,
|
||||||
|
} from "../queries/questionQueries";
|
||||||
|
|
||||||
|
const routeApi = getRouteApi("/_dashboardLayout/questions/");
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
questionId: string;
|
||||||
|
text: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateQuestionPayload {
|
||||||
|
id?: string;
|
||||||
|
subAspectId: string;
|
||||||
|
question: string;
|
||||||
|
needFile: boolean;
|
||||||
|
options: Option[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateQuestionPayload {
|
||||||
|
id: string;
|
||||||
|
subAspectId: string;
|
||||||
|
question: string;
|
||||||
|
needFile: boolean;
|
||||||
|
options?: Option[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuestionFormModal() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = routeApi.useNavigate();
|
||||||
|
const searchParams = routeApi.useSearch();
|
||||||
|
const dataId = searchParams.detail || searchParams.edit;
|
||||||
|
const isModalOpen = Boolean(dataId || searchParams.create);
|
||||||
|
const detailId = searchParams.detail;
|
||||||
|
const editId = searchParams.edit;
|
||||||
|
const formType = detailId ? "detail" : editId ? "ubah" : "tambah";
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
id: "",
|
||||||
|
question: "",
|
||||||
|
needFile: false,
|
||||||
|
aspectId: "",
|
||||||
|
subAspectId: "",
|
||||||
|
options: [] as { id: string; text: string; score: number; questionId: string }[],
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
aspectId: (value) => (value ? null : "Nama Aspek harus dipilih."),
|
||||||
|
subAspectId: (value) => (value ? null : "Nama Sub Aspek harus dipilih."),
|
||||||
|
question: (value) => (value ? null : "Pertanyaan tidak boleh kosong."),
|
||||||
|
options: {
|
||||||
|
text: (value) => (value ? null : "Jawaban tidak boleh kosong."),
|
||||||
|
score: (value) => (value >= 0 ? null : "Skor harus diisi dengan angka."),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch aspects and sub-aspects
|
||||||
|
const aspectsQuery = useQuery({
|
||||||
|
queryKey: ["aspects"],
|
||||||
|
queryFn: fetchAspects,
|
||||||
|
});
|
||||||
|
|
||||||
|
const subAspectsQuery = useQuery({
|
||||||
|
queryKey: ["subAspects"],
|
||||||
|
queryFn: fetchSubAspects,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for form initialization and aspectId before filtering
|
||||||
|
const filteredSubAspects = form.values.aspectId
|
||||||
|
? subAspectsQuery.data?.filter(
|
||||||
|
(subAspect) => subAspect.aspectId === form.values.aspectId
|
||||||
|
) || []
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const questionQuery = useQuery(getQuestionByIdQueryOptions(dataId));
|
||||||
|
const modalTitle =
|
||||||
|
formType.charAt(0).toUpperCase() + formType.slice(1) + " Pertanyaan";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const data = questionQuery.data;
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
form.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.setValues({
|
||||||
|
id: data.id,
|
||||||
|
question: data.question ?? "",
|
||||||
|
needFile: data.needFile ?? false,
|
||||||
|
aspectId: data.aspectId ?? "",
|
||||||
|
subAspectId: data.subAspectId ?? "",
|
||||||
|
options: data.options.map((option) => ({
|
||||||
|
...option,
|
||||||
|
questionId: data.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
form.setErrors({});
|
||||||
|
}, [questionQuery.data]);
|
||||||
|
|
||||||
|
// Define possible actions, depending on the action, it can be one or the other
|
||||||
|
interface MutationOptions {
|
||||||
|
action: "ubah" | "tambah";
|
||||||
|
data: CreateQuestionPayload | UpdateQuestionPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MutationResponse {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutation = useMutation<MutationResponse, Error, MutationOptions>({
|
||||||
|
mutationKey: ["questionsMutation"],
|
||||||
|
mutationFn: async (options) => {
|
||||||
|
if (options.action === "ubah") {
|
||||||
|
return await updateQuestion(options.data as UpdateQuestionPayload);
|
||||||
|
} else {
|
||||||
|
return await createQuestion(options.data as CreateQuestionPayload);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (values: CreateQuestionPayload) => {
|
||||||
|
if (formType === "detail") return;
|
||||||
|
|
||||||
|
const payload: CreateQuestionPayload = {
|
||||||
|
id: values.id,
|
||||||
|
question: values.question,
|
||||||
|
needFile: values.needFile,
|
||||||
|
subAspectId: values.subAspectId,
|
||||||
|
options: values.options.map((option) => ({
|
||||||
|
questionId: values.id || "",
|
||||||
|
text: option.text,
|
||||||
|
score: option.score,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (formType === "tambah") {
|
||||||
|
await mutation.mutateAsync({ action: "tambah", data: payload });
|
||||||
|
notifications.show({
|
||||||
|
message: "Data pertanyaan berhasil dibuat!",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await mutation.mutateAsync({ action: "ubah", data: payload });
|
||||||
|
notifications.show({
|
||||||
|
message: "Data pertanyaan berhasil diperbarui!",
|
||||||
|
color: "green",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["questions"] });
|
||||||
|
navigate({ search: {} });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FormResponseError) {
|
||||||
|
form.setErrors(error.formErrors);
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
notifications.show({
|
||||||
|
message: error.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddOption = () => {
|
||||||
|
form.insertListItem("options", { id: "", text: "", score: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveOption = (index: number) => {
|
||||||
|
form.removeListItem("options", index);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={isModalOpen}
|
||||||
|
onClose={() => navigate({ search: {} })}
|
||||||
|
title={modalTitle}
|
||||||
|
scrollAreaComponent={ScrollArea.Autosize}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
|
{createInputComponents({
|
||||||
|
disableAll: mutation.isPending,
|
||||||
|
readonlyAll: formType === "detail",
|
||||||
|
inputs: [
|
||||||
|
formType === "detail"
|
||||||
|
? {
|
||||||
|
type: "text",
|
||||||
|
label: "Nama Aspek",
|
||||||
|
readOnly: true,
|
||||||
|
value: aspectsQuery.data?.find(aspect => aspect.id === form.values.aspectId)?.name || "",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "select",
|
||||||
|
label: "Nama Aspek",
|
||||||
|
placeholder: "Pilih Aspek",
|
||||||
|
data: aspectsQuery.data?.map((aspect) => ({
|
||||||
|
value: aspect.id,
|
||||||
|
label: aspect.name,
|
||||||
|
})) || [],
|
||||||
|
disabled: mutation.isPending,
|
||||||
|
...form.getInputProps("aspectId"),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
formType === "detail"
|
||||||
|
? {
|
||||||
|
type: "text",
|
||||||
|
label: "Nama Sub Aspek",
|
||||||
|
readOnly: true,
|
||||||
|
value: filteredSubAspects.find(subAspect => subAspect.id === form.values.subAspectId)?.name || "",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "select",
|
||||||
|
label: "Nama Sub Aspek",
|
||||||
|
placeholder: "Pilih Sub Aspek",
|
||||||
|
data: filteredSubAspects.map((subAspect) => ({
|
||||||
|
value: subAspect.id,
|
||||||
|
label: subAspect.name,
|
||||||
|
})),
|
||||||
|
disabled: mutation.isPending,
|
||||||
|
...form.getInputProps("subAspectId"),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "textarea",
|
||||||
|
label: "Pertanyaan",
|
||||||
|
placeholder: "Tulis Pertanyaan",
|
||||||
|
...form.getInputProps("question"),
|
||||||
|
},
|
||||||
|
formType === "detail"
|
||||||
|
? {
|
||||||
|
type: "text",
|
||||||
|
label: "Dibutuhkan Upload File?",
|
||||||
|
readOnly: true,
|
||||||
|
value: form.values.needFile ? "Ya" : "Tidak",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "select",
|
||||||
|
label: "Dibutuhkan Upload File?",
|
||||||
|
placeholder: "Pilih opsi",
|
||||||
|
data: [
|
||||||
|
{ value: "true", label: "Ya" },
|
||||||
|
{ value: "false", label: "Tidak" },
|
||||||
|
],
|
||||||
|
value: form.values.needFile ? "true" : "false",
|
||||||
|
onChange: (value) => form.setFieldValue("needFile", value === "true"),
|
||||||
|
disabled: mutation.isPending,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<Stack mt="sm">
|
||||||
|
{form.values.options.map((option, index) => (
|
||||||
|
<Group key={index} mb="sm">
|
||||||
|
<TextInput
|
||||||
|
label={`Jawaban ${index + 1}`}
|
||||||
|
placeholder="Jawaban"
|
||||||
|
readOnly={formType === "detail"}
|
||||||
|
{...form.getInputProps(`options.${index}.text`)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Skor"
|
||||||
|
placeholder="Skor"
|
||||||
|
readOnly={formType === "detail"}
|
||||||
|
min={0}
|
||||||
|
max={999}
|
||||||
|
allowDecimal={false}
|
||||||
|
allowNegative={false}
|
||||||
|
{...form.getInputProps(`options.${index}.score`)}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{formType !== "detail" && (
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleRemoveOption(index)}
|
||||||
|
>
|
||||||
|
<TbTrash />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{formType !== "detail" && (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddOption}
|
||||||
|
leftSection={<TbPlus />}
|
||||||
|
>
|
||||||
|
Tambah Jawaban
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate({ search: {} })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
>
|
||||||
|
Tutup
|
||||||
|
</Button>
|
||||||
|
{formType !== "detail" && (
|
||||||
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
leftSection={<TbDeviceFloppy size={20} />}
|
||||||
|
type="submit"
|
||||||
|
loading={mutation.isPending}
|
||||||
|
>
|
||||||
|
Simpan
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import { queryOptions } from "@tanstack/react-query";
|
||||||
|
import { InferRequestType } from "hono";
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
questionId: string;
|
||||||
|
text: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateQuestionPayload {
|
||||||
|
subAspectId: string; // Ensure this matches the correct ID type
|
||||||
|
question: string;
|
||||||
|
needFile: boolean;
|
||||||
|
options: Option[]; // Array of options (text and score)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateQuestionPayload {
|
||||||
|
id: string; // The ID of the question to update
|
||||||
|
subAspectId: string; // Ensure this matches the correct ID type
|
||||||
|
question: string;
|
||||||
|
needFile: boolean;
|
||||||
|
options?: Option[]; // Optional array of options (text and score)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const questionQueryOptions = (page: number, limit: number, q?: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["questions", { page, limit, q }],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.questions.$get({
|
||||||
|
query: {
|
||||||
|
limit: String(limit),
|
||||||
|
page: String(page),
|
||||||
|
q,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getQuestionByIdQueryOptions = (questionId: string | undefined) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["question", questionId],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchRPC(
|
||||||
|
client.questions[":id"].$get({
|
||||||
|
param: {
|
||||||
|
id: questionId!,
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
enabled: Boolean(questionId),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createQuestion = async (form: CreateQuestionPayload) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.questions.$post({
|
||||||
|
json: {
|
||||||
|
question: form.question,
|
||||||
|
needFile: form.needFile,
|
||||||
|
subAspectId: form.subAspectId,
|
||||||
|
options: form.options.map((option) => ({
|
||||||
|
text: option.text,
|
||||||
|
score: option.score,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateQuestion = async (form: UpdateQuestionPayload) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.questions[":id"].$patch({
|
||||||
|
param: {
|
||||||
|
id: form.id,
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
question: form.question,
|
||||||
|
needFile: form.needFile,
|
||||||
|
subAspectId: form.subAspectId,
|
||||||
|
options: form.options?.map((option: Option) => ({
|
||||||
|
text: option.text,
|
||||||
|
score: option.score,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteQuestion = async (id: string) => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.questions[":id"].$delete({
|
||||||
|
param: { id },
|
||||||
|
query: {},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAspects = async () => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.questions.aspects.$get({
|
||||||
|
query: {} // Provide an empty query if no parameters are needed
|
||||||
|
}) // Adjust this based on your API client structure
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSubAspects = async () => {
|
||||||
|
return await fetchRPC(
|
||||||
|
client.questions.subAspects.$get({
|
||||||
|
query: {} // Provide an empty query if no parameters are needed
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import client from "@/honoClient";
|
import client from "@/honoClient";
|
||||||
import { Button, Flex, Modal, Text } from "@mantine/core";
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/shadcn/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { 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";
|
||||||
|
|
@ -60,40 +69,34 @@ export default function UserDeleteModal() {
|
||||||
const isModalOpen = Boolean(searchParams.delete && userQuery.data);
|
const isModalOpen = Boolean(searchParams.delete && userQuery.data);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<AlertDialog open={isModalOpen} onOpenChange={() => navigate({ search: {} })}>
|
||||||
opened={isModalOpen}
|
<AlertDialogContent>
|
||||||
onClose={() => navigate({ search: {} })}
|
<AlertDialogHeader>
|
||||||
title={`Delete confirmation`}
|
<AlertDialogTitle>Konfirmasi Hapus</AlertDialogTitle>
|
||||||
>
|
<AlertDialogDescription>
|
||||||
<Text size="sm">
|
Apakah Anda yakin ingin menghapus pengguna{" "}
|
||||||
Are you sure you want to delete user{" "}
|
<strong>{userQuery.data?.name}</strong>?
|
||||||
<Text span fw={700}>
|
<br />
|
||||||
{userQuery.data?.name}
|
Tindakan ini tidak dapat diubah.
|
||||||
</Text>
|
</AlertDialogDescription>
|
||||||
? This action is irreversible.
|
</AlertDialogHeader>
|
||||||
</Text>
|
<AlertDialogFooter>
|
||||||
|
<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}
|
||||||
>
|
>
|
||||||
Cancel
|
Batal
|
||||||
</Button>
|
</AlertDialogCancel>
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="default"
|
||||||
// leftSection={<TbDeviceFloppy size={20} />}
|
|
||||||
type="submit"
|
|
||||||
color="red"
|
color="red"
|
||||||
loading={mutation.isPending}
|
|
||||||
onClick={() => mutation.mutate({ id: userId })}
|
onClick={() => mutation.mutate({ id: userId })}
|
||||||
|
disabled={mutation.isPending}
|
||||||
>
|
>
|
||||||
Delete User
|
{mutation.isPending ? "Hapus..." : "Hapus Pengguna"}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</AlertDialogFooter>
|
||||||
</Modal>
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
|
import client from "../../../honoClient";
|
||||||
import stringToColorHex from "@/utils/stringToColorHex";
|
import stringToColorHex from "@/utils/stringToColorHex";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
AvatarFallback,
|
||||||
Center,
|
AvatarImage
|
||||||
Flex,
|
} from "@/shadcn/components/ui/avatar";
|
||||||
Modal,
|
import { Modal, ScrollArea } from "@mantine/core";
|
||||||
ScrollArea,
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
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";
|
||||||
|
|
@ -42,7 +39,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 ? "edit" : "create";
|
const formType = detailId ? "detail" : editId ? "ubah" : "tambah";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CHANGE FOLLOWING:
|
* CHANGE FOLLOWING:
|
||||||
|
|
@ -51,7 +48,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) + " User";
|
formType.charAt(0).toUpperCase() + formType.slice(1) + " Pengguna";
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
|
@ -62,6 +59,11 @@ export default function UserFormModal() {
|
||||||
photoProfileUrl: "",
|
photoProfileUrl: "",
|
||||||
password: "",
|
password: "",
|
||||||
roles: [] as string[],
|
roles: [] as string[],
|
||||||
|
companyName: "",
|
||||||
|
position: "",
|
||||||
|
workExperience: "",
|
||||||
|
address: "",
|
||||||
|
phoneNumber: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -75,12 +77,17 @@ 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({});
|
||||||
|
|
@ -91,11 +98,11 @@ export default function UserFormModal() {
|
||||||
mutationKey: ["usersMutation"],
|
mutationKey: ["usersMutation"],
|
||||||
mutationFn: async (
|
mutationFn: async (
|
||||||
options:
|
options:
|
||||||
| { action: "edit"; data: Parameters<typeof updateUser>[0] }
|
| { action: "ubah"; data: Parameters<typeof updateUser>[0] }
|
||||||
| { action: "create"; data: Parameters<typeof createUser>[0] }
|
| { action: "tambah"; data: Parameters<typeof createUser>[0] }
|
||||||
) => {
|
) => {
|
||||||
console.log("called");
|
console.log("called");
|
||||||
return options.action === "edit"
|
return options.action === "ubah"
|
||||||
? await updateUser(options.data)
|
? await updateUser(options.data)
|
||||||
: await createUser(options.data);
|
: await createUser(options.data);
|
||||||
},
|
},
|
||||||
|
|
@ -120,16 +127,21 @@ export default function UserFormModal() {
|
||||||
if (formType === "detail") return;
|
if (formType === "detail") return;
|
||||||
|
|
||||||
//TODO: OPtimize this code
|
//TODO: OPtimize this code
|
||||||
if (formType === "create") {
|
if (formType === "tambah") {
|
||||||
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: JSON.stringify(values.roles),
|
roles: 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 {
|
||||||
|
|
@ -140,15 +152,20 @@ export default function UserFormModal() {
|
||||||
email: values.email,
|
email: values.email,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
roles: JSON.stringify(values.roles),
|
roles: 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: `The ser is ${formType === "create" ? "created" : "edited"}`,
|
message: `Pengguna berhasil di${formType === "tambah" ? "tambahkan" : "perbarui"}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
navigate({ search: {} });
|
navigate({ search: {} });
|
||||||
|
|
@ -179,39 +196,36 @@ export default function UserFormModal() {
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
<Stack mt="sm" gap="lg" px="lg">
|
<div className="flex items-center justify-center my-2">
|
||||||
{/* Avatar */}
|
<div className="h-120 w-120 rounded-full overflow-hidden">
|
||||||
<Center>
|
<Avatar className="h-[120px] w-[120px] rounded-full overflow-hidden">
|
||||||
<Avatar
|
<AvatarImage src={form.values.photoProfileUrl} alt={form.values.name} />
|
||||||
color={stringToColorHex(form.values.id ?? "")}
|
<AvatarFallback style={{ backgroundColor: stringToColorHex(form.values.id ?? ""), fontSize: "60px", color: "white", fontWeight: "bold" }}>
|
||||||
src={form.values.photoProfileUrl}
|
{form.values.name?.[0]?.toUpperCase() ?? "?"}
|
||||||
size={120}
|
</AvatarFallback>
|
||||||
>
|
|
||||||
{form.values.name?.[0]?.toUpperCase()}
|
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</Center>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
|
|
||||||
|
<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",
|
||||||
readOnly: true,
|
label: "Nama",
|
||||||
variant: "filled",
|
|
||||||
...form.getInputProps("id"),
|
|
||||||
hidden: !form.values.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
label: "Name",
|
|
||||||
...form.getInputProps("name"),
|
...form.getInputProps("name"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
label: "Username",
|
label: "Jabatan",
|
||||||
...form.getInputProps("username"),
|
...form.getInputProps("position"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
label: "Pengalaman Kerja",
|
||||||
|
...form.getInputProps("workExperience"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
|
|
@ -219,11 +233,21 @@ export default function UserFormModal() {
|
||||||
...form.getInputProps("email"),
|
...form.getInputProps("email"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "password",
|
type: "text",
|
||||||
label: "Password",
|
label: "Instansi/Perusahaan",
|
||||||
hidden: formType !== "create",
|
...form.getInputProps("companyName"),
|
||||||
...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",
|
||||||
|
|
@ -236,29 +260,40 @@ 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 */}
|
||||||
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
|
<div className="flex justify-end align-center gap-1 mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate({ search: {} })}
|
onClick={() => navigate({ search: {} })}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
>
|
>
|
||||||
Close
|
Tutup
|
||||||
</Button>
|
</Button>
|
||||||
{formType !== "detail" && (
|
{formType !== "detail" && (
|
||||||
<Button
|
<Button
|
||||||
variant="filled"
|
variant="default"
|
||||||
leftSection={<TbDeviceFloppy size={20} />}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
>
|
>
|
||||||
Save
|
Simpan
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -34,26 +34,26 @@ export const getUserByIdQueryOptions = (userId: string | undefined) =>
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createUser = async (
|
export const createUser = async (
|
||||||
form: InferRequestType<typeof client.users.$post>["form"]
|
json: InferRequestType<typeof client.users.$post>["json"]
|
||||||
) => {
|
) => {
|
||||||
return await fetchRPC(
|
return await fetchRPC(
|
||||||
client.users.$post({
|
client.users.$post({
|
||||||
form,
|
json,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUser = async (
|
export const updateUser = async (
|
||||||
form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & {
|
json: InferRequestType<(typeof client.users)[":id"]["$patch"]>["json"] & {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
return await fetchRPC(
|
return await fetchRPC(
|
||||||
client.users[":id"].$patch({
|
client.users[":id"].$patch({
|
||||||
param: {
|
param: {
|
||||||
id: form.id,
|
id: json.id,
|
||||||
},
|
},
|
||||||
form,
|
json,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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";
|
||||||
|
|
@ -7,6 +6,8 @@ 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>;
|
||||||
|
|
@ -29,31 +30,28 @@ const createColumns = (options: ColumnOptions) => {
|
||||||
columnHelper.accessor("name", {
|
columnHelper.accessor("name", {
|
||||||
header: "Name",
|
header: "Name",
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<Group>
|
<div className="items-center justify-center gap-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
color={stringToColorHex(props.row.original.id)}
|
style={{ backgroundColor: stringToColorHex(props.row.original.id), width: '26px', height: '26px' }}
|
||||||
// src={props.row.original.photoUrl}
|
// src={props.row.original.photoUrl}
|
||||||
size={26}
|
|
||||||
>
|
>
|
||||||
{props.getValue()?.[0].toUpperCase()}
|
{props.getValue()?.[0].toUpperCase()}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="sm" fw={500}>
|
<span className="text-sm font-medium">
|
||||||
{props.getValue()}
|
{props.getValue()}
|
||||||
</Text>
|
</span>
|
||||||
</Group>
|
</div>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
columnHelper.accessor("email", {
|
columnHelper.accessor("email", {
|
||||||
header: "Email",
|
header: "Email",
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<Anchor
|
<Link
|
||||||
to={`mailto:${props.getValue()}`}
|
href={`mailto:${props.getValue()}`}
|
||||||
size="sm"
|
|
||||||
component={Link}
|
|
||||||
>
|
>
|
||||||
{props.getValue()}
|
{props.getValue()}
|
||||||
</Anchor>
|
</Link>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
@ -66,7 +64,7 @@ const createColumns = (options: ColumnOptions) => {
|
||||||
id: "status",
|
id: "status",
|
||||||
header: "Status",
|
header: "Status",
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<Badge color={props.row.original.isEnabled ? "green" : "gray"}>
|
<Badge variant={props.row.original.isEnabled ? "default" : "secondary"}>
|
||||||
{props.row.original.isEnabled ? "Active" : "Inactive"}
|
{props.row.original.isEnabled ? "Active" : "Inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
|
|
@ -80,7 +78,7 @@ const createColumns = (options: ColumnOptions) => {
|
||||||
className: "w-fit",
|
className: "w-fit",
|
||||||
},
|
},
|
||||||
cell: (props) => (
|
cell: (props) => (
|
||||||
<Flex gap="xs">
|
<div className="gap-8">
|
||||||
{createActionButtons([
|
{createActionButtons([
|
||||||
{
|
{
|
||||||
label: "Detail",
|
label: "Detail",
|
||||||
|
|
@ -104,7 +102,7 @@ const createColumns = (options: ColumnOptions) => {
|
||||||
icon: <TbTrash />,
|
icon: <TbTrash />,
|
||||||
},
|
},
|
||||||
])}
|
])}
|
||||||
</Flex>
|
</div>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -13,38 +13,93 @@ 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 DashboardLayoutDashboardIndexImport } from './routes/_dashboardLayout/dashboard/index'
|
import { Route as DashboardLayoutQuestionsIndexImport } from './routes/_dashboardLayout/questions/index'
|
||||||
|
import { Route as DashboardLayoutAssessmentResultsManagementIndexImport } from './routes/_dashboardLayout/assessmentResultsManagement/index'
|
||||||
|
import { Route as DashboardLayoutAssessmentResultIndexImport } from './routes/_dashboardLayout/assessmentResult/index'
|
||||||
|
import { Route as DashboardLayoutAssessmentRequestManagementsIndexImport } from './routes/_dashboardLayout/assessmentRequestManagements/index'
|
||||||
|
import { Route as DashboardLayoutAssessmentRequestIndexImport } from './routes/_dashboardLayout/assessmentRequest/index'
|
||||||
|
import { Route as DashboardLayoutAspectIndexImport } from './routes/_dashboardLayout/aspect/index'
|
||||||
|
import { Route as AssessmentLayoutAssessmentIndexImport } from './routes/_assessmentLayout/assessment/index'
|
||||||
|
|
||||||
// Create Virtual Routes
|
// Create Virtual Routes
|
||||||
|
|
||||||
const IndexLazyImport = createFileRoute('/')()
|
const IndexLazyImport = createFileRoute('/')()
|
||||||
|
const RegisterIndexLazyImport = createFileRoute('/register/')()
|
||||||
const LogoutIndexLazyImport = createFileRoute('/logout/')()
|
const LogoutIndexLazyImport = createFileRoute('/logout/')()
|
||||||
const LoginIndexLazyImport = createFileRoute('/login/')()
|
const ForgotPasswordIndexLazyImport = createFileRoute('/forgot-password/')()
|
||||||
|
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 LoginIndexLazyRoute = LoginIndexLazyImport.update({
|
const ForgotPasswordIndexLazyRoute = ForgotPasswordIndexLazyImport.update({
|
||||||
|
path: '/forgot-password/',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/forgot-password/index.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
|
|
||||||
|
const LoginIndexRoute = LoginIndexImport.update({
|
||||||
path: '/login/',
|
path: '/login/',
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any).lazy(() => import('./routes/login/index.lazy').then((d) => d.Route))
|
} as any)
|
||||||
|
|
||||||
|
const ForgotPasswordVerifyLazyRoute = ForgotPasswordVerifyLazyImport.update({
|
||||||
|
path: '/forgot-password/verify',
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/forgot-password/verify.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
|
|
||||||
|
const VerifyingLayoutVerifyingIndexRoute =
|
||||||
|
VerifyingLayoutVerifyingIndexImport.update({
|
||||||
|
path: '/verifying/',
|
||||||
|
getParentRoute: () => VerifyingLayoutRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/_verifyingLayout/verifying/index.lazy').then(
|
||||||
|
(d) => d.Route,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const DashboardLayoutUsersIndexRoute = DashboardLayoutUsersIndexImport.update({
|
const DashboardLayoutUsersIndexRoute = DashboardLayoutUsersIndexImport.update({
|
||||||
path: '/users/',
|
path: '/users/',
|
||||||
|
|
@ -59,11 +114,74 @@ const DashboardLayoutTimetableIndexRoute =
|
||||||
getParentRoute: () => DashboardLayoutRoute,
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
const DashboardLayoutDashboardIndexRoute =
|
const DashboardLayoutQuestionsIndexRoute =
|
||||||
DashboardLayoutDashboardIndexImport.update({
|
DashboardLayoutQuestionsIndexImport.update({
|
||||||
path: '/dashboard/',
|
path: '/questions/',
|
||||||
getParentRoute: () => DashboardLayoutRoute,
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
} as any)
|
} as any).lazy(() =>
|
||||||
|
import('./routes/_dashboardLayout/questions/index.lazy').then(
|
||||||
|
(d) => d.Route,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const DashboardLayoutAssessmentResultsManagementIndexRoute =
|
||||||
|
DashboardLayoutAssessmentResultsManagementIndexImport.update({
|
||||||
|
path: '/assessmentResultsManagement/',
|
||||||
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import(
|
||||||
|
'./routes/_dashboardLayout/assessmentResultsManagement/index.lazy'
|
||||||
|
).then((d) => d.Route),
|
||||||
|
)
|
||||||
|
|
||||||
|
const DashboardLayoutAssessmentResultIndexRoute =
|
||||||
|
DashboardLayoutAssessmentResultIndexImport.update({
|
||||||
|
path: '/assessmentResult/',
|
||||||
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/_dashboardLayout/assessmentResult/index.lazy').then(
|
||||||
|
(d) => d.Route,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const DashboardLayoutAssessmentRequestManagementsIndexRoute =
|
||||||
|
DashboardLayoutAssessmentRequestManagementsIndexImport.update({
|
||||||
|
path: '/assessmentRequestManagements/',
|
||||||
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import(
|
||||||
|
'./routes/_dashboardLayout/assessmentRequestManagements/index.lazy'
|
||||||
|
).then((d) => d.Route),
|
||||||
|
)
|
||||||
|
|
||||||
|
const DashboardLayoutAssessmentRequestIndexRoute =
|
||||||
|
DashboardLayoutAssessmentRequestIndexImport.update({
|
||||||
|
path: '/assessmentRequest/',
|
||||||
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/_dashboardLayout/assessmentRequest/index.lazy').then(
|
||||||
|
(d) => d.Route,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const DashboardLayoutAspectIndexRoute = DashboardLayoutAspectIndexImport.update(
|
||||||
|
{
|
||||||
|
path: '/aspect/',
|
||||||
|
getParentRoute: () => DashboardLayoutRoute,
|
||||||
|
} as any,
|
||||||
|
).lazy(() =>
|
||||||
|
import('./routes/_dashboardLayout/aspect/index.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
|
|
||||||
|
const AssessmentLayoutAssessmentIndexRoute =
|
||||||
|
AssessmentLayoutAssessmentIndexImport.update({
|
||||||
|
path: '/assessment/',
|
||||||
|
getParentRoute: () => AssessmentLayoutRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/_assessmentLayout/assessment/index.lazy').then(
|
||||||
|
(d) => d.Route,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Populate the FileRoutesByPath interface
|
// Populate the FileRoutesByPath interface
|
||||||
|
|
||||||
|
|
@ -76,6 +194,13 @@ 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: ''
|
||||||
|
|
@ -83,11 +208,32 @@ 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 LoginIndexLazyImport
|
preLoaderRoute: typeof LoginIndexImport
|
||||||
|
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/': {
|
||||||
|
|
@ -97,11 +243,60 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof LogoutIndexLazyImport
|
preLoaderRoute: typeof LogoutIndexLazyImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
'/_dashboardLayout/dashboard/': {
|
'/register/': {
|
||||||
id: '/_dashboardLayout/dashboard/'
|
id: '/register/'
|
||||||
path: '/dashboard'
|
path: '/register'
|
||||||
fullPath: '/dashboard'
|
fullPath: '/register'
|
||||||
preLoaderRoute: typeof DashboardLayoutDashboardIndexImport
|
preLoaderRoute: typeof RegisterIndexLazyImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
|
'/_assessmentLayout/assessment/': {
|
||||||
|
id: '/_assessmentLayout/assessment/'
|
||||||
|
path: '/assessment'
|
||||||
|
fullPath: '/assessment'
|
||||||
|
preLoaderRoute: typeof AssessmentLayoutAssessmentIndexImport
|
||||||
|
parentRoute: typeof AssessmentLayoutImport
|
||||||
|
}
|
||||||
|
'/_dashboardLayout/aspect/': {
|
||||||
|
id: '/_dashboardLayout/aspect/'
|
||||||
|
path: '/aspect'
|
||||||
|
fullPath: '/aspect'
|
||||||
|
preLoaderRoute: typeof DashboardLayoutAspectIndexImport
|
||||||
|
parentRoute: typeof DashboardLayoutImport
|
||||||
|
}
|
||||||
|
'/_dashboardLayout/assessmentRequest/': {
|
||||||
|
id: '/_dashboardLayout/assessmentRequest/'
|
||||||
|
path: '/assessmentRequest'
|
||||||
|
fullPath: '/assessmentRequest'
|
||||||
|
preLoaderRoute: typeof DashboardLayoutAssessmentRequestIndexImport
|
||||||
|
parentRoute: typeof DashboardLayoutImport
|
||||||
|
}
|
||||||
|
'/_dashboardLayout/assessmentRequestManagements/': {
|
||||||
|
id: '/_dashboardLayout/assessmentRequestManagements/'
|
||||||
|
path: '/assessmentRequestManagements'
|
||||||
|
fullPath: '/assessmentRequestManagements'
|
||||||
|
preLoaderRoute: typeof DashboardLayoutAssessmentRequestManagementsIndexImport
|
||||||
|
parentRoute: typeof DashboardLayoutImport
|
||||||
|
}
|
||||||
|
'/_dashboardLayout/assessmentResult/': {
|
||||||
|
id: '/_dashboardLayout/assessmentResult/'
|
||||||
|
path: '/assessmentResult'
|
||||||
|
fullPath: '/assessmentResult'
|
||||||
|
preLoaderRoute: typeof DashboardLayoutAssessmentResultIndexImport
|
||||||
|
parentRoute: typeof DashboardLayoutImport
|
||||||
|
}
|
||||||
|
'/_dashboardLayout/assessmentResultsManagement/': {
|
||||||
|
id: '/_dashboardLayout/assessmentResultsManagement/'
|
||||||
|
path: '/assessmentResultsManagement'
|
||||||
|
fullPath: '/assessmentResultsManagement'
|
||||||
|
preLoaderRoute: typeof DashboardLayoutAssessmentResultsManagementIndexImport
|
||||||
|
parentRoute: typeof DashboardLayoutImport
|
||||||
|
}
|
||||||
|
'/_dashboardLayout/questions/': {
|
||||||
|
id: '/_dashboardLayout/questions/'
|
||||||
|
path: '/questions'
|
||||||
|
fullPath: '/questions'
|
||||||
|
preLoaderRoute: typeof DashboardLayoutQuestionsIndexImport
|
||||||
parentRoute: typeof DashboardLayoutImport
|
parentRoute: typeof DashboardLayoutImport
|
||||||
}
|
}
|
||||||
'/_dashboardLayout/timetable/': {
|
'/_dashboardLayout/timetable/': {
|
||||||
|
|
@ -118,6 +313,13 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,13 +327,27 @@ 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({
|
||||||
DashboardLayoutDashboardIndexRoute,
|
DashboardLayoutAspectIndexRoute,
|
||||||
|
DashboardLayoutAssessmentRequestIndexRoute,
|
||||||
|
DashboardLayoutAssessmentRequestManagementsIndexRoute,
|
||||||
|
DashboardLayoutAssessmentResultIndexRoute,
|
||||||
|
DashboardLayoutAssessmentResultsManagementIndexRoute,
|
||||||
|
DashboardLayoutQuestionsIndexRoute,
|
||||||
DashboardLayoutTimetableIndexRoute,
|
DashboardLayoutTimetableIndexRoute,
|
||||||
DashboardLayoutUsersIndexRoute,
|
DashboardLayoutUsersIndexRoute,
|
||||||
}),
|
}),
|
||||||
LoginIndexLazyRoute,
|
VerifyingLayoutRoute: VerifyingLayoutRoute.addChildren({
|
||||||
|
VerifyingLayoutVerifyingIndexRoute,
|
||||||
|
}),
|
||||||
|
ForgotPasswordVerifyLazyRoute,
|
||||||
|
LoginIndexRoute,
|
||||||
|
ForgotPasswordIndexLazyRoute,
|
||||||
LogoutIndexLazyRoute,
|
LogoutIndexLazyRoute,
|
||||||
|
RegisterIndexLazyRoute,
|
||||||
})
|
})
|
||||||
|
|
||||||
/* prettier-ignore-end */
|
/* prettier-ignore-end */
|
||||||
|
|
@ -143,30 +359,85 @@ export const routeTree = rootRoute.addChildren({
|
||||||
"filePath": "__root.tsx",
|
"filePath": "__root.tsx",
|
||||||
"children": [
|
"children": [
|
||||||
"/",
|
"/",
|
||||||
|
"/_assessmentLayout",
|
||||||
"/_dashboardLayout",
|
"/_dashboardLayout",
|
||||||
|
"/_verifyingLayout",
|
||||||
|
"/forgot-password/verify",
|
||||||
"/login/",
|
"/login/",
|
||||||
"/logout/"
|
"/forgot-password/",
|
||||||
|
"/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/dashboard/",
|
"/_dashboardLayout/aspect/",
|
||||||
|
"/_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.lazy.tsx"
|
"filePath": "login/index.tsx"
|
||||||
|
},
|
||||||
|
"/forgot-password/": {
|
||||||
|
"filePath": "forgot-password/index.lazy.tsx"
|
||||||
},
|
},
|
||||||
"/logout/": {
|
"/logout/": {
|
||||||
"filePath": "logout/index.lazy.tsx"
|
"filePath": "logout/index.lazy.tsx"
|
||||||
},
|
},
|
||||||
"/_dashboardLayout/dashboard/": {
|
"/register/": {
|
||||||
"filePath": "_dashboardLayout/dashboard/index.tsx",
|
"filePath": "register/index.lazy.tsx"
|
||||||
|
},
|
||||||
|
"/_assessmentLayout/assessment/": {
|
||||||
|
"filePath": "_assessmentLayout/assessment/index.tsx",
|
||||||
|
"parent": "/_assessmentLayout"
|
||||||
|
},
|
||||||
|
"/_dashboardLayout/aspect/": {
|
||||||
|
"filePath": "_dashboardLayout/aspect/index.tsx",
|
||||||
|
"parent": "/_dashboardLayout"
|
||||||
|
},
|
||||||
|
"/_dashboardLayout/assessmentRequest/": {
|
||||||
|
"filePath": "_dashboardLayout/assessmentRequest/index.tsx",
|
||||||
|
"parent": "/_dashboardLayout"
|
||||||
|
},
|
||||||
|
"/_dashboardLayout/assessmentRequestManagements/": {
|
||||||
|
"filePath": "_dashboardLayout/assessmentRequestManagements/index.tsx",
|
||||||
|
"parent": "/_dashboardLayout"
|
||||||
|
},
|
||||||
|
"/_dashboardLayout/assessmentResult/": {
|
||||||
|
"filePath": "_dashboardLayout/assessmentResult/index.tsx",
|
||||||
|
"parent": "/_dashboardLayout"
|
||||||
|
},
|
||||||
|
"/_dashboardLayout/assessmentResultsManagement/": {
|
||||||
|
"filePath": "_dashboardLayout/assessmentResultsManagement/index.tsx",
|
||||||
|
"parent": "/_dashboardLayout"
|
||||||
|
},
|
||||||
|
"/_dashboardLayout/questions/": {
|
||||||
|
"filePath": "_dashboardLayout/questions/index.tsx",
|
||||||
"parent": "/_dashboardLayout"
|
"parent": "/_dashboardLayout"
|
||||||
},
|
},
|
||||||
"/_dashboardLayout/timetable/": {
|
"/_dashboardLayout/timetable/": {
|
||||||
|
|
@ -176,6 +447,10 @@ 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-manrope">
|
<div className="font-inter">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackRouterDevtools />
|
{/* <TanStackRouterDevtools /> */}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
71
apps/frontend/src/routes/_assessmentLayout.tsx
Normal file
71
apps/frontend/src/routes/_assessmentLayout.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import AppHeader from "../components/AppHeader";
|
||||||
|
import AppNavbar from "../components/AppNavbar";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import fetchRPC from "@/utils/fetchRPC";
|
||||||
|
import client from "@/honoClient";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_assessmentLayout")({
|
||||||
|
component: AssessmentLayout,
|
||||||
|
|
||||||
|
// beforeLoad: ({ location }) => {
|
||||||
|
// if (true) {
|
||||||
|
// throw redirect({
|
||||||
|
// to: "/login",
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// },
|
||||||
|
});
|
||||||
|
|
||||||
|
function AssessmentLayout() {
|
||||||
|
const { isAuthenticated, saveAuthData } = useAuth();
|
||||||
|
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["my-profile"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetchRPC(client.auth["my-profile"].$get());
|
||||||
|
|
||||||
|
saveAuthData({
|
||||||
|
id: response.id,
|
||||||
|
name: response.name,
|
||||||
|
permissions: response.permissions,
|
||||||
|
role: response.roles[0],
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [openNavbar, setNavbarOpen] = useState(true);
|
||||||
|
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
||||||
|
const toggle = () => {
|
||||||
|
setNavbarOpen(!openNavbar);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLeftSidebar = () => {
|
||||||
|
setIsLeftSidebarOpen(!isLeftSidebarOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
return isAuthenticated ? (
|
||||||
|
<div className="flex flex-col w-full h-screen overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<AppHeader toggle={toggle} openNavbar={openNavbar} toggleLeftSidebar={toggleLeftSidebar} />
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<div className="flex h-full w-screen overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<AppNavbar />
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="relative w-full mt-16 bg-white overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/login" />
|
||||||
|
);
|
||||||
|
}
|
||||||
1173
apps/frontend/src/routes/_assessmentLayout/assessment/index.lazy.tsx
Normal file
1173
apps/frontend/src/routes/_assessmentLayout/assessment/index.lazy.tsx
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { getQuestionsAllQueryOptions } from "@/modules/assessmentManagement/queries/assessmentQueries.ts";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const searchParamSchema = z.object({
|
||||||
|
create: z.boolean().default(false).optional(),
|
||||||
|
edit: z.string().default("").optional(),
|
||||||
|
delete: z.string().default("").optional(),
|
||||||
|
detail: z.string().default("").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_assessmentLayout/assessment/")({
|
||||||
|
validateSearch: searchParamSchema,
|
||||||
|
|
||||||
|
loader: ({ context: { queryClient } }) => {
|
||||||
|
queryClient.ensureQueryData(getQuestionsAllQueryOptions(0, 10));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
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,
|
||||||
|
|
@ -32,6 +31,7 @@ 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,29 +39,32 @@ function DashboardLayout() {
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [openNavbar, { toggle }] = useDisclosure(false);
|
const [openNavbar, setNavbarOpen] = useState(true);
|
||||||
|
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
||||||
|
const toggle = () => {
|
||||||
|
setNavbarOpen(!openNavbar);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLeftSidebar = () => {
|
||||||
|
setIsLeftSidebarOpen(!isLeftSidebarOpen);
|
||||||
|
};
|
||||||
|
|
||||||
return isAuthenticated ? (
|
return isAuthenticated ? (
|
||||||
<AppShell
|
<div className="flex flex-col w-full h-screen overflow-hidden">
|
||||||
padding="md"
|
{/* Header */}
|
||||||
header={{ height: 70 }}
|
<AppHeader toggle={toggle} openNavbar={openNavbar} toggleLeftSidebar={toggleLeftSidebar} />
|
||||||
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 />
|
||||||
|
|
||||||
<AppShell.Main
|
{/* Main Content */}
|
||||||
className="bg-slate-100"
|
<main className="relative w-full mt-16 p-6 bg-white overflow-auto">
|
||||||
styles={{ main: { backgroundColor: "rgb(241 245 249)" } }}
|
|
||||||
>
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</AppShell.Main>
|
</main>
|
||||||
</AppShell>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Navigate to="/login" />
|
<Navigate to="/login" />
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
|
||||||
|
import PageTemplate from "@/components/PageTemplate";
|
||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import AspectFormModal from "@/modules/aspectManagement/modals/AspectFormModal";
|
||||||
|
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import { Flex } from "@mantine/core";
|
||||||
|
import createActionButtons from "@/utils/createActionButton";
|
||||||
|
import { TbEye, TbPencil, TbTrash } from "react-icons/tb";
|
||||||
|
import AspectDeleteModal from "@/modules/aspectManagement/modals/AspectDeleteModal";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/_dashboardLayout/aspect/")({
|
||||||
|
component: AspectPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type DataType = ExtractQueryDataType<typeof aspectQueryOptions>;
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<DataType>();
|
||||||
|
|
||||||
|
export default function AspectPage() {
|
||||||
|
// Function to update URL query string without refreshing
|
||||||
|
const updateQueryString = (key: string, value: string) => {
|
||||||
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
|
searchParams.set(key, value);
|
||||||
|
window.history.pushState({}, "", `?${searchParams.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTemplate
|
||||||
|
title="Manajemen Aspek"
|
||||||
|
queryOptions={aspectQueryOptions}
|
||||||
|
modals={[<AspectFormModal />, <AspectDeleteModal />]}
|
||||||
|
columnDefs={[
|
||||||
|
// Number of columns
|
||||||
|
columnHelper.display({
|
||||||
|
header: "#",
|
||||||
|
cell: (props) => props.row.index + 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Aspect Columns
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Nama Aspek",
|
||||||
|
cell: (props) => props.row.original.name || "Tidak ada Aspek",
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Sub aspect columns
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Sub Aspek",
|
||||||
|
cell: (props) => {
|
||||||
|
const subAspects = props.row.original.subAspects || [];
|
||||||
|
return subAspects.length > 0 ? (
|
||||||
|
<span>
|
||||||
|
{subAspects.map((subAspect, index) => (
|
||||||
|
<span key={subAspect.id}>
|
||||||
|
{subAspect.name}
|
||||||
|
{index < subAspects.length - 1 ? ", " : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>Tidak ada Sub Aspek</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Actions columns
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Aksi",
|
||||||
|
cell: (props) => (
|
||||||
|
<div className="flex flex-row w-fit items-center rounded gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-fit items-center hover:bg-gray-300 border"
|
||||||
|
onClick={() => updateQueryString("detail", props.row.original.id)}
|
||||||
|
>
|
||||||
|
<TbEye className="text-black" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-fit items-center hover:bg-gray-300 border"
|
||||||
|
onClick={() => updateQueryString("edit", props.row.original.id)}
|
||||||
|
>
|
||||||
|
<TbPencil className="text-black" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-fit items-center hover:bg-gray-300 border"
|
||||||
|
onClick={() => updateQueryString("delete", props.row.original.id)}
|
||||||
|
>
|
||||||
|
<TbTrash className="text-black" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx
Normal file
18
apps/frontend/src/routes/_dashboardLayout/aspect/index.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const searchParamSchema = z.object({
|
||||||
|
create: z.boolean().default(false).optional(),
|
||||||
|
edit: z.string().default("").optional(),
|
||||||
|
delete: z.string().default("").optional(),
|
||||||
|
detail: z.string().default("").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_dashboardLayout/aspect/")({
|
||||||
|
validateSearch: searchParamSchema,
|
||||||
|
|
||||||
|
loader: ({ context: { queryClient } }) => {
|
||||||
|
queryClient.ensureQueryData(aspectQueryOptions(0, 10));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { assessmentRequestQueryOptions, updateAssessmentRequest } from "@/modules/assessmentRequest/queries/assessmentRequestQueries";
|
||||||
|
import PageTemplate from "@/components/PageTemplate";
|
||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import FormModal from "@/modules/assessmentRequest/modals/CreateAssessmentRequestModal";
|
||||||
|
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import { Badge } from "@/shadcn/components/ui/badge";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import StartAssessmentModal from "@/modules/assessmentRequest/modals/ConfirmModal";
|
||||||
|
import { TbListCheck, TbClipboardText, TbChevronDown, TbChevronRight } from "react-icons/tb";
|
||||||
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/shadcn/components/ui/dropdown-menu";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentRequest/")({
|
||||||
|
component: UsersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type DataType = ExtractQueryDataType<typeof assessmentRequestQueryOptions>;
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<DataType>();
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selectedAssessmentId, setSelectedAssessmentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to open confirmation modal to start assessment
|
||||||
|
* @param {string} assessmentId ID of the assessment to be started
|
||||||
|
*/
|
||||||
|
const handleOpenModal = (assessmentId: string) => {
|
||||||
|
if (!assessmentId) {
|
||||||
|
console.error("Assessment ID is missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedAssessmentId(assessmentId);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to start assessment and redirect to the assessment page in new tab
|
||||||
|
* This function will update the status of the assessment to "dalam pengerjaan"
|
||||||
|
* and then open the assessment page in a new tab
|
||||||
|
* @param {string} assessmentId ID of the assessment to be started
|
||||||
|
*/
|
||||||
|
const handleStartAssessment = async (assessmentId: string) => {
|
||||||
|
try {
|
||||||
|
await updateAssessmentRequest({
|
||||||
|
assessmentId,
|
||||||
|
status: "dalam pengerjaan",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to the assessment page
|
||||||
|
const assessmentUrl = `/assessment?id=${assessmentId}`;
|
||||||
|
window.open(assessmentUrl, "_blank");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Gagal memulai asesmen:", error);
|
||||||
|
} finally {
|
||||||
|
setModalOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to open assessment result page based on valid ID
|
||||||
|
* Used when "View Result" button is clicked
|
||||||
|
* @param {string} assessmentId ID of the assessment to be opened
|
||||||
|
*/
|
||||||
|
const handleViewResult = (assessmentId: string) => {
|
||||||
|
// to make sure assessmentId is valid and not null
|
||||||
|
if (!assessmentId) {
|
||||||
|
console.error("Assessment ID is missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resultUrl = `/assessmentResult?id=${assessmentId}`;
|
||||||
|
window.open(resultUrl, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageTemplate
|
||||||
|
title="Permohonan Asesmen"
|
||||||
|
queryOptions={assessmentRequestQueryOptions}
|
||||||
|
modals={[<FormModal />]}
|
||||||
|
columnDefs={[
|
||||||
|
columnHelper.display({
|
||||||
|
header: "No",
|
||||||
|
cell: (props) => (
|
||||||
|
<div className="ml-1">
|
||||||
|
{props.row.index + 1}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Tanggal",
|
||||||
|
cell: (props) =>
|
||||||
|
props.row.original.tanggal
|
||||||
|
? new Intl.DateTimeFormat("id-ID", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
.format(new Date(props.row.original.tanggal))
|
||||||
|
.replace(/\./g, ':')
|
||||||
|
.replace('pukul ', '')
|
||||||
|
: 'N/A',
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Status",
|
||||||
|
cell: (props) => {
|
||||||
|
const status = props.row.original.status;
|
||||||
|
switch (status) {
|
||||||
|
case "dalam pengerjaan":
|
||||||
|
return <div className="flex items-center justify-center">
|
||||||
|
<Badge variant={"inprogress"}>Dalam Pengerjaan</Badge>
|
||||||
|
</div>;
|
||||||
|
case "menunggu konfirmasi":
|
||||||
|
return <div className="flex items-center justify-center">
|
||||||
|
<Badge variant={"waiting"}>Menunggu Konfirmasi</Badge>
|
||||||
|
</div>;
|
||||||
|
case "diterima":
|
||||||
|
return <div className="flex items-center justify-center">
|
||||||
|
<Badge variant={"accepted"}>Diterima</Badge>
|
||||||
|
</div>;
|
||||||
|
case "ditolak":
|
||||||
|
return <div className="flex items-center justify-center">
|
||||||
|
<Badge variant={"rejected"}>Ditolak</Badge>
|
||||||
|
</div>;
|
||||||
|
case "belum diverifikasi":
|
||||||
|
return <div className="flex items-center justify-center">
|
||||||
|
<Badge variant={"unverified"}>Belum Diverifikasi</Badge>
|
||||||
|
</div>;
|
||||||
|
case "selesai":
|
||||||
|
return <div className="flex items-center justify-center">
|
||||||
|
<Badge variant={"completed"}>Selesai</Badge>
|
||||||
|
</div>;
|
||||||
|
default:
|
||||||
|
return <div className="flex items-center justify-center">
|
||||||
|
<Badge variant={"outline"}>Tidak diketahui</Badge>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Aksi",
|
||||||
|
cell: (props) => {
|
||||||
|
const status = props.row.original.status;
|
||||||
|
const assessmentId = props.row.original.assessmentId; // Retrieve the assessmentId from the data row
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<DropdownMenu onOpenChange={(open) => setIsOpen(open)}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="secondary" className="pl-4 pr-2 rounded-lg bg-[--primary-color] text-white hover:bg-[--hover-primary-color] hover:text-white">
|
||||||
|
Pilihan
|
||||||
|
{isOpen ? <TbChevronDown className="ml-10" /> : <TbChevronRight className="ml-10" />}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="w-full">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (status === "diterima") {
|
||||||
|
handleOpenModal(assessmentId ?? '');
|
||||||
|
} else if (status === "dalam pengerjaan") {
|
||||||
|
const newUrl = `/assessment?id=${assessmentId}`;
|
||||||
|
window.open(newUrl, "_blank");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!(status === "diterima" || status === "dalam pengerjaan")}
|
||||||
|
>
|
||||||
|
<TbClipboardText className="mr-2" />
|
||||||
|
Mulai Asesmen
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => status === "selesai" || status === "belum diverifikasi" ? handleViewResult(assessmentId ?? '') : null}
|
||||||
|
disabled={!(status === "selesai" || status === "belum diverifikasi")}
|
||||||
|
>
|
||||||
|
<TbListCheck className="mr-2" />
|
||||||
|
Lihat Hasil
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Confirmation Modal to Start Assessment */}
|
||||||
|
{selectedAssessmentId && (
|
||||||
|
<StartAssessmentModal
|
||||||
|
assessmentId={selectedAssessmentId}
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onConfirm={handleStartAssessment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { assessmentRequestQueryOptions } from "@/modules/assessmentRequest/queries/assessmentRequestQueries"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const searchParamSchema = z.object({
|
||||||
|
create: z.boolean().default(false).optional(),
|
||||||
|
edit: z.string().default("").optional(),
|
||||||
|
delete: z.string().default("").optional(),
|
||||||
|
detail: z.string().default("").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_dashboardLayout/assessmentRequest/")({
|
||||||
|
validateSearch: searchParamSchema,
|
||||||
|
|
||||||
|
loader: ({ context: { queryClient } }) => {
|
||||||
|
queryClient.ensureQueryData(assessmentRequestQueryOptions(0, 10));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { assessmentRequestManagementQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries";
|
||||||
|
import PageTemplate from "@/components/PageTemplate";
|
||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import { Flex } from "@mantine/core";
|
||||||
|
import { Badge } from "@/shadcn/components/ui/badge";
|
||||||
|
import createActionButtons from "@/utils/createActionButton";
|
||||||
|
import { TbEye } from "react-icons/tb";
|
||||||
|
import AssessmentRequestManagementFormModal from "@/modules/assessmentRequestManagement/modals/AssessmentRequestManagementFormModal";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentRequestManagements/")({
|
||||||
|
component: AssessmentRequestManagementsPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type DataType = ExtractQueryDataType<typeof assessmentRequestManagementQueryOptions>;
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<DataType>();
|
||||||
|
|
||||||
|
export default function AssessmentRequestManagementsPage() {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const handleDetailClick = (id: string) => {
|
||||||
|
setSelectedId(id);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format the date
|
||||||
|
const formatDate = (dateString: string | null) => {
|
||||||
|
if (!dateString) {
|
||||||
|
return "Tanggal tidak tersedia";
|
||||||
|
}
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return new Intl.DateTimeFormat("id-ID", {
|
||||||
|
hour12: true,
|
||||||
|
minute: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
})
|
||||||
|
.format(date)
|
||||||
|
.replace(/\./g, ':')
|
||||||
|
.replace('pukul ', '');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTemplate
|
||||||
|
title="Manajemen Permohonan Asesmen"
|
||||||
|
queryOptions={assessmentRequestManagementQueryOptions}
|
||||||
|
modals={[
|
||||||
|
<AssessmentRequestManagementFormModal
|
||||||
|
key="form-modal"
|
||||||
|
assessmentId={selectedId}
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setModalOpen(false);
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
]}
|
||||||
|
createButton={null}
|
||||||
|
columnDefs={[
|
||||||
|
columnHelper.display({
|
||||||
|
header: "#",
|
||||||
|
cell: (props) => props.row.index + 1,
|
||||||
|
}),
|
||||||
|
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Tanggal",
|
||||||
|
cell: (props) => formatDate(props.row.original.tanggal),
|
||||||
|
}),
|
||||||
|
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Nama Responden",
|
||||||
|
cell: (props) => props.row.original.namaResponden,
|
||||||
|
}),
|
||||||
|
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Nama Perusahaan",
|
||||||
|
cell: (props) => props.row.original.namaPerusahaan,
|
||||||
|
}),
|
||||||
|
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Status",
|
||||||
|
cell: (props) => (
|
||||||
|
<div className="flex items-center justify-center text-center">
|
||||||
|
{(() => {
|
||||||
|
const status = props.row.original.status;
|
||||||
|
switch (status) {
|
||||||
|
case "menunggu konfirmasi":
|
||||||
|
return <Badge variant={"waiting"}>Menunggu Konfirmasi</Badge>;
|
||||||
|
case "diterima":
|
||||||
|
return <Badge variant={"accepted"}>Diterima</Badge>;
|
||||||
|
case "dalam pengerjaan":
|
||||||
|
return <Badge variant={"inprogress"}>Dalam Pengerjaan</Badge>;
|
||||||
|
case "ditolak":
|
||||||
|
return <Badge variant={"rejected"}>Ditolak</Badge>;
|
||||||
|
default:
|
||||||
|
return <Badge variant={"outline"}>Tidak diketahui</Badge>;
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Aksi",
|
||||||
|
cell: (props) => (
|
||||||
|
<Flex gap="xs">
|
||||||
|
{createActionButtons([
|
||||||
|
{
|
||||||
|
label: "Detail",
|
||||||
|
permission: true,
|
||||||
|
action: () => handleDetailClick(props.row.original.idPermohonan),
|
||||||
|
color: "black",
|
||||||
|
icon: <TbEye />,
|
||||||
|
},
|
||||||
|
])}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { assessmentRequestManagementQueryOptions } from "@/modules/assessmentRequestManagement/queries/assessmentRequestManagementQueries";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const searchParamSchema = z.object({
|
||||||
|
create: z.boolean().default(false).optional(),
|
||||||
|
edit: z.string().default("").optional(),
|
||||||
|
delete: z.string().default("").optional(),
|
||||||
|
detail: z.string().default("").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_dashboardLayout/assessmentRequestManagements/")({
|
||||||
|
validateSearch: searchParamSchema,
|
||||||
|
|
||||||
|
loader: ({ context: { queryClient } }) => {
|
||||||
|
queryClient.ensureQueryData(assessmentRequestManagementQueryOptions(0, 10));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,831 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import html2pdf from "html2pdf.js";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import { getAllAspectsAverageScore, getAllSubAspectsAverageScore, getAllVerifiedAspectsAverageScore, getAllVerifiedSubAspectsAverageScore } from "@/modules/assessmentResult/queries/assessmentResultQueries";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getAssessmentResultByIdQueryOptions, getVerifiedAssessmentResultByIdQueryOptions } from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
|
||||||
|
import { PieChart, Pie, Label, BarChart, Bar, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shadcn/components/ui/card"
|
||||||
|
import {
|
||||||
|
ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from "@/shadcn/components/ui/chart"
|
||||||
|
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
|
||||||
|
import { TbChevronDown, TbChevronLeft, TbChevronRight, TbChevronUp, TbFileTypePdf } from "react-icons/tb";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shadcn/components/ui/dropdown-menu";
|
||||||
|
import clsx from "clsx";
|
||||||
|
import React from "react";
|
||||||
|
import AppHeader from "@/components/AppHeader";
|
||||||
|
import { LeftSheet, LeftSheetContent } from "@/shadcn/components/ui/leftsheet";
|
||||||
|
import { ScrollArea } from "@mantine/core";
|
||||||
|
import data from "node_modules/backend/src/appEnv";
|
||||||
|
|
||||||
|
const getQueryParam = (param: string) => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get(param);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/_dashboardLayout/assessmentResult/")({
|
||||||
|
component: AssessmentResultPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function AssessmentResultPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isSuperAdmin = user?.role === "super-admin";
|
||||||
|
|
||||||
|
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); // Check for mobile screen
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const [openNavbar, setOpenNavbar] = useState(false);
|
||||||
|
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
|
||||||
|
const toggle = () => {
|
||||||
|
setOpenNavbar((prevState) => !prevState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Adjust layout on screen resize
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
setIsMobile(window.innerWidth <= 768);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleLeftSidebar = () => setIsLeftSidebarOpen(!isLeftSidebarOpen);
|
||||||
|
|
||||||
|
const [assessmentId, setAssessmentId] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = getQueryParam("id");
|
||||||
|
setAssessmentId(id ?? undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
//fetch data from API
|
||||||
|
const { data: aspectsData } = useQuery(aspectQueryOptions(0, 10));
|
||||||
|
const { data: assessmentResult } = useQuery(getAssessmentResultByIdQueryOptions(assessmentId));
|
||||||
|
const { data: verifiedAssessmentResult } = useQuery(getVerifiedAssessmentResultByIdQueryOptions(assessmentId));
|
||||||
|
const { data: allAspectsScoreData } = useQuery(getAllAspectsAverageScore(assessmentId));
|
||||||
|
const { data: allSubAspectsScoreData } = useQuery(getAllSubAspectsAverageScore(assessmentId));
|
||||||
|
const { data: allVerifiedAspectsScoreData } = useQuery(getAllVerifiedAspectsAverageScore(assessmentId));
|
||||||
|
const { data: allVerifiedSubAspectsScoreData } = useQuery(getAllVerifiedSubAspectsAverageScore(assessmentId));
|
||||||
|
|
||||||
|
// Pastikan status tersedia
|
||||||
|
const assessmentStatus = assessmentResult?.statusAssessment;
|
||||||
|
|
||||||
|
const getAspectScore = (aspectId: string) => {
|
||||||
|
return allAspectsScoreData?.aspects?.find((score) => score.aspectId === aspectId)?.averageScore || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSubAspectScore = (subAspectId: string) => {
|
||||||
|
return allSubAspectsScoreData?.subAspects?.find((score) => score.subAspectId === subAspectId)?.averageScore || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVerifiedAspectScore = (aspectId: string, assessmentStatus: string) => {
|
||||||
|
if (assessmentStatus !== "selesai") return undefined;
|
||||||
|
return allVerifiedAspectsScoreData?.aspects?.find((score) => score.aspectId === aspectId)?.averageScore || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVerifiedSubAspectScore = (subAspectId: string, assessmentStatus: string) => {
|
||||||
|
if (assessmentStatus !== "selesai") return undefined;
|
||||||
|
return allVerifiedSubAspectsScoreData?.subAspects?.find((score) => score.subAspectId === subAspectId)?.averageScore || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatScore = (score: string | number | undefined) => {
|
||||||
|
if (score === null || score === undefined) return '0';
|
||||||
|
const parsedScore = typeof score === 'number' ? score : parseFloat(score || "NaN");
|
||||||
|
return !isNaN(parsedScore) ? parsedScore.toFixed(2) : '0';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Total score
|
||||||
|
const totalScore = parseFloat(formatScore(assessmentResult?.assessmentsResult));
|
||||||
|
const totalVerifiedScore =
|
||||||
|
assessmentStatus === "selesai"
|
||||||
|
? parseFloat(formatScore(verifiedAssessmentResult?.verifiedAssessmentsResult))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Mengatur warna dan level maturitas berdasarkan skor
|
||||||
|
const getScoreStyleClass = (score: number | undefined, isBg: boolean = false) => {
|
||||||
|
if (score === undefined || score === null) return { color: 'grey' };
|
||||||
|
|
||||||
|
let colorVar = '--levelOne-color';
|
||||||
|
let descLevel = '1';
|
||||||
|
|
||||||
|
if (score >= 1.50 && score < 2.50) {
|
||||||
|
colorVar = '--levelTwo-color';
|
||||||
|
descLevel = '2';
|
||||||
|
} else if (score >= 2.50 && score < 3.50) {
|
||||||
|
colorVar = '--levelThree-color';
|
||||||
|
descLevel = '3';
|
||||||
|
} else if (score >= 3.50 && score < 4.50) {
|
||||||
|
colorVar = '--levelFour-color';
|
||||||
|
descLevel = '4';
|
||||||
|
} else if (score >= 4.50 && score <= 5) {
|
||||||
|
colorVar = '--levelFive-color';
|
||||||
|
descLevel = '5';
|
||||||
|
}
|
||||||
|
|
||||||
|
return isBg
|
||||||
|
? { backgroundColor: `var(${colorVar})`, descLevel }
|
||||||
|
: { color: `var(${colorVar})`, descLevel };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Warna aspek
|
||||||
|
const aspectsColors = [
|
||||||
|
"#37DCCC",
|
||||||
|
"#FF8C8C",
|
||||||
|
"#51D0FD",
|
||||||
|
"#FEA350",
|
||||||
|
"#AD8AFC",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Data diagram
|
||||||
|
const chartData = aspectsData?.data?.map((aspect, index) => ({
|
||||||
|
aspectName: aspect.name,
|
||||||
|
score: Number(formatScore(getAspectScore(aspect.id))),
|
||||||
|
fill: aspectsColors[index % aspectsColors.length],
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
const verifiedChartData =
|
||||||
|
assessmentStatus === "selesai"
|
||||||
|
? aspectsData?.data?.map((aspect, index) => ({
|
||||||
|
aspectName: aspect.name,
|
||||||
|
score: Number(formatScore(getVerifiedAspectScore(aspect.id, assessmentStatus))),
|
||||||
|
fill: aspectsColors[index % aspectsColors.length],
|
||||||
|
})) || []
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const barChartData = aspectsData?.data?.flatMap((aspect) =>
|
||||||
|
aspect.subAspects.map((subAspect) => ({
|
||||||
|
subAspectName: subAspect.name,
|
||||||
|
score: Number(formatScore(getSubAspectScore(subAspect.id))),
|
||||||
|
fill: "#005BFF",
|
||||||
|
aspectId: aspect.id,
|
||||||
|
aspectName: aspect.name
|
||||||
|
}))
|
||||||
|
) || [];
|
||||||
|
|
||||||
|
const verifiedBarChartData =
|
||||||
|
assessmentStatus === "selesai"
|
||||||
|
? aspectsData?.data?.flatMap((aspect) =>
|
||||||
|
aspect.subAspects.map((subAspect) => ({
|
||||||
|
subAspectName: subAspect.name,
|
||||||
|
score: Number(formatScore(getVerifiedSubAspectScore(subAspect.id, assessmentStatus))),
|
||||||
|
fill: "#005BFF",
|
||||||
|
aspectId: aspect.id,
|
||||||
|
aspectName: aspect.name,
|
||||||
|
}))
|
||||||
|
) || []
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const sortedBarChartData = barChartData.sort((a, b) => (a.aspectId ?? '').localeCompare(b.aspectId ?? ''));
|
||||||
|
const sortedVerifiedBarChartData = verifiedBarChartData.sort((a, b) => (a.aspectId ?? '').localeCompare(b.aspectId ?? ''));
|
||||||
|
|
||||||
|
const chartConfig =
|
||||||
|
assessmentStatus === "selesai"
|
||||||
|
? aspectsData?.data?.reduce((config, aspect, index) => {
|
||||||
|
config[aspect.name.toLowerCase()] = {
|
||||||
|
label: aspect.name,
|
||||||
|
color: aspectsColors[index % aspectsColors.length],
|
||||||
|
};
|
||||||
|
return config;
|
||||||
|
}, {} as ChartConfig) || {}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const barChartConfig =
|
||||||
|
assessmentStatus === "selesai"
|
||||||
|
? aspectsData?.data?.reduce((config, aspect, index) => {
|
||||||
|
aspect.subAspects.forEach((subAspect) => {
|
||||||
|
config[subAspect.name.toLowerCase()] = {
|
||||||
|
label: subAspect.name,
|
||||||
|
color: aspectsColors[index % aspectsColors.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return config;
|
||||||
|
}, {} as ChartConfig) || {}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
// Dropdown State
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState('Hasil Asesmen');
|
||||||
|
|
||||||
|
const handleDropdownToggle = () => {
|
||||||
|
setIsOpen((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemClick = () => {
|
||||||
|
setSelectedItem(prev =>
|
||||||
|
prev === 'Hasil Asesmen' ? 'Hasil Terverifikasi' : 'Hasil Asesmen'
|
||||||
|
);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pie Chart Component
|
||||||
|
function PieChartComponent({ chartData, totalScore, chartConfig }: { chartData: { aspectName: string, score: number, fill: string }[], totalScore: number, chartConfig: ChartConfig }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-full border-none">
|
||||||
|
<div className="flex-1 pb-0 w-72">
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="-ml-6 -mb-6 lg:mb-6 aspect-square max-h-60 lg:max-h-64"
|
||||||
|
>
|
||||||
|
<PieChart>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent hideLabel />}
|
||||||
|
/>
|
||||||
|
<Pie
|
||||||
|
data={chartData}
|
||||||
|
dataKey="score"
|
||||||
|
nameKey="aspectName"
|
||||||
|
innerRadius={50}
|
||||||
|
strokeWidth={5}
|
||||||
|
label={({ cx, cy, midAngle, innerRadius, outerRadius, index }) => {
|
||||||
|
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||||
|
const x = cx + radius * Math.cos(-midAngle * (Math.PI / 180));
|
||||||
|
const y = cy + radius * Math.sin(-midAngle * (Math.PI / 180));
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
fill="black"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
className="text-xs lg:text-md"
|
||||||
|
>
|
||||||
|
{chartData[index]?.score || ""}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
<Label
|
||||||
|
content={({ viewBox }) => {
|
||||||
|
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
<tspan
|
||||||
|
x={viewBox.cx}
|
||||||
|
y={viewBox.cy}
|
||||||
|
className="fill-foreground text-2xl lg:text-3xl font-bold"
|
||||||
|
>
|
||||||
|
{totalScore.toLocaleString()}
|
||||||
|
</tspan>
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-col lg:grid lg:grid-cols-2 gap-x-4 text-xs justify-center gap-2 lg:gap-0">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="pl-4 w-4 h-4"
|
||||||
|
style={{ backgroundColor: entry.fill }}
|
||||||
|
/>
|
||||||
|
<span className="font-medium whitespace-nowrap">{entry.aspectName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Mengatur tampilan label sumbu X
|
||||||
|
const customizedAxisTick = (props: any) => {
|
||||||
|
const { x, y, payload } = props;
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x},${y})`}>
|
||||||
|
<text
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
dy={3}
|
||||||
|
textAnchor="end"
|
||||||
|
fill="#666"
|
||||||
|
transform="rotate(-90)"
|
||||||
|
className="lg:text-[0.6rem] text-[0.3rem]"
|
||||||
|
>
|
||||||
|
{payload.value.slice(0, 3)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bar Chart Component
|
||||||
|
function BarChartComponent({ barChartData, barChartConfig }: { barChartData: { subAspectName: string, score: number, fill: string, aspectId: string, aspectName: string }[], barChartConfig: ChartConfig }) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<ChartContainer config={barChartConfig}>
|
||||||
|
<BarChart accessibilityLayer data={barChartData} margin={{ top: 5, left: -40 }}>
|
||||||
|
<CartesianGrid vertical={false} horizontal={true} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="subAspectName"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={0}
|
||||||
|
axisLine={false}
|
||||||
|
interval={0}
|
||||||
|
tick={customizedAxisTick}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 5]}
|
||||||
|
tickCount={6}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (active && payload && payload.length > 0) {
|
||||||
|
const { subAspectName, score } = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="tooltip bg-white p-1 rounded-md shadow-lg">
|
||||||
|
<p>{`${subAspectName} : ${score}`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="score" radius={2} fill="#007BFF" />
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrintPDF = async () => {
|
||||||
|
const pdfContainer = document.getElementById("pdfContainer");
|
||||||
|
|
||||||
|
if (pdfContainer) {
|
||||||
|
// Sembunyikan elemen yang tidak ingin dicetak
|
||||||
|
const buttonPrint = document.getElementById("button-print");
|
||||||
|
const noPrint = document.getElementById("no-print");
|
||||||
|
if (buttonPrint) buttonPrint.style.visibility = 'hidden';
|
||||||
|
if (noPrint) noPrint.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
margin: [10, 10, 10, -220],
|
||||||
|
image: { type: 'jpeg', quality: 0.98 },
|
||||||
|
html2canvas: {
|
||||||
|
scale: 2,
|
||||||
|
width: 1510,
|
||||||
|
height: pdfContainer.scrollHeight,
|
||||||
|
ignoreElements: (element: { tagName: string; }) => {
|
||||||
|
// Abaikan elemen <header> dalam pdfContainer
|
||||||
|
return element.tagName === 'HEADER';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
jsPDF: {
|
||||||
|
unit: 'pt',
|
||||||
|
format: 'a4',
|
||||||
|
orientation: 'portrait',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfBlob: Blob = await html2pdf()
|
||||||
|
.set(options)
|
||||||
|
.from(pdfContainer)
|
||||||
|
.toPdf()
|
||||||
|
.get('pdf')
|
||||||
|
.then((pdf: any) => {
|
||||||
|
pdf.setProperties({
|
||||||
|
title: 'Hasil_Asesemen_Level_Maturitas',
|
||||||
|
});
|
||||||
|
return pdf.output('blob');
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfURL = URL.createObjectURL(pdfBlob);
|
||||||
|
window.open(pdfURL, '_blank');
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error generating PDF:", err);
|
||||||
|
} finally {
|
||||||
|
// Tampilkan kembali elemen yang disembunyikan
|
||||||
|
if (buttonPrint) buttonPrint.style.visibility = 'visible';
|
||||||
|
if (noPrint) noPrint.style.visibility = 'visible';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex flex-row w-full h-full border-none shadow-none" id="pdfContainer">
|
||||||
|
<AppHeader
|
||||||
|
openNavbar={isLeftSidebarOpen}
|
||||||
|
toggle={toggleLeftSidebar}
|
||||||
|
toggleLeftSidebar={toggleLeftSidebar}
|
||||||
|
/>
|
||||||
|
{isMobile && (
|
||||||
|
<LeftSheet open={isLeftSidebarOpen} onOpenChange={(open) => setIsLeftSidebarOpen(open)}>
|
||||||
|
<LeftSheetContent className="h-full w-75 overflow-auto">
|
||||||
|
<ScrollArea className="h-full w-75 rounded-md p-2">
|
||||||
|
<Label className="text-gray-800 p-5 text-sm font-normal">Tingkatan Level Maturitas</Label>
|
||||||
|
<div className="flex flex-col w-64 h-fit border-none shadow-none -mt-6 pt-6">
|
||||||
|
<div className="flex flex-col mr-2 h-full">
|
||||||
|
<p className="font-bold mb-4 text-lg">Tingkatan Level Maturitas</p>
|
||||||
|
{[
|
||||||
|
{ level: 5, colorVar: '--levelFive-color', title: 'Implementasi Optimal', details: ['Otomatisasi', 'Terintegrasi', 'Membudaya'], textColor: 'white' },
|
||||||
|
{ level: 4, colorVar: '--levelFour-color', title: 'Implementasi Terkelola', details: ['Terorganisir', 'Review Berkala', 'Berkelanjutan'], textColor: 'white' },
|
||||||
|
{ level: 3, colorVar: '--levelThree-color', title: 'Implementasi Terdefinisi', details: ['Terorganisir', 'Konsisten', 'Review Berkala'], textColor: 'white' },
|
||||||
|
{ level: 2, colorVar: '--levelTwo-color', title: 'Implementasi Berulang', details: ['Terorganisir', 'Tidak Konsisten', 'Berulang'], textColor: 'white' },
|
||||||
|
{ level: 1, colorVar: '--levelOne-color', title: 'Implementasi Awal', details: ['Tidak Terukur', 'Tidak Konsisten', 'Risiko Tinggi'], textColor: 'white' }
|
||||||
|
].map(({ level, colorVar, title, details }) => (
|
||||||
|
<div key={level} className="flex flex-row h-full border-none">
|
||||||
|
<div
|
||||||
|
className="w-20 h-20 text-white font-medium text-lg flex justify-center items-center"
|
||||||
|
style={{ background: `var(${colorVar})` }}
|
||||||
|
>
|
||||||
|
<p className="text-center">Level {level}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start justify-center p-2">
|
||||||
|
<p className="text-xs font-bold whitespace-nowrap">{title}</p>
|
||||||
|
{details.map((detail) => (
|
||||||
|
<p key={detail} className="text-xs">{detail}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total verified score */}
|
||||||
|
<div className="pt-14">
|
||||||
|
{selectedItem === 'Hasil Asesmen' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-row h-16 border-t font-semibold justify-between items-center gap-2 pl-8 pr-6 -ml-6">
|
||||||
|
<p className="text-lg">Nilai Maturitas</p>
|
||||||
|
<span className="text-xl">{totalScore}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row h-16 font-semibold justify-between items-center gap-2 pl-8 pr-6 text-white -ml-6" style={getScoreStyleClass(Number(totalScore), true)}>
|
||||||
|
<p className="text-lg">Level Maturitas</p>
|
||||||
|
<span className="ml-2 text-xl">{getScoreStyleClass(Number(totalScore), true).descLevel}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-row h-16 border-t font-semibold justify-between items-center gap-2 pl-8 pr-6 -ml-6">
|
||||||
|
<p className="text-lg">Nilai Maturitas</p>
|
||||||
|
<span className="text-xl">{totalVerifiedScore}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row h-16 font-semibold justify-between items-center gap-2 pl-8 pr-6 text-white -ml-6" style={getScoreStyleClass(Number(totalVerifiedScore), true)}>
|
||||||
|
<p className="text-lg">Level Maturitas</p>
|
||||||
|
<span className="ml-2 text-xl">{getScoreStyleClass(Number(totalVerifiedScore), true).descLevel}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</LeftSheetContent>
|
||||||
|
</LeftSheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="hidden md:block lg:flex flex-col w-fit h-fit border-r shadow-none -mt-6 pt-6">
|
||||||
|
<div className="flex flex-col mr-2 h-full">
|
||||||
|
<p className="font-bold mb-4 text-lg">Tingkatan Level Maturitas</p>
|
||||||
|
{[
|
||||||
|
{ level: 5, colorVar: '--levelFive-color', title: 'Implementasi Optimal', details: ['Otomatisasi', 'Terintegrasi', 'Membudaya'], textColor: 'white' },
|
||||||
|
{ level: 4, colorVar: '--levelFour-color', title: 'Implementasi Terkelola', details: ['Terorganisir', 'Review Berkala', 'Berkelanjutan'], textColor: 'white' },
|
||||||
|
{ level: 3, colorVar: '--levelThree-color', title: 'Implementasi Terdefinisi', details: ['Terorganisir', 'Konsisten', 'Review Berkala'], textColor: 'white' },
|
||||||
|
{ level: 2, colorVar: '--levelTwo-color', title: 'Implementasi Berulang', details: ['Terorganisir', 'Tidak Konsisten', 'Berulang'], textColor: 'white' },
|
||||||
|
{ level: 1, colorVar: '--levelOne-color', title: 'Implementasi Awal', details: ['Tidak Terukur', 'Tidak Konsisten', 'Risiko Tinggi'], textColor: 'white' }
|
||||||
|
].map(({ level, colorVar, title, details }) => (
|
||||||
|
<div key={level} className="flex flex-row h-full border-none">
|
||||||
|
<div
|
||||||
|
className="w-20 h-20 text-white font-medium text-lg flex justify-center items-center"
|
||||||
|
style={{ background: `var(${colorVar})` }}
|
||||||
|
>
|
||||||
|
<p className="text-center">Level {level}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start justify-center p-2">
|
||||||
|
<p className="text-xs font-bold whitespace-nowrap">{title}</p>
|
||||||
|
{details.map((detail) => (
|
||||||
|
<p key={detail} className="text-xs">{detail}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total verified score */}
|
||||||
|
<div className="pt-14">
|
||||||
|
{selectedItem === 'Hasil Asesmen' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-row h-16 border-t font-semibold justify-between items-center gap-2 pl-8 pr-6 -ml-6">
|
||||||
|
<p className="text-lg">Nilai Maturitas</p>
|
||||||
|
<span className="text-xl">{totalScore}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row h-16 font-semibold justify-between items-center gap-2 pl-8 pr-6 text-white -ml-6" style={getScoreStyleClass(Number(totalScore), true)}>
|
||||||
|
<p className="text-lg">Level Maturitas</p>
|
||||||
|
<span className="ml-2 text-xl">{getScoreStyleClass(Number(totalScore), true).descLevel}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-row h-16 border-t font-semibold justify-between items-center gap-2 pl-8 pr-6 -ml-6">
|
||||||
|
<p className="text-lg">Nilai Maturitas</p>
|
||||||
|
<span className="text-xl">{totalVerifiedScore}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row h-16 font-semibold justify-between items-center gap-2 pl-8 pr-6 text-white -ml-6" style={getScoreStyleClass(Number(totalVerifiedScore), true)}>
|
||||||
|
<p className="text-lg">Level Maturitas</p>
|
||||||
|
<span className="ml-2 text-xl">{getScoreStyleClass(Number(totalVerifiedScore), true).descLevel}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="flex flex-col w-screen h-fit border-none shadow-none -mt-6 lg:-mr-6 lg:-ml-0 -mx-3 lg:bg-stone-50 md:bg-white overflow-hidden">
|
||||||
|
<div className="flex flex-col w-full h-fit mb-4 justify-center items-start bg-white p-4 border-b">
|
||||||
|
{/* Konten Header */}
|
||||||
|
<div className="flex flex-col lg:flex-row justify-between items-center w-full">
|
||||||
|
{isSuperAdmin ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
className="flex items-center text-sm text-blue-600 gap-2 mb-2" id="no-print"
|
||||||
|
onClick={() => window.close()}
|
||||||
|
>
|
||||||
|
<TbChevronLeft size={20} className="mr-1" />
|
||||||
|
Kembali
|
||||||
|
</button>
|
||||||
|
<p className="text-md lg:text-2xl font-bold">Detail Hasil Asesmen</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-md lg:text-2xl font-bold">Dasboard Hasil Tingkat Kematangan Keamanan Cyber</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Kelola dan Pantau Semua Permohonan Asesmen Dengan Mudah</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-row gap-2 mt-4 lg:mt-0" id="button-print">
|
||||||
|
<div className="flex">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="text-black flex w-44 p-2 pl-4 rounded-lg text-sm items-start justify-between border outline-none ring-offset-0"
|
||||||
|
onClick={handleDropdownToggle}
|
||||||
|
>
|
||||||
|
{selectedItem}
|
||||||
|
{isOpen ? (
|
||||||
|
<TbChevronUp size={20} className="justify-center items-center" />
|
||||||
|
) : (
|
||||||
|
<TbChevronDown size={20} className="justify-center items-center" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
{isOpen && (
|
||||||
|
<DropdownMenuContent className="bg-white text-black flex w-44 rounded-sm text-xs lg:text-sm items-start">
|
||||||
|
<DropdownMenuItem className="w-full" onClick={handleItemClick}>
|
||||||
|
{selectedItem === 'Hasil Asesmen' ? 'Hasil Terverifikasi' : 'Hasil Asesmen'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePrintPDF()}
|
||||||
|
className="bg-blue-600 text-white flex w-fit py-2 px-4 rounded-lg text-xs lg:text-sm items-start justify-between outline-none ring-offset-0"
|
||||||
|
>
|
||||||
|
Cetak PDF
|
||||||
|
<TbFileTypePdf size={20} className="ml-2" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSuperAdmin &&
|
||||||
|
<Card className="flex flex-col h-full mb-6 justify-center items-start mx-4">
|
||||||
|
<div className="flex lg:flex-row flex-col text-xs h-full w-full justify-between p-6 gap-4 lg:gap-0">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Nama Responden</p>
|
||||||
|
<p>{assessmentResult?.respondentName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Posisi</p>
|
||||||
|
<p>{assessmentResult?.position}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Nama Pengguna</p>
|
||||||
|
<p>{assessmentResult?.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Nama Perusahaan</p>
|
||||||
|
<p>{assessmentResult?.companyName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Pengalaman Kerja</p>
|
||||||
|
<p>{assessmentResult?.workExperience}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Email</p>
|
||||||
|
<p>{assessmentResult?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">No. HP</p>
|
||||||
|
<p>{assessmentResult?.phoneNumber}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Alamat</p>
|
||||||
|
<p>{assessmentResult?.address}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Tanggal Asesmen</p>
|
||||||
|
<p>
|
||||||
|
{assessmentResult?.assessmentDate ? (
|
||||||
|
new Intl.DateTimeFormat("id-ID", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
.format(new Date(assessmentResult.assessmentDate))
|
||||||
|
.replace(/\./g, ':')
|
||||||
|
.replace('pukul ', '')
|
||||||
|
) : (
|
||||||
|
'N/A'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">Status Asesmen</p>
|
||||||
|
<p>{assessmentResult?.statusAssessment}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Conditional rendering based on selectedItem */}
|
||||||
|
{selectedItem === 'Hasil Asesmen' ? (
|
||||||
|
<>
|
||||||
|
{/* Score Table */}
|
||||||
|
<Card className="flex flex-col h-fit my-2 mb-6 overflow-hidden lg:mx-4">
|
||||||
|
<p className="text-sm lg:text-lg font-bold p-2 border-b">Tabel Nilai Hasil Asesmen</p>
|
||||||
|
<table className="w-full table-fixed border-collapse border rounded-lg overflow-hidden">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{aspectsData?.data?.map((aspect) => (
|
||||||
|
<th
|
||||||
|
key={aspect.id}
|
||||||
|
colSpan={2}
|
||||||
|
className="text-start font-normal bg-white border border-gray-200 p-2 lg:p-4 w-1/5 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<p className="text-[0.6rem] lg:text-sm text-black">{aspect.name}</p>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"text-sm lg:text-3xl font-bold",
|
||||||
|
)}
|
||||||
|
style={getScoreStyleClass(getAspectScore(aspect.id))}
|
||||||
|
>
|
||||||
|
{formatScore(getAspectScore(aspect.id))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{aspectsData && Array.from({ length: Math.max(...aspectsData.data.map(aspect => aspect.subAspects.length)) }).map((_, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className={rowIndex % 2 === 0 ? "bg-slate-100" : "bg-white"}>
|
||||||
|
{aspectsData?.data?.map((aspect) => (
|
||||||
|
<React.Fragment key={aspect.id}>
|
||||||
|
{/* Sub-aspect Name Column (No Right Border) */}
|
||||||
|
<td className="text-[0.33rem] md:text-[0.5rem] lg:text-xs text-black p-1 py-0 lg:py-2 border-t border-l border-b border-gray-200 w-full text-left">
|
||||||
|
{aspect.subAspects[rowIndex]?.name || ""}
|
||||||
|
</td>
|
||||||
|
{/* Sub-aspect Score Column (No Left Border and w-fit for flexible width) */}
|
||||||
|
<td className="text-[0.4rem] lg:text-sm font-bold text-right p-1 lg:p-2 border-t border-b border-r border-gray-200 w-fit">
|
||||||
|
{aspect.subAspects[rowIndex] ? formatScore(getSubAspectScore(aspect.subAspects[rowIndex].id)) : ""}
|
||||||
|
</td>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Verified Result Table */}
|
||||||
|
<Card className="flex flex-col h-fit my-2 mb-6 overflow-hidden border-y lg:mx-4">
|
||||||
|
<p className="text-sm lg:text-lg font-bold p-2 border-b">Tabel Nilai Hasil Asesmen Terverifikasi</p>
|
||||||
|
<table className="w-full table-fixed border-collapse border rounded-lg overflow-hidden">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{aspectsData?.data?.map((aspect) => (
|
||||||
|
<th
|
||||||
|
key={aspect.id}
|
||||||
|
colSpan={2}
|
||||||
|
className="text-start font-normal bg-white border border-gray-200 p-2 lg:p-4 w-1/5 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<p className="text-[0.6rem] lg:text-sm text-black">{aspect.name}</p>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"text-sm lg:text-3xl font-bold",
|
||||||
|
)}
|
||||||
|
style={getScoreStyleClass(getVerifiedAspectScore(aspect.id, assessmentStatus ?? ''))}
|
||||||
|
>
|
||||||
|
{formatScore(getVerifiedAspectScore(aspect.id, assessmentStatus ?? ''))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{aspectsData && Array.from({ length: Math.max(...aspectsData.data.map(aspect => aspect.subAspects.length)) }).map((_, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className={rowIndex % 2 === 0 ? "bg-slate-100" : "bg-white"}>
|
||||||
|
{aspectsData?.data?.map((aspect) => (
|
||||||
|
<React.Fragment key={aspect.id}>
|
||||||
|
{/* Sub-aspect Name Column (No Right Border) */}
|
||||||
|
<td className="text-[0.33rem] md:text-[0.5rem] lg:text-xs text-black p-1 py-0 lg:py-2 border-t border-l border-b border-gray-200 w-full text-left">
|
||||||
|
{aspect.subAspects[rowIndex]?.name || ""}
|
||||||
|
</td>
|
||||||
|
{/* Sub-aspect Score Column (No Left Border and w-fit for flexible width) */}
|
||||||
|
<td className="text-[0.4rem] lg:text-sm font-bold text-right p-1 lg:p-2 border-t border-b border-r border-gray-200 w-fit">
|
||||||
|
{aspect.subAspects[rowIndex] ? formatScore(getVerifiedSubAspectScore(aspect.subAspects[rowIndex].id, assessmentStatus ?? '')) : ""}
|
||||||
|
</td>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="flex flex-col lg:flex-row gap-4 border-none shadow-none lg:mx-4 bg-transparent mb-4">
|
||||||
|
{/* Bar Chart */}
|
||||||
|
{selectedItem === 'Hasil Asesmen' ? (
|
||||||
|
<>
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="items-start">
|
||||||
|
<CardTitle className="text-sm lg:text-lg">Diagram Nilai Hasil Asesmen</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<BarChartComponent barChartData={sortedBarChartData} barChartConfig={barChartConfig} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardHeader className="items-start">
|
||||||
|
<CardTitle className="text-sm lg:text-lg">Diagram Nilai Hasil Asesmen Terverifikasi</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<BarChartComponent barChartData={sortedVerifiedBarChartData} barChartConfig={barChartConfig} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pie Chart */}
|
||||||
|
{selectedItem === 'Hasil Asesmen' ? (
|
||||||
|
<Card className="flex flex-col w-full lg:w-64">
|
||||||
|
<CardContent>
|
||||||
|
<PieChartComponent
|
||||||
|
chartData={chartData}
|
||||||
|
totalScore={totalScore}
|
||||||
|
chartConfig={chartConfig}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="flex flex-col w-full lg:w-64">
|
||||||
|
<CardContent>
|
||||||
|
<PieChartComponent
|
||||||
|
chartData={verifiedChartData}
|
||||||
|
totalScore={totalVerifiedScore}
|
||||||
|
chartConfig={chartConfig}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Card>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { getAllAspectsAverageScore } from '@/modules/assessmentResult/queries/assessmentResultQueries';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const searchParamSchema = z.object({
|
||||||
|
detail: z.string().default("").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_dashboardLayout/assessmentResult/")({
|
||||||
|
validateSearch: searchParamSchema,
|
||||||
|
|
||||||
|
loader: ({ context: { queryClient } }) => {
|
||||||
|
queryClient.ensureQueryData(getAllAspectsAverageScore("0"));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import PageTemplate from "@/components/PageTemplate";
|
||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
|
||||||
|
import { createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import { TbEye } from "react-icons/tb";
|
||||||
|
import { assessmentResultsQueryOptions, postAnswerRevisionMutation, postAnswerRevisionQueryOptions } from "@/modules/assessmentResultsManagement/queries/assessmentResultsManagaementQueries";
|
||||||
|
import { Button } from "@/shadcn/components/ui/button";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import FormResponseError from "@/errors/FormResponseError";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { Badge } from "@/shadcn/components/ui/badge";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute('/_dashboardLayout/assessmentResultsManagement/')({
|
||||||
|
component: assessmentResultsManagementPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
type DataType = ExtractQueryDataType<typeof assessmentResultsQueryOptions>;
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<DataType>();
|
||||||
|
|
||||||
|
const handleViewResult = (assessmentId: string) => {
|
||||||
|
// to make sure assessmentId is valid and not null
|
||||||
|
if (!assessmentId) {
|
||||||
|
console.error("Assessment ID is missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resultUrl = `/assessmentResult?id=${assessmentId}`;
|
||||||
|
window.open(resultUrl, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function assessmentResultsManagementPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const revisedBy = user ? user.name : '';
|
||||||
|
|
||||||
|
// Use the mutation defined in the queries file
|
||||||
|
const mutation = postAnswerRevisionMutation();
|
||||||
|
|
||||||
|
const verifyAssessment = (assessmentId: string) => {
|
||||||
|
if (!assessmentId) {
|
||||||
|
console.error("Assessment ID is missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Call the mutation to post the answer revision
|
||||||
|
mutation.mutate({ assessmentId, revisedBy });
|
||||||
|
|
||||||
|
const resultUrl = `/verifying?id=${assessmentId}`;
|
||||||
|
window.open(resultUrl, "_blank");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageTemplate
|
||||||
|
title="Manajemen Hasil Asesmen"
|
||||||
|
queryOptions={assessmentResultsQueryOptions}
|
||||||
|
// modals={[assessmentResultsFormModal()]}
|
||||||
|
createButton={false}
|
||||||
|
columnDefs={[
|
||||||
|
columnHelper.display({
|
||||||
|
header: "#",
|
||||||
|
cell: (props) => props.row.index + 1,
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Nama Responden",
|
||||||
|
cell: (props) => props.row.original.respondentName,
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Nama Perusahaan",
|
||||||
|
cell: (props) => props.row.original.companyName,
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
id: "statusAssessments",
|
||||||
|
header: () => <div className="text-center">Status Verifikasi</div>,
|
||||||
|
cell: (props) => {
|
||||||
|
const status = props.row.original.statusAssessments;
|
||||||
|
switch (status) {
|
||||||
|
case "belum diverifikasi":
|
||||||
|
return <div className="flex items-center justify-center text-center">
|
||||||
|
<Badge variant={"unverified"}>Belum Diverifikasi</Badge>
|
||||||
|
</div>;
|
||||||
|
case "selesai":
|
||||||
|
return <div className="flex items-center justify-center text-center">
|
||||||
|
<Badge variant={"completed"}>Selesai</Badge>
|
||||||
|
</div>;
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Hasil Asesmen",
|
||||||
|
cell: (props) => props.row.original.assessmentsResult,
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
header: " ",
|
||||||
|
cell: (props) => (
|
||||||
|
<div className="flex flex-row w-fit items-center rounded gap-2">
|
||||||
|
{props.row.original.statusAssessments === 'belum diverifikasi' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-fit items-center bg-blue-600 hover:bg-blue-900"
|
||||||
|
onClick={() => verifyAssessment(props.row.original.id ?? '')}
|
||||||
|
>
|
||||||
|
<span className="text-white">Verifikasi</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
columnHelper.display({
|
||||||
|
header: "Aksi",
|
||||||
|
cell: (props) => (
|
||||||
|
<div className="flex flex-row w-fit items-center rounded gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-fit items-center hover:bg-gray-300 border"
|
||||||
|
onClick={() => handleViewResult(props.row.original.id ?? '')}
|
||||||
|
>
|
||||||
|
<TbEye className="text-black" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user