diff --git a/apps/backend/src/data/permissions.ts b/apps/backend/src/data/permissions.ts index d0a7c76..b85fe2b 100644 --- a/apps/backend/src/data/permissions.ts +++ b/apps/backend/src/data/permissions.ts @@ -47,6 +47,15 @@ const permissionsData = [ { code: "questions.restore", }, + { + code :"assessmentRequestManagement.readAll", + }, + { + code: "assessmentRequestManagement.update", + }, + { + code :"assessmentRequestManagement.read", + }, { code: "managementAspect.readAll", }, @@ -104,6 +113,18 @@ const permissionsData = [ { code: "assessments.updateAnswer", }, + { + code: "assessments.readAverageSubAspect", + }, + { + code: "assessments.readAverageAllSubAspects", + }, + { + code: "assessments.readAverageAspect", + }, + { + code: "assessments.readAverageAllAspects", + }, ] as const; export type SpecificPermissionCode = (typeof permissionsData)[number]["code"]; diff --git a/apps/backend/src/data/sidebarMenus.ts b/apps/backend/src/data/sidebarMenus.ts index ec98d7e..85d98d6 100644 --- a/apps/backend/src/data/sidebarMenus.ts +++ b/apps/backend/src/data/sidebarMenus.ts @@ -8,7 +8,7 @@ const sidebarMenus: SidebarMenu[] = [ link: "/dashboard", }, { - label: "Users", + label: "Manajemen Pengguna", icon: { tb: "TbUsers" }, allowedPermissions: ["permissions.read"], link: "/users", diff --git a/apps/backend/src/drizzle/schema/assessments.ts b/apps/backend/src/drizzle/schema/assessments.ts index 75b9ccd..8507c1a 100644 --- a/apps/backend/src/drizzle/schema/assessments.ts +++ b/apps/backend/src/drizzle/schema/assessments.ts @@ -4,7 +4,7 @@ import { relations } from "drizzle-orm"; import { respondents } from "./respondents"; import { users } from "./users"; -const statusEnum = pgEnum("status", ["menunggu konfirmasi", "disetujui", "ditolak", "selesai"]); +const statusEnum = pgEnum("status", ["menunggu konfirmasi", "diterima", "ditolak", "selesai"]); export const assessments = pgTable("assessments", { id: varchar("id", { length: 50 }) @@ -16,7 +16,9 @@ export const assessments = pgTable("assessments", { reviewedAt: timestamp("reviewedAt", { mode: "date" }), validatedBy: varchar("validatedBy"), validatedAt: timestamp("validatedAt", { mode: "date" }), - createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), + createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), + + }); // Query Tools in PosgreSQL -// CREATE TYPE status AS ENUM ('menunggu konfirmasi', 'disetujui', 'ditolak', 'selesai'); \ No newline at end of file +// CREATE TYPE status AS ENUM ('menunggu konfirmasi', 'diterima', 'ditolak', 'selesai'); \ No newline at end of file diff --git a/apps/backend/src/drizzle/schema/respondents.ts b/apps/backend/src/drizzle/schema/respondents.ts index 744ec4f..98d8805 100644 --- a/apps/backend/src/drizzle/schema/respondents.ts +++ b/apps/backend/src/drizzle/schema/respondents.ts @@ -15,7 +15,7 @@ export const respondents = pgTable("respondents", { phoneNumber: varchar("phoneNumber", { length: 13 }).notNull(), createdAt: timestamp("createdAt", { mode: "date" }).defaultNow(), updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow(), - deletedAt: timestamp("deletetAt", { mode: "date" }), + deletedAt: timestamp("deletedAt", { mode: "date" }), }); export const respondentsRelations = relations(respondents, ({ one }) => ({ diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 7133f40..820fe77 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -22,6 +22,7 @@ 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(); @@ -92,11 +93,13 @@ const routes = app .route("/assessmentRequest", assessmentRequestRoute) .route("/forgot-password", forgotPasswordRoutes) .route("/assessments", assessmentsRoute) + .route("/assessmentRequestManagement",assessmentsRequestManagementRoutes) .onError((err, c) => { if (err instanceof DashboardError) { return c.json( { message: err.message, + errorCode: err.errorCode, formErrors: err.formErrors, }, diff --git a/apps/backend/src/routes/assessmentRequestManagement/route.ts b/apps/backend/src/routes/assessmentRequestManagement/route.ts new file mode 100644 index 0000000..802c702 --- /dev/null +++ b/apps/backend/src/routes/assessmentRequestManagement/route.ts @@ -0,0 +1,173 @@ + import { and, eq, ilike, or, sql } from "drizzle-orm"; + import { Hono } from "hono"; + import checkPermission from "../../middlewares/checkPermission"; + import { z } from "zod"; + import { HTTPException } from "hono/http-exception"; + import db from "../../drizzle"; + import { assessments } from "../../drizzle/schema/assessments"; + import { respondents } from "../../drizzle/schema/respondents"; + import { users } from "../../drizzle/schema/users"; + import HonoEnv from "../../types/HonoEnv"; + import requestValidator from "../../utils/requestValidator"; + import authInfo from "../../middlewares/authInfo"; + + export const assessmentFormSchema = z.object({ + respondentId: z.string().min(1), + status: z.enum(["menunggu konfirmasi", "diterima", "ditolak", "selesai"]), + reviewedBy: z.string().min(1), + validatedBy: z.string().min(1), + validatedAt: z.string().optional(), + }); + + export const assessmentUpdateSchema = assessmentFormSchema.extend({ + validatedAt: z.string().optional().or(z.literal("")), + }); + + const assessmentsRequestManagementRoutes = new Hono() + .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 totalCountQuery = sql`(SELECT count(*) FROM ${assessments})`; + + const result = await db + .select({ + idPermohonan: assessments.id, + namaResponden: users.name, + namaPerusahaan: respondents.companyName, + status: assessments.status, + tanggal: assessments.createdAt, + fullCount: totalCountQuery, + }) + .from(assessments) + .leftJoin(respondents, eq(assessments.respondentId, respondents.id)) + .leftJoin(users, eq(respondents.userId, users.id)) + .where( + q + ? or( + ilike(users.name, `%${q}%`), + ilike(respondents.companyName, `%${q}%`), + eq(assessments.id, q) + ) + : undefined + ) + .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( + (Number(result[0]?.fullCount) ?? 0) / limit + ), + totalItems: Number(result[0]?.fullCount) ?? 0, + perPage: limit, + }, + }); + } + ) + + // Get assessment by id + .get( + "/:id", + checkPermission("assessmentRequestManagement.read"), + async (c) => { + const assessmentId = c.req.param("id"); + + const queryResult = await db + .select({ + // id: assessments.id, + 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", "selesai"]), + }) + ), + async (c) => { + const assessmentId = c.req.param("id"); + const { status } = c.req.valid("json"); + + const assessment = await db + .select() + .from(assessments) + .where(and(eq(assessments.id, assessmentId),)); + + if (!assessment[0]) throw new HTTPException(404, { + message: "Assessment tidak ditemukan.", + }); + + await db + .update(assessments) + .set({ + status, + }) + .where(eq(assessments.id, assessmentId)); + + return c.json({ + message: "Status assessment berhasil diperbarui.", + }); + } + ) + + + + + export default assessmentsRequestManagementRoutes; diff --git a/apps/backend/src/routes/assessments/route.ts b/apps/backend/src/routes/assessments/route.ts index 604c903..462fd09 100644 --- a/apps/backend/src/routes/assessments/route.ts +++ b/apps/backend/src/routes/assessments/route.ts @@ -409,7 +409,7 @@ const assessmentsRoute = new Hono() // Get data for One Sub Aspect average score By Sub Aspect Id and Assessment Id .get( '/average-score/sub-aspects/:subAspectId/assessments/:assessmentId', - // checkPermission("assessments.readAssessmentScore"), + checkPermission("assessments.readAverageSubAspect"), async (c) => { const { subAspectId, assessmentId } = c.req.param(); @@ -440,7 +440,7 @@ const assessmentsRoute = new Hono() // Get data for All Sub Aspects average score By Assessment Id .get( '/average-score/sub-aspects/assessments/:assessmentId', - // checkPermission("assessments.readAssessmentScore"), + checkPermission("assessments.readAverageAllSubAspects"), async (c) => { const { assessmentId } = c.req.param(); @@ -472,6 +472,7 @@ const assessmentsRoute = new Hono() // Get data for One Aspect average score By Aspect Id and Assessment Id .get( "/average-score/aspects/:aspectId/assessments/:assessmentId", + checkPermission("assessments.readAverageAspect"), async (c) => { const { aspectId, assessmentId } = c.req.param(); @@ -503,7 +504,7 @@ const assessmentsRoute = new Hono() // Get data for All Aspects average score By Assessment Id .get( '/average-score/aspects/assessments/:assessmentId', - // checkPermission("assessments.readAssessmentScore"), + checkPermission("assessments.readAverageAllAspects"), async (c) => { const { assessmentId } = c.req.param(); diff --git a/apps/backend/src/routes/managementAspect/route.ts b/apps/backend/src/routes/managementAspect/route.ts index da8a140..ebf11fd 100644 --- a/apps/backend/src/routes/managementAspect/route.ts +++ b/apps/backend/src/routes/managementAspect/route.ts @@ -1,6 +1,6 @@ -import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; +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"; @@ -33,14 +33,16 @@ export const aspectFormSchema = z.object({ .optional(), }); -export const aspectUpdateSchema = aspectFormSchema.extend({ - subAspects: z.string().optional().or(z.literal("")), -}); - // Schema for creating and updating subAspects export const subAspectFormSchema = z.object({ + id: z.string(), name: z.string().min(1).max(50), - aspectId: z.string().uuid(), + aspectId: z.string().optional(), +}); + +export const aspectUpdateSchema = z.object({ + name: z.string(), + subAspects: z.array(subAspectFormSchema), }); export const subAspectUpdateSchema = subAspectFormSchema.extend({}); @@ -55,140 +57,223 @@ const managementAspectRoute = new Hono() * - 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(""), + // 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 totalCountQuery = includeTrashed + ? sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})` + : sql`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; + + const aspectIdsQuery = await db + .select({ + id: aspects.id, }) - ), - async (c) => { - const { includeTrashed, page, limit, q } = c.req.valid("query"); - - const totalCountQuery = includeTrashed - ? sql`(SELECT count(*) FROM ${aspects})` - : sql`(SELECT count(*) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; - - const result = await db - .select({ - id: aspects.id, - name: aspects.name, - createdAt: aspects.createdAt, - updatedAt: aspects.updatedAt, - ...(includeTrashed ? { deletedAt: aspects.deletedAt } : {}), - fullCount: totalCountQuery, - }) - .from(aspects) - .where( - and( - includeTrashed ? undefined : isNull(aspects.deletedAt), - q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined - ) + .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); - + ) + .offset(page * limit) + .limit(limit); + + const aspectIds = aspectIdsQuery.map(a => a.id); + + if (aspectIds.length === 0) { return c.json({ - data: result.map((d) => ({ ...d, fullCount: undefined })), + data: [], _metadata: { currentPage: page, - totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit), - totalItems: Number(result[0]?.fullCount) ?? 0, + totalPages: 0, + totalItems: 0, perPage: limit, }, }); } - ) - - // Get aspect by id - .get( - "/:id", - checkPermission("managementAspect.readAll"), - requestValidator( - "query", - z.object({ - includeTrashed: z.string().default("false"), + + // 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`( + SELECT count(*) + FROM ${questions} + WHERE ${questions.subAspectId} = ${subAspects.id} + )`.as('questionCount'), + fullCount: totalCountQuery, }) - ), - async (c) => { - const aspectId = c.req.param("id"); + .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); + + 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, + }, + }); + } + ) - if (!aspectId) - throw notFound({ - message: "Missing id", - }); + // 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"); - const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true"; + if (!aspectId) + throw notFound({ + message: "Missing id", + }); - 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, - }, - }) - .from(aspects) - .leftJoin(subAspects, eq(aspects.id, subAspects.aspectId)) - .where(and(eq(aspects.id, aspectId), !includeTrashed ? isNull(aspects.deletedAt) : undefined)); + const includeTrashed = c.req.query("includeTrashed")?.toLowerCase() === "true"; - if (!queryResult.length) - throw forbidden({ - message: "The aspect does not exist", - }); + 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); - const subAspectsList = queryResult.reduce((prev, curr) => { - if (!curr.subAspect) return prev; - prev.set(curr.subAspect.id, curr.subAspect.name); - return prev; - }, new Map()); // Map + if (!queryResult.length) + throw notFound({ + message: "The aspect does not exist", + }); - const aspectData = { - ...queryResult[0], - subAspect: undefined, - subAspects: Array.from(subAspectsList, ([id, name]) => ({ id, name })), - }; + 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()); - return c.json(aspectData); - } - ) + const aspectData = { + ...queryResult[0], + subAspect: undefined, + subAspects: Array.from(subAspectsList, ([id, { name, questionCount }]) => ({ id, name, questionCount })), + }; - // Create aspect - .post("/", - checkPermission("managementAspect.create"), - requestValidator("json", aspectFormSchema), - async (c) => { - const aspectData = c.req.valid("json"); + return c.json(aspectData); + } + ) - // Validation to check if the aspect name already exists + // 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(eq(aspects.name, aspectData.name)); + .where( + and( + eq(aspects.name, aspectData.name), + isNull(aspects.deletedAt) + ) + ); if (existingAspect.length > 0) { - throw forbidden({ - message: "Aspect name already exists", - }); + // 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({ @@ -196,56 +281,60 @@ const managementAspectRoute = new Hono() }) .returning(); - // if sub-aspects are available, parse them into a string array + 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[]; - // if there are sub-aspects, insert them into the database + + // Insert new sub aspects into the database without checking for sub aspect duplication if (subAspectsArray.length) { await db.insert(subAspects).values( subAspectsArray.map((subAspect) => ({ - aspectId: aspect[0].id, + aspectId, name: subAspect, })) ); } } - return c.json( - { - message: "Aspect created successfully", - }, - 201 - ); - } - ) + 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"); + // Update aspect + .patch( + "/:id", + checkPermission("managementAspect.update"), + requestValidator("json", aspectUpdateSchema), + async (c) => { + const aspectId = c.req.param("id"); + const aspectData = c.req.valid("json"); - // Validation to check if the new aspect name already exists + // 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}` - ) - ); + eq(aspects.name, aspectData.name), + isNull(aspects.deletedAt), + sql`${aspects.id} <> ${aspectId}` + ) + ); if (existingAspect.length > 0) { - throw forbidden({ + throw notFound({ message: "Aspect name already exists", }); } + // Check if the aspect in question exists const aspect = await db .select() .from(aspects) @@ -253,240 +342,153 @@ const managementAspectRoute = new Hono() if (!aspect[0]) throw notFound(); + // Update aspect name await db .update(aspects) .set({ - ...aspectData, + name: aspectData.name, updatedAt: new Date(), }) .where(eq(aspects.id, aspectId)); - //Update for Sub-Aspects - // if (aspectData.subAspects) { - // const subAspectsArray = JSON.parse(aspectData.subAspects) as string[]; + // Get new sub aspect data from request + const newSubAspects = aspectData.subAspects || []; - // await db.delete(subAspects).where(eq(subAspects.aspectId, aspectId)); + // Take the existing sub aspects + const currentSubAspects = await db + .select({ id: subAspects.id, name: subAspects.name }) + .from(subAspects) + .where(eq(subAspects.aspectId, aspectId)); - // if (subAspectsArray.length) { - // await db.insert(subAspects).values( - // subAspectsArray.map((subAspect) => ({ - // aspectId: aspectId, - // name: subAspect, - // })) - // ); - // } - // } + const currentSubAspectMap = new Map(currentSubAspects.map(sub => [sub.id, sub.name])); - return c.json({ - message: "Aspect updated successfully", - }); - } - ) - - // Delete aspect - .delete( - "/:id", - checkPermission("managementAspect.delete"), - requestValidator( - "form", - z.object({ - // skipTrash: z.string().default("false"), - }) - ), - async (c) => { - const aspectId = c.req.param("id"); - - // const skipTrash = c.req.valid("form").skipTrash.toLowerCase() === "true"; - - 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", - }); + // 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 - .update(aspects) + .delete(subAspects) + .where( + and( + eq(subAspects.aspectId, aspectId), + inArray(subAspects.id, subAspectsToDelete) + ) + ); + } + + // Update or add new sub aspects + for (const subAspect of newSubAspects) { + const existingSubAspect = currentSubAspectMap.has(subAspect.id); + + if (existingSubAspect) { + // Update if sub aspect already exists + await db + .update(subAspects) .set({ - deletedAt: new Date(), + name: subAspect.name, + updatedAt: new Date(), }) - .where(eq(aspects.id, aspectId)); - - return c.json({ - message: "Aspect deleted successfully", - }); - } - ) - - // Undo delete - .patch( - "/restore/:id", - checkPermission("managementAspect.restore"), - async (c) => { - const aspectId = c.req.param("id"); - - const aspect = (await db.select().from(aspects).where(eq(aspects.id, aspectId)))[0]; - - if (!aspect) throw notFound(); - - if (!aspect.deletedAt) { - throw forbidden({ - message: "The aspect is not deleted", - }); + .where( + and( + eq(subAspects.id, subAspect.id), + eq(subAspects.aspectId, aspectId) + ) + ); + } else { + // Add if new sub aspect + await db + .insert(subAspects) + .values({ + id: subAspect.id, + aspectId, + name: subAspect.name, + createdAt: new Date(), + }); } + } - await db.update(aspects).set({ deletedAt: null }).where(eq(aspects.id, aspectId)); + return c.json({ + message: "Aspect and sub aspects updated successfully", + }); + } + ) - return c.json({ - message: "Aspect restored 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", }); } - ) - // Get sub aspects by aspect ID - .get( - "/subAspects/:aspectId", - checkPermission("managementAspect.readAll"), - async (c) => { - const aspectId = c.req.param("aspectId"); - - 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", - }); - - const subAspectsData = await db - .select() - .from(subAspects) - .where(eq(subAspects.aspectId, aspectId)); - - return c.json({ - subAspects: subAspectsData, + // Make sure the aspect has been deleted (there is deletedAt) + if (!aspect.deletedAt) { + throw notFound({ + message: "The aspect is not deleted", }); } - ) - // Create sub aspect - .post( - "/subAspect", - checkPermission("managementAspect.create"), - requestValidator("json", subAspectFormSchema), - async (c) => { - const subAspectData = c.req.valid("json"); + // Restore aspects (remove deletedAt mark) + await db.update(aspects).set({ deletedAt: null }).where(eq(aspects.id, aspectId)); - // Validation to check if the sub aspect name already exists - const existingSubAspect = await db - .select() - .from(subAspects) - .where( - and( - eq(subAspects.name, subAspectData.name), - eq(subAspects.aspectId, subAspectData.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)); - if (existingSubAspect.length > 0) { - throw forbidden({ message: "Nama Sub Aspek sudah tersedia!" }); - } + return c.json({ + message: "Aspect and sub aspects restored successfully", + }); + } + ) - const [aspect] = await db - .select() - .from(aspects) - .where( - and( - eq(aspects.id, subAspectData.aspectId), - isNull(aspects.deletedAt))); - - if (!aspect) - throw forbidden({ - message: "The aspect is not found", - }); - - await db.insert(subAspects).values(subAspectData); - - return c.json( - { - message: "Sub aspect created successfully", - }, - 201 - ); - } - ) - - // Update sub aspect - .patch( - "/subAspect/:id", checkPermission("managementAspect.update"), - requestValidator("json", subAspectUpdateSchema), - async (c) => { - const subAspectId = c.req.param("id"); - const subAspectData = c.req.valid("json"); - - // Validation to check if the new sub aspect name already exists - const existingSubAspect = await db - .select() - .from(subAspects) - .where( - eq(subAspects.aspectId, subAspectData.aspectId)); - - if (existingSubAspect.length > 0) { - throw forbidden({ message: "Name Sub Aspect already exists" }); - } - - if (!existingSubAspect[0]) - throw notFound({ - message: "The sub aspect is not found", - }); - - await db - .update(subAspects) - .set({ - ...subAspectData, - updatedAt: new Date(), - }) - .where(eq(subAspects.id, subAspectId)); - - return c.json({ - message: "Sub aspect updated successfully", - }); - }) - - // Delete sub aspect - .delete( - "/subAspect/:id", - checkPermission("managementAspect.delete"), - async (c) => { - const subAspectId = c.req.param("id"); - - const subAspect = await db - .select() - .from(subAspects) - .where(eq(subAspects.id, subAspectId)); - - if (!subAspect[0]) - throw notFound({ - message: "The sub aspect is not found", - }); - - await db - .update(subAspects) - .set({ - deletedAt: new Date(), - }) - .where(eq(subAspects.id, subAspectId)); - // await db.delete(subAspects).where(eq(subAspects.id, subAspectId)); - - return c.json({ - message: "Sub aspect deleted successfully", - }); - } - ); - -export default managementAspectRoute; +export default managementAspectRoute; \ No newline at end of file diff --git a/apps/backend/src/routes/users/route.ts b/apps/backend/src/routes/users/route.ts index 3069960..9f2e289 100644 --- a/apps/backend/src/routes/users/route.ts +++ b/apps/backend/src/routes/users/route.ts @@ -1,4 +1,4 @@ -import { and, eq, ilike, isNull, or, sql } from "drizzle-orm"; +import { and, eq, ilike, isNull, or, sql, not, inArray } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; @@ -12,30 +12,21 @@ import HonoEnv from "../../types/HonoEnv"; import requestValidator from "../../utils/requestValidator"; import authInfo from "../../middlewares/authInfo"; import checkPermission from "../../middlewares/checkPermission"; +import { respondents } from "../../drizzle/schema/respondents"; +import { forbidden, notFound } from "../../errors/DashboardError"; export const userFormSchema = z.object({ - name: z.string().min(1).max(255), - username: z.string().min(1).max(255), - email: z.string().email().optional().or(z.literal("")), - password: z.string().min(6), + name: z.string().min(1, "Name is required").max(255), + username: z.string().min(1, "Username is required").max(255), + email: z.string().min(1, "Email is required").email().optional().or(z.literal("")), + password: z.string().min(6, "Password is required"), + companyName: z.string().min(1, "Company name is required").max(255), + position: z.string().min(1, "Position is required").max(255), + workExperience: z.string().min(1, "Work experience is required").max(255), + address: z.string().min(1, "Address is required"), + phoneNumber: z.string().min(1, "Phone number is required").max(13), isEnabled: z.string().default("false"), - roles: z - .string() - .refine( - (data) => { - console.log(data); - try { - const parsed = JSON.parse(data); - return Array.isArray(parsed); - } catch { - return false; - } - }, - { - message: "Roles must be an array", - } - ) - .optional(), + roles: z.array(z.string().min(1, "Role is required")), }); export const userUpdateSchema = userFormSchema.extend({ @@ -50,75 +41,169 @@ const usersRoute = new Hono() * Query params: * - includeTrashed: boolean (default: false)\ * - withMetadata: boolean - */ + */ + + // Get all users with search .get( "/", checkPermission("users.readAll"), requestValidator( - "query", - z.object({ - includeTrashed: z - .string() - .optional() - .transform((v) => v?.toLowerCase() === "true"), - withMetadata: z - .string() - .optional() - .transform((v) => v?.toLowerCase() === "true"), - page: z.coerce.number().int().min(0).default(0), - limit: z.coerce.number().int().min(1).max(1000).default(1), - q: z.string().default(""), - }) + "query", + z.object({ + includeTrashed: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + withMetadata: z + .string() + .optional() + .transform((v) => v?.toLowerCase() === "true"), + page: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(1000).default(10), + q: z.string().default(""), // Keyword search + }) ), async (c) => { - const { includeTrashed, page, limit, q } = c.req.valid("query"); - - const totalCountQuery = includeTrashed - ? sql`(SELECT count(*) FROM ${users})` - : sql`(SELECT count(*) FROM ${users} WHERE ${users.deletedAt} IS NULL)`; - - const result = await db - .select({ - id: users.id, - name: users.name, - email: users.email, - username: users.username, - isEnabled: users.isEnabled, - createdAt: users.createdAt, - updatedAt: users.updatedAt, - ...(includeTrashed ? { deletedAt: users.deletedAt } : {}), - fullCount: totalCountQuery, - }) - .from(users) - .where( - and( - includeTrashed ? undefined : isNull(users.deletedAt), - q - ? or( - ilike(users.name, q), - ilike(users.username, q), - ilike(users.email, q), - eq(users.id, q) - ) - : undefined + const { includeTrashed, page, limit, q } = c.req.valid("query"); + + // Query to count total data without duplicates + const totalCountQuery = db + .select({ + count: sql`count(distinct ${users.id})`, + }) + .from(users) + .leftJoin(respondents, eq(users.id, respondents.userId)) + .leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId)) + .leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id)) + .where( + and( + includeTrashed ? undefined : isNull(users.deletedAt), + q + ? or( + ilike(users.name, `%${q}%`), // Search by name + ilike(users.username, `%${q}%`), // Search by username + ilike(users.email, `%${q}%`), // Search by email + ilike(respondents.companyName, `%${q}%`), // Search by companyName (from respondents) + ilike(rolesSchema.name, `%${q}%`) // Search by role name (from rolesSchema) ) - ) - .offset(page * limit) - .limit(limit); - - 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, - }, - }); + : 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 + .select({ + id: users.id, + name: users.name, + email: users.email, + username: users.username, + isEnabled: users.isEnabled, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + ...(includeTrashed ? { deletedAt: users.deletedAt } : {}), + company: respondents.companyName, + role: { + name: rolesSchema.name, + id: rolesSchema.id, + }, + }) + .from(users) + .leftJoin(respondents, eq(users.id, respondents.userId)) + .leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId)) + .leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id)) + .where(inArray(users.id, userIdsQuery)) // Only take data based on IDs from subquery + .orderBy(users.createdAt); + + // Group roles for each user to avoid duplication + const userMap = new Map< + string, + { + id: string; + name: string; + email: string | null; + username: string; + isEnabled: boolean; + createdAt: Date; + updatedAt: Date; + deletedAt?: Date; + company: string | null; + roles: { id: string; name: string }[]; + } + >(); + + result.forEach((item) => { + if (!userMap.has(item.id)) { + userMap.set(item.id, { + id: item.id, + name: item.name, + email: item.email ?? null, + username: item.username, + isEnabled: item.isEnabled ?? false, + createdAt: item.createdAt ?? new Date(), + updatedAt: item.updatedAt ?? new Date(), + deletedAt: item.deletedAt ?? undefined, + company: item.company, + roles: item.role + ? [{ id: item.role.id, name: item.role.name }] + : [], + }); + } else { + const existingUser = userMap.get(item.id); + if (item.role) { + existingUser?.roles.push({ + id: item.role.id, + name: item.role.name, + }); + } + } + }); + + // Return user data without duplicates, with roles array + const groupedData = Array.from(userMap.values()); + + return c.json({ + data: groupedData, + _metadata: { + currentPage: page, + totalPages: Math.ceil(totalCount / limit), + totalItems: totalCount, + perPage: limit, + }, + }); } - ) + ) + //get user by id .get( "/:id", @@ -139,7 +224,12 @@ const usersRoute = new Hono() .select({ id: users.id, name: users.name, + position: respondents.position, + workExperience: respondents.workExperience, email: users.email, + companyName: respondents.companyName, + address: respondents.address, + phoneNumber: respondents.phoneNumber, username: users.username, isEnabled: users.isEnabled, createdAt: users.createdAt, @@ -151,6 +241,7 @@ const usersRoute = new Hono() }, }) .from(users) + .leftJoin(respondents, eq(users.id, respondents.userId)) .leftJoin(rolesToUsers, eq(users.id, rolesToUsers.userId)) .leftJoin(rolesSchema, eq(rolesToUsers.roleId, rolesSchema.id)) .where( @@ -161,9 +252,9 @@ const usersRoute = new Hono() ); if (!queryResult.length) - throw new HTTPException(404, { - message: "The user does not exists", - }); + throw notFound({ + message : "The user does not exists", + }) const roles = queryResult.reduce((prev, curr) => { if (!curr.role) return prev; @@ -180,38 +271,121 @@ const usersRoute = new Hono() return c.json(userData); } ) + //create user .post( "/", checkPermission("users.create"), - requestValidator("form", userFormSchema), + requestValidator("json", userFormSchema), async (c) => { - const userData = c.req.valid("form"); + const userData = c.req.valid("json"); - const user = await db - .insert(users) - .values({ - name: userData.name, - username: userData.username, - email: userData.email, - password: await hashPassword(userData.password), - isEnabled: userData.isEnabled.toLowerCase() === "true", - }) - .returning(); - - if (userData.roles) { - const roles = JSON.parse(userData.roles) as string[]; - console.log(roles); - - if (roles.length) { - await db.insert(rolesToUsers).values( - roles.map((role) => ({ - userId: user[0].id, - roleId: role, - })) - ); - } + // Check if the provided email or username is already exists in database + const conditions = []; + if (userData.email) { + conditions.push(eq(users.email, userData.email)); } + conditions.push(eq(users.username, userData.username)); + + const existingUser = await db + .select() + .from(users) + .where( + or( + eq(users.email, userData.email), + eq(users.username, userData.username) + ) + ); + + const existingRespondent = await db + .select() + .from(respondents) + .where(eq(respondents.phoneNumber, userData.phoneNumber)); + + if (existingUser.length > 0) { + throw forbidden({ + message: "Email or username has been registered", + }) + } + + if (existingRespondent.length > 0) { + throw forbidden({ + message: "Phone number has been registered", + }) + } + + // Hash the password + const hashedPassword = await hashPassword(userData.password); + + // Start a transaction + const result = await db.transaction(async (trx) => { + // Create user + const [newUser] = await trx + .insert(users) + .values({ + name: userData.name, + username: userData.username, + email: userData.email, + password: hashedPassword, + isEnabled: userData.isEnabled?.toLowerCase() === "true" || true, + }) + .returning() + .catch(() => { + throw forbidden({ + message: "Error creating user", + }) + }); + + // Create respondent + const [newRespondent] = await trx + .insert(respondents) + .values({ + companyName: userData.companyName, + position: userData.position, + workExperience: userData.workExperience, + address: userData.address, + phoneNumber: userData.phoneNumber, + userId: newUser.id, + }) + .returning() + .catch((err) => { + throw new HTTPException(500, { + message: "Error creating respondent: " + err.message, + }); + }); + + // Add other roles if provided + if (userData.roles && userData.roles.length > 0) { + const roles = userData.roles; + + for (let roleId of roles) { + const role = ( + await trx + .select() + .from(rolesSchema) + .where(eq(rolesSchema.id, roleId)) + .limit(1) + )[0]; + + if (role) { + await trx.insert(rolesToUsers).values({ + userId: newUser.id, + roleId: role.id, + }); + } else { + throw new HTTPException(404, { + message: `Role ${roleId} does not exists`, + }); + } + } + } else { + throw forbidden({ + message: "Harap pilih minimal satu role", + }); + } + + return newUser; + }); return c.json( { @@ -226,10 +400,32 @@ const usersRoute = new Hono() .patch( "/:id", checkPermission("users.update"), - requestValidator("form", userUpdateSchema), + requestValidator("json", userUpdateSchema), async (c) => { const userId = c.req.param("id"); - const userData = c.req.valid("form"); + const userData = c.req.valid("json"); + + // Check if the provided email or username is already exists in the database (excluding the current user) + if (userData.email || userData.username) { + const existingUser = await db + .select() + .from(users) + .where( + and( + or( + eq(users.email, userData.email), + eq(users.username, userData.username) + ), + not(eq(users.id, userId)) + ) + ); + + if (existingUser.length > 0) { + throw forbidden({ + message: "Email or username has been registered by another user", + }) + } + } const user = await db .select() @@ -238,18 +434,71 @@ const usersRoute = new Hono() if (!user[0]) return c.notFound(); - await db - .update(users) - .set({ - ...userData, - ...(userData.password - ? { password: await hashPassword(userData.password) } - : {}), - updatedAt: new Date(), - isEnabled: userData.isEnabled.toLowerCase() === "true", - }) - .where(eq(users.id, userId)); + // Start transaction to update both user and respondent + await db.transaction(async (trx) => { + // Update user + await trx + .update(users) + .set({ + ...userData, + ...(userData.password + ? { password: await hashPassword(userData.password) } + : {}), + updatedAt: new Date(), + isEnabled: userData.isEnabled.toLowerCase() === "true", + }) + .where(eq(users.id, userId)); + // Update respondent data if provided + if (userData.companyName || userData.position || userData.workExperience || userData.address || userData.phoneNumber) { + await trx + .update(respondents) + .set({ + ...(userData.companyName ? {companyName: userData.companyName} : {}), + ...(userData.position ? {position: userData.position} : {}), + ...(userData.workExperience ? {workExperience: userData.workExperience} : {}), + ...(userData.address ? {address: userData.address} : {}), + ...(userData.phoneNumber ? {phoneNumber: userData.phoneNumber} : {}), + updatedAt: new Date(), + }) + .where(eq(respondents.userId, userId)); + } + + // Update roles if provided + if (userData.roles && userData.roles.length > 0) { + const roles = userData.roles; + + // Remove existing roles for the user + await trx.delete(rolesToUsers).where(eq(rolesToUsers.userId, userId)); + + // Assign new roles + for (let roleId of roles) { + const role = ( + await trx + .select() + .from(rolesSchema) + .where(eq(rolesSchema.id, roleId)) + .limit(1) + )[0]; + + if (role) { + await trx.insert(rolesToUsers).values({ + userId: userId, + roleId: role.id, + }); + } else { + throw new HTTPException(404, { + message: `Role ${roleId} does not exist`, + }); + } + } + } else { + throw forbidden({ + message: "Harap pilih minimal satu role", + }); + } + }); + return c.json({ message: "User updated successfully", }); @@ -273,6 +522,7 @@ const usersRoute = new Hono() const skipTrash = c.req.valid("form").skipTrash.toLowerCase() === "true"; + // Check if the user exists const user = await db .select() .from(users) @@ -283,17 +533,20 @@ const usersRoute = new Hono() ) ); + // Throw error if the user does not exist if (!user[0]) - throw new HTTPException(404, { + throw notFound ({ message: "The user is not found", }); + // Throw error if the user is trying to delete themselves if (user[0].id === currentUserId) { - throw new HTTPException(400, { + throw forbidden ({ message: "You cannot delete yourself", }); } + // Delete or soft delete user if (skipTrash) { await db.delete(users).where(eq(users.id, userId)); } else { @@ -311,28 +564,34 @@ const usersRoute = new Hono() ) //undo delete - .patch("/restore/:id", checkPermission("users.restore"), async (c) => { - const userId = c.req.param("id"); + .patch( + "/restore/:id", + checkPermission("users.restore"), + async (c) => { + const userId = c.req.param("id"); - const user = ( - await db.select().from(users).where(eq(users.id, userId)) - )[0]; + // Check if the user exists + const user = ( + await db.select().from(users).where(eq(users.id, userId)) + )[0]; - if (!user) return c.notFound(); + if (!user) return c.notFound(); - if (!user.deletedAt) { - throw new HTTPException(400, { - message: "The user is not deleted", + // Throw error if the user is not deleted + if (!user.deletedAt) { + throw forbidden({ + message: "The user is not deleted", + }); + } + + // Restore user + await db + .update(users) + .set({ deletedAt: null }) + .where(eq(users.id, userId)); + + return c.json({ + message: "User restored successfully", }); - } - - await db - .update(users) - .set({ deletedAt: null }) - .where(eq(users.id, userId)); - - return c.json({ - message: "User restored successfully", - }); }); export default usersRoute; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 37d7123..6fd0053 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -16,9 +16,14 @@ "@mantine/form": "^7.10.2", "@mantine/hooks": "^7.10.2", "@mantine/notifications": "^7.10.2", + "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-radio-group": "^1.2.0", + "@radix-ui/react-scroll-area": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.45.0", "@tanstack/react-router": "^1.38.1", diff --git a/apps/frontend/src/assets/logos/amati-logo.png b/apps/frontend/src/assets/logos/amati-logo.png new file mode 100644 index 0000000..bf15f56 Binary files /dev/null and b/apps/frontend/src/assets/logos/amati-logo.png differ diff --git a/apps/frontend/src/components/AppHeader.tsx b/apps/frontend/src/components/AppHeader.tsx index 836b93d..cab58ab 100644 --- a/apps/frontend/src/components/AppHeader.tsx +++ b/apps/frontend/src/components/AppHeader.tsx @@ -1,20 +1,13 @@ import { useState } from "react"; -import { - AppShell, - Avatar, - Burger, - Group, - Menu, - UnstyledButton, - Text, - rem, -} from "@mantine/core"; -import logo from "@/assets/logos/logo.png"; +import logo from "@/assets/logos/amati-logo.png"; import cx from "clsx"; import classNames from "./styles/appHeader.module.css"; -import { TbChevronDown } from "react-icons/tb"; +import { IoMdMenu } from "react-icons/io"; import { Link } from "@tanstack/react-router"; import useAuth from "@/hooks/useAuth"; +import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/components/ui/avatar"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shadcn/components/ui/dropdown-menu"; +import { Button } from "@/shadcn/components/ui/button"; // import getUserMenus from "../actions/getUserMenus"; // import { useAuth } from "@/modules/auth/contexts/AuthContext"; // import UserMenuItem from "./UserMenuItem"; @@ -24,73 +17,73 @@ interface Props { toggle: () => void; } +interface User { + id: string; + name: string; + permissions: string[]; + photoProfile?: string; +} + // const mockUserData = { // name: "Fulan bin Fulanah", // email: "janspoon@fighter.dev", // image: "https://raw.githubusercontent.com/mantinedev/mantine/master/.demo/avatars/avatar-5.png", // }; -export default function AppHeader(props: Props) { +export default function AppHeader({ toggle }: Props) { const [userMenuOpened, setUserMenuOpened] = useState(false); - const { user } = useAuth(); + const { user }: { user: User | null } = useAuth(); // const userMenus = getUserMenus().map((item, i) => ( // // )); return ( - - - - - setUserMenuOpened(true)} - onClose={() => setUserMenuOpened(false)} - withinPortal +
+
+ + + + + + + + - - - Logout - - - {/* {userMenus} */} - -
-
-
+ + + Logout + + + + + ); } diff --git a/apps/frontend/src/components/AppNavbar.tsx b/apps/frontend/src/components/AppNavbar.tsx index d44397c..5e9af2d 100644 --- a/apps/frontend/src/components/AppNavbar.tsx +++ b/apps/frontend/src/components/AppNavbar.tsx @@ -1,7 +1,10 @@ -import { AppShell, ScrollArea } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import client from "../honoClient"; import MenuItem from "./NavbarMenuItem"; +import { useState, useEffect } from "react"; +import { useLocation } from "@tanstack/react-router"; +import { ScrollArea } from "@/shadcn/components/ui/scroll-area"; +import AppHeader from "./AppHeader"; // import MenuItem from "./SidebarMenuItem"; // import { useAuth } from "@/modules/auth/contexts/AuthContext"; @@ -15,30 +18,70 @@ import MenuItem from "./NavbarMenuItem"; export default function AppNavbar() { // const {user} = useAuth(); + const { pathname } = useLocation(); + + const [isSidebarOpen, setSidebarOpen] = useState(true); + const toggleSidebar = () => { + setSidebarOpen(!isSidebarOpen); + }; + const { data } = useQuery({ queryKey: ["sidebarData"], queryFn: async () => { const res = await client.dashboard.getSidebarItems.$get(); if (res.ok) { const data = await res.json(); - return data; } console.error("Error:", res.status, res.statusText); - - //TODO: Handle error properly throw new Error("Error fetching sidebar data"); }, }); + useEffect(() => { + const handleResize = () => { + if (window.innerWidth < 768) { // Ganti 768 dengan breakpoint mobile Anda + setSidebarOpen(false); + } else { + setSidebarOpen(true); + } + }; + + window.addEventListener('resize', handleResize); + handleResize(); // Initial check + + return () => window.removeEventListener('resize', handleResize); + }, []); + + const handleMenuItemClick = () => { + if (window.innerWidth < 768) { + setSidebarOpen(false); + } + }; + return ( - - - {data?.map((menu, i) => )} - {/* {user?.sidebarMenus.map((menu, i) => ( - - )) ?? null} */} - - + <> +
+ {/* Header */} + + + {/* Sidebar */} +
+ + {data?.map((menu, i) => ( + + ))} + +
+
+ ); } diff --git a/apps/frontend/src/components/DashboardTable.tsx b/apps/frontend/src/components/DashboardTable.tsx index 1b03a27..988435c 100644 --- a/apps/frontend/src/components/DashboardTable.tsx +++ b/apps/frontend/src/components/DashboardTable.tsx @@ -1,5 +1,13 @@ -import { Table, Center, ScrollArea } from "@mantine/core"; -import { Table as ReactTable, flexRender } from "@tanstack/react-table"; +import { ScrollArea } from "@/shadcn/components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/shadcn/components/ui/table"; +import { flexRender, Table as ReactTable } from "@tanstack/react-table"; interface Props { table: ReactTable; @@ -7,68 +15,55 @@ interface Props { export default function DashboardTable({ table }: Props) { return ( - - - {/* Thead */} - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} - - ))} - +
+
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + ))} - + + ))} + - {/* Tbody */} - - {table.getRowModel().rows.length > 0 ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - -
- No Data -
-
-
- )} -
+ + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + - No Data - + + + )} +
-
+ ); } diff --git a/apps/frontend/src/components/NavbarChildMenu.tsx b/apps/frontend/src/components/NavbarChildMenu.tsx index 3f09f2f..5ed22ef 100644 --- a/apps/frontend/src/components/NavbarChildMenu.tsx +++ b/apps/frontend/src/components/NavbarChildMenu.tsx @@ -1,5 +1,3 @@ -import { Text } from "@mantine/core"; - import classNames from "./styles/navbarChildMenu.module.css"; import { SidebarMenu } from "backend/types"; @@ -22,13 +20,10 @@ export default function ChildMenu(props: Props) { : `/${props.item.link}`; return ( - - component="a" - className={classNames.link} - href={`${linkPath}`} - fw={props.active ? "bold" : "normal"} + {props.item.label} - + ); } diff --git a/apps/frontend/src/components/NavbarMenuItem.tsx b/apps/frontend/src/components/NavbarMenuItem.tsx index fc0e202..4b96a4a 100644 --- a/apps/frontend/src/components/NavbarMenuItem.tsx +++ b/apps/frontend/src/components/NavbarMenuItem.tsx @@ -1,17 +1,6 @@ import { useState } from "react"; - -import { - Box, - Collapse, - Group, - ThemeIcon, - UnstyledButton, - rem, -} from "@mantine/core"; -import { TbChevronRight } from "react-icons/tb"; import * as TbIcons from "react-icons/tb"; - -import classNames from "./styles/navbarMenuItem.module.css"; +// import classNames from "./styles/navbarMenuItem.module.css"; // import dashboardConfig from "../dashboard.config"; // import { usePathname } from "next/navigation"; // import areURLsSame from "@/utils/areUrlSame"; @@ -19,9 +8,14 @@ import classNames from "./styles/navbarMenuItem.module.css"; import { SidebarMenu } from "backend/types"; import ChildMenu from "./NavbarChildMenu"; import { Link } from "@tanstack/react-router"; +import { Button } from "@/shadcn/components/ui/button"; +import { ChevronRightIcon} from "lucide-react"; +import { cn } from "@/lib/utils"; interface Props { menu: SidebarMenu; + isActive: boolean; + onClick: (link: string) => void; } //TODO: Make bold and collapsed when the item is active @@ -34,7 +28,7 @@ interface Props { * @param props.menu - The menu item data to display. * @returns A React element representing an individual menu item. */ -export default function MenuItem({ menu }: Props) { +export default function MenuItem({ menu, isActive, onClick }: Props) { const hasChildren = Array.isArray(menu.children); // const pathname = usePathname(); @@ -50,6 +44,13 @@ export default function MenuItem({ menu }: Props) { setOpened((prev) => !prev); }; + const handleClick = () => { + onClick(menu.link ?? ""); + if (!hasChildren) { + toggleOpenMenu(); + } + }; + // Mapping children menu items if available const subItems = (hasChildren ? menu.children! : []).map((child, index) => ( @@ -69,43 +70,41 @@ export default function MenuItem({ menu }: Props) { return ( <> {/* Main Menu Item */} - - onClick={toggleOpenMenu} - className={`${classNames.control} py-2`} - to={menu.link} - component={menu.link ? Link : "button"} + {/* Collapsible Sub-Menu */} - {hasChildren && {subItems}} + {hasChildren && ( +
+ {subItems} +
+ )} ); } diff --git a/apps/frontend/src/components/PageTemplate.tsx b/apps/frontend/src/components/PageTemplate.tsx index 3107229..b0cf287 100644 --- a/apps/frontend/src/components/PageTemplate.tsx +++ b/apps/frontend/src/components/PageTemplate.tsx @@ -1,16 +1,4 @@ /* eslint-disable no-mixed-spaces-and-tabs */ -import { - Button, - Card, - Flex, - Pagination, - Select, - Stack, - Text, - TextInput, - Title, -} from "@mantine/core"; -import { Link } from "@tanstack/react-router"; import React, { ReactNode, useState } from "react"; import { TbPlus, TbSearch } from "react-icons/tb"; import DashboardTable from "./DashboardTable"; @@ -25,7 +13,25 @@ import { keepPreviousData, useQuery, } from "@tanstack/react-query"; -import { useDebouncedCallback } from "@mantine/hooks"; +import { useDebouncedValue } from "@mantine/hooks"; +import { Button } from "@/shadcn/components/ui/button"; +import { 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> = { data: Array; @@ -70,24 +76,32 @@ const createCreateButton = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any property: Props["createButton"] = true ) => { + const navigate = useNavigate(); + + const addQuery = () => { + navigate({ to: `${window.location.pathname}`, search: { create: true } }); + } + if (property === true) { return ( ); } else if (typeof property === "string") { return ( ); } else { @@ -95,6 +109,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(); + } + + // Add page numbers + for (let i = startPage; i <= endPage; i++) { + items.push( + + ); + } + + // Add ellipsis after + if (endPage < totalPages - 1) { + items.push(); + } + + // Add last page + if (endPage < totalPages) { + items.push( + + ); + } + if (currentPage > 2) { + items.unshift( + + ); + } + + return items; + }; + + return ( + + + + + +
+ {getPaginationItems().map((item) => ( + + {item} + + ))} +
+ + + +
+
+ ); +}; + /** * PageTemplate component for displaying a paginated table with search and filter functionality. @@ -113,15 +230,15 @@ export default function PageTemplate< q: "", }); - // const [deboucedSearchQuery] = useDebouncedValue(filterOptions.q, 500); + const [debouncedSearchQuery] = useDebouncedValue(filterOptions.q, 500); const query = useQuery({ ...(typeof props.queryOptions === "function" ? props.queryOptions( - filterOptions.page, - filterOptions.limit, - filterOptions.q - ) + filterOptions.page, + filterOptions.limit, + debouncedSearchQuery + ) : props.queryOptions), placeholderData: keepPreviousData, }); @@ -131,7 +248,11 @@ export default function PageTemplate< columns: props.columnDefs, getCoreRowModel: getCoreRowModel(), defaultColumn: { - cell: (props) => {props.getValue() as ReactNode}, + cell: (props) => ( + + {props.getValue() as ReactNode} + + ), }, }); @@ -140,13 +261,13 @@ export default function PageTemplate< * * @param value - The new search query value. */ - const handleSearchQueryChange = useDebouncedCallback((value: string) => { + const handleSearchQueryChange = (value: string) => { setFilterOptions((prev) => ({ page: 0, limit: prev.limit, q: value, })); - }, 500); + }; /** * Handles the change in page number. @@ -155,33 +276,34 @@ export default function PageTemplate< */ const handlePageChange = (page: number) => { setFilterOptions((prev) => ({ - page: page - 1, + page: page - 1, // Adjust for zero-based index limit: prev.limit, q: prev.q, })); }; return ( - - {props.title} - - {/* Top Section */} - - {createCreateButton(props.createButton)} - - +
+

{props.title}

+ {/* Table Functionality */}
- {/* Search */} -
- } - value={filterOptions.q} - onChange={(e) => - handleSearchQueryChange(e.target.value) - } - placeholder="Search..." - /> + {/* Search and Create Button */} +
+
+ + handleSearchQueryChange(e.target.value)} + placeholder="Pencarian..." + /> +
+
+ {createCreateButton(props.createButton)} +
{/* Table */} @@ -189,41 +311,50 @@ export default function PageTemplate< {/* Pagination */} {query.data && ( -
- + setFilterOptions((prev) => ({ + page: prev.page, + limit: parseInt(value ?? "10"), + q: prev.q, + })) + } + defaultValue="10" + > + + + + + 5 + 10 + 50 + 100 + 500 + 1000 + + +
+ - - Showing {query.data.data.length} of{" "} - {query.data._metadata.totalItems} - +
+ + Menampilkan {query.data.data.length} dari {query.data._metadata.totalItems} + +
)}
- {/* The Modals */} {props.modals?.map((modal, index) => ( {modal} ))}
- +
); } diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index c6f68d5..6f4ed59 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -69,5 +69,5 @@ } :root { - --primary-color: #2555FF; + --primary-color: #2555FF } \ No newline at end of file diff --git a/apps/frontend/src/modules/usersManagement/modals/UserDeleteModal.tsx b/apps/frontend/src/modules/usersManagement/modals/UserDeleteModal.tsx index 8e5f24f..2e5d89b 100644 --- a/apps/frontend/src/modules/usersManagement/modals/UserDeleteModal.tsx +++ b/apps/frontend/src/modules/usersManagement/modals/UserDeleteModal.tsx @@ -63,14 +63,14 @@ export default function UserDeleteModal() { navigate({ search: {} })} - title={`Delete confirmation`} + title={`Konfirmasi Hapus`} > - Are you sure you want to delete user{" "} + Apakah Anda yakin ingin menghapus pengguna{" "} {userQuery.data?.name} - ? This action is irreversible. + ? Tindakan ini tidak dapat diubah. {/* {errorMessage && {errorMessage}} */} @@ -81,7 +81,7 @@ export default function UserDeleteModal() { onClick={() => navigate({ search: {} })} disabled={mutation.isPending} > - Cancel + Batal diff --git a/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx b/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx index 05531b1..c153550 100644 --- a/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx +++ b/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx @@ -42,7 +42,7 @@ export default function UserFormModal() { const detailId = searchParams.detail; const editId = searchParams.edit; - const formType = detailId ? "detail" : editId ? "edit" : "create"; + const formType = detailId ? "detail" : editId ? "ubah" : "tambah"; /** * CHANGE FOLLOWING: @@ -51,7 +51,7 @@ export default function UserFormModal() { const userQuery = useQuery(getUserByIdQueryOptions(dataId)); const modalTitle = - formType.charAt(0).toUpperCase() + formType.slice(1) + " User"; + formType.charAt(0).toUpperCase() + formType.slice(1) + " Pengguna"; const form = useForm({ initialValues: { @@ -62,6 +62,11 @@ export default function UserFormModal() { photoProfileUrl: "", password: "", roles: [] as string[], + companyName: "", + position: "", + workExperience: "", + address: "", + phoneNumber: "", }, }); @@ -81,6 +86,11 @@ export default function UserFormModal() { username: data.username, password: "", roles: data.roles.map((v) => v.id), //only extract the id + companyName: data.companyName ?? "", + position: data.position ?? "", + workExperience: data.workExperience ?? "", + address: data.address ?? "", + phoneNumber: data.phoneNumber ?? "", }); form.setErrors({}); @@ -91,11 +101,11 @@ export default function UserFormModal() { mutationKey: ["usersMutation"], mutationFn: async ( options: - | { action: "edit"; data: Parameters[0] } - | { action: "create"; data: Parameters[0] } + | { action: "ubah"; data: Parameters[0] } + | { action: "tambah"; data: Parameters[0] } ) => { console.log("called"); - return options.action === "edit" + return options.action === "ubah" ? await updateUser(options.data) : await createUser(options.data); }, @@ -120,16 +130,21 @@ export default function UserFormModal() { if (formType === "detail") return; //TODO: OPtimize this code - if (formType === "create") { + if (formType === "tambah") { await mutation.mutateAsync({ action: formType, data: { email: values.email, name: values.name, password: values.password, - roles: JSON.stringify(values.roles), + roles: values.roles, isEnabled: "true", username: values.username, + companyName: values.email, + position: values.position, + workExperience: values.workExperience, + address: values.address, + phoneNumber: values.phoneNumber, }, }); } else { @@ -140,15 +155,20 @@ export default function UserFormModal() { email: values.email, name: values.name, password: values.password, - roles: JSON.stringify(values.roles), + roles: values.roles, isEnabled: "true", username: values.username, + companyName: values.companyName, + position: values.position, + workExperience: values.workExperience, + address: values.address, + phoneNumber: values.phoneNumber, }, }); } queryClient.invalidateQueries({ queryKey: ["users"] }); notifications.show({ - message: `The ser is ${formType === "create" ? "created" : "edited"}`, + message: `The ser is ${formType === "tambah" ? "created" : "edited"}`, }); navigate({ search: {} }); @@ -198,20 +218,18 @@ export default function UserFormModal() { inputs: [ { type: "text", - readOnly: true, - variant: "filled", - ...form.getInputProps("id"), - hidden: !form.values.id, - }, - { - type: "text", - label: "Name", + label: "Nama", ...form.getInputProps("name"), }, { type: "text", - label: "Username", - ...form.getInputProps("username"), + label: "Jabatan", + ...form.getInputProps("position"), + }, + { + type: "text", + label: "Pengalaman Kerja", + ...form.getInputProps("workExperience"), }, { type: "text", @@ -219,11 +237,21 @@ export default function UserFormModal() { ...form.getInputProps("email"), }, { - type: "password", - label: "Password", - hidden: formType !== "create", - ...form.getInputProps("password"), + type: "text", + label: "Instansi/Perusahaan", + ...form.getInputProps("companyName"), }, + { + type: "text", + label: "Alamat", + ...form.getInputProps("address"), + }, + { + type: "text", + label: "Nomor Telepon", + ...form.getInputProps("phoneNumber"), + }, + { type: "multi-select", label: "Roles", @@ -236,6 +264,17 @@ export default function UserFormModal() { })), error: form.errors.roles, }, + { + type: "text", + label: "Username", + ...form.getInputProps("username"), + }, + { + type: "password", + label: "Password", + hidden: formType !== "tambah", + ...form.getInputProps("password"), + }, ], })} @@ -246,7 +285,7 @@ export default function UserFormModal() { onClick={() => navigate({ search: {} })} disabled={mutation.isPending} > - Close + Tutup {formType !== "detail" && ( )} diff --git a/apps/frontend/src/modules/usersManagement/queries/userQueries.ts b/apps/frontend/src/modules/usersManagement/queries/userQueries.ts index 02da504..2093488 100644 --- a/apps/frontend/src/modules/usersManagement/queries/userQueries.ts +++ b/apps/frontend/src/modules/usersManagement/queries/userQueries.ts @@ -34,26 +34,26 @@ export const getUserByIdQueryOptions = (userId: string | undefined) => }); export const createUser = async ( - form: InferRequestType["form"] + json: InferRequestType["json"] ) => { return await fetchRPC( client.users.$post({ - form, + json, }) ); }; export const updateUser = async ( - form: InferRequestType<(typeof client.users)[":id"]["$patch"]>["form"] & { + json: InferRequestType<(typeof client.users)[":id"]["$patch"]>["json"] & { id: string; } ) => { return await fetchRPC( client.users[":id"].$patch({ param: { - id: form.id, + id: json.id, }, - form, + json, }) ); }; diff --git a/apps/frontend/src/modules/usersManagement/tables/columns.tsx b/apps/frontend/src/modules/usersManagement/tables/columns.tsx index d29d65c..134f191 100644 --- a/apps/frontend/src/modules/usersManagement/tables/columns.tsx +++ b/apps/frontend/src/modules/usersManagement/tables/columns.tsx @@ -1,5 +1,4 @@ import { createColumnHelper } from "@tanstack/react-table"; -import { Badge, Flex, Group, Avatar, Text, Anchor } from "@mantine/core"; import { TbEye, TbPencil, TbTrash } from "react-icons/tb"; import { CrudPermission } from "@/types"; import stringToColorHex from "@/utils/stringToColorHex"; @@ -7,6 +6,8 @@ import createActionButtons from "@/utils/createActionButton"; import client from "@/honoClient"; import { InferResponseType } from "hono"; import { Link } from "@tanstack/react-router"; +import { Badge } from "@/shadcn/components/ui/badge"; +import { Avatar } from "@/shadcn/components/ui/avatar"; interface ColumnOptions { permissions: Partial; @@ -29,31 +30,28 @@ const createColumns = (options: ColumnOptions) => { columnHelper.accessor("name", { header: "Name", cell: (props) => ( - +
{props.getValue()?.[0].toUpperCase()} - + {props.getValue()} - - + +
), }), columnHelper.accessor("email", { header: "Email", cell: (props) => ( - + {props.getValue()} - + ), }), @@ -66,7 +64,7 @@ const createColumns = (options: ColumnOptions) => { id: "status", header: "Status", cell: (props) => ( - + {props.row.original.isEnabled ? "Active" : "Inactive"} ), @@ -80,7 +78,7 @@ const createColumns = (options: ColumnOptions) => { className: "w-fit", }, cell: (props) => ( - +
{createActionButtons([ { label: "Detail", @@ -104,7 +102,7 @@ const createColumns = (options: ColumnOptions) => { icon: , }, ])} - +
), }), ]; diff --git a/apps/frontend/src/routeTree.gen.ts b/apps/frontend/src/routeTree.gen.ts index 8619974..3f1430d 100644 --- a/apps/frontend/src/routeTree.gen.ts +++ b/apps/frontend/src/routeTree.gen.ts @@ -23,6 +23,10 @@ import { Route as DashboardLayoutDashboardIndexImport } from './routes/_dashboar const IndexLazyImport = createFileRoute('/')() const LogoutIndexLazyImport = createFileRoute('/logout/')() const LoginIndexLazyImport = createFileRoute('/login/')() +const ForgotPasswordIndexLazyImport = createFileRoute('/forgot-password/')() +const ForgotPasswordVerifyLazyImport = createFileRoute( + '/forgot-password/verify', +)() // Create/Update Routes @@ -46,6 +50,20 @@ const LoginIndexLazyRoute = LoginIndexLazyImport.update({ getParentRoute: () => rootRoute, } as any).lazy(() => import('./routes/login/index.lazy').then((d) => d.Route)) +const ForgotPasswordIndexLazyRoute = ForgotPasswordIndexLazyImport.update({ + path: '/forgot-password/', + getParentRoute: () => rootRoute, +} as any).lazy(() => + import('./routes/forgot-password/index.lazy').then((d) => d.Route), +) + +const ForgotPasswordVerifyLazyRoute = ForgotPasswordVerifyLazyImport.update({ + path: '/forgot-password/verify', + getParentRoute: () => rootRoute, +} as any).lazy(() => + import('./routes/forgot-password/verify.lazy').then((d) => d.Route), +) + const DashboardLayoutUsersIndexRoute = DashboardLayoutUsersIndexImport.update({ path: '/users/', getParentRoute: () => DashboardLayoutRoute, @@ -83,6 +101,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardLayoutImport parentRoute: typeof rootRoute } + '/forgot-password/verify': { + id: '/forgot-password/verify' + path: '/forgot-password/verify' + fullPath: '/forgot-password/verify' + preLoaderRoute: typeof ForgotPasswordVerifyLazyImport + parentRoute: typeof rootRoute + } + '/forgot-password/': { + id: '/forgot-password/' + path: '/forgot-password' + fullPath: '/forgot-password' + preLoaderRoute: typeof ForgotPasswordIndexLazyImport + parentRoute: typeof rootRoute + } '/login/': { id: '/login/' path: '/login' @@ -130,6 +162,8 @@ export const routeTree = rootRoute.addChildren({ DashboardLayoutTimetableIndexRoute, DashboardLayoutUsersIndexRoute, }), + ForgotPasswordVerifyLazyRoute, + ForgotPasswordIndexLazyRoute, LoginIndexLazyRoute, LogoutIndexLazyRoute, }) @@ -144,6 +178,8 @@ export const routeTree = rootRoute.addChildren({ "children": [ "/", "/_dashboardLayout", + "/forgot-password/verify", + "/forgot-password/", "/login/", "/logout/" ] @@ -159,6 +195,12 @@ export const routeTree = rootRoute.addChildren({ "/_dashboardLayout/users/" ] }, + "/forgot-password/verify": { + "filePath": "forgot-password/verify.lazy.tsx" + }, + "/forgot-password/": { + "filePath": "forgot-password/index.lazy.tsx" + }, "/login/": { "filePath": "login/index.lazy.tsx" }, diff --git a/apps/frontend/src/routes/_dashboardLayout.tsx b/apps/frontend/src/routes/_dashboardLayout.tsx index e5d8517..5de5782 100644 --- a/apps/frontend/src/routes/_dashboardLayout.tsx +++ b/apps/frontend/src/routes/_dashboardLayout.tsx @@ -1,68 +1,65 @@ -import { AppShell } from "@mantine/core"; import { Navigate, Outlet, createFileRoute } from "@tanstack/react-router"; -import { useDisclosure } from "@mantine/hooks"; import AppHeader from "../components/AppHeader"; import AppNavbar from "../components/AppNavbar"; import useAuth from "@/hooks/useAuth"; import { useQuery } from "@tanstack/react-query"; import fetchRPC from "@/utils/fetchRPC"; import client from "@/honoClient"; +import { useState } from "react"; export const Route = createFileRoute("/_dashboardLayout")({ - component: DashboardLayout, + component: DashboardLayout, - // beforeLoad: ({ location }) => { - // if (true) { - // throw redirect({ - // to: "/login", - // }); - // } - // }, + // beforeLoad: ({ location }) => { + // if (true) { + // throw redirect({ + // to: "/login", + // }); + // } + // }, }); function DashboardLayout() { - const { isAuthenticated, saveAuthData } = useAuth(); + const { isAuthenticated, saveAuthData } = useAuth(); - useQuery({ - queryKey: ["my-profile"], - queryFn: async () => { - const response = await fetchRPC(client.auth["my-profile"].$get()); + useQuery({ + queryKey: ["my-profile"], + queryFn: async () => { + const response = await fetchRPC(client.auth["my-profile"].$get()); - saveAuthData({ - id: response.id, - name: response.name, - permissions: response.permissions, - }); + saveAuthData({ + id: response.id, + name: response.name, + permissions: response.permissions, + }); - return response; - }, - enabled: isAuthenticated, - }); + return response; + }, + enabled: isAuthenticated, + }); - const [openNavbar, { toggle }] = useDisclosure(false); + const [openNavbar, setNavbarOpen] = useState(true); + const toggle = () => { + setNavbarOpen(!openNavbar); + }; - return isAuthenticated ? ( - - + return isAuthenticated ? ( +
+ {/* Header */} + - + {/* Main Content Area */} +
+ {/* Sidebar */} + - - - - - ) : ( - - ); + {/* Main Content */} +
+ +
+
+
+ ) : ( + + ); } diff --git a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx index 85cb609..8b6262d 100644 --- a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx @@ -20,17 +20,17 @@ const columnHelper = createColumnHelper(); export default function UsersPage() { return ( , ]} columnDefs={[ columnHelper.display({ - header: "#", + header: "No", cell: (props) => props.row.index + 1, }), columnHelper.display({ - header: "Name", + header: "Nama", cell: (props) => props.row.original.name, }), @@ -38,19 +38,28 @@ export default function UsersPage() { header: "Username", cell: (props) => props.row.original.username, }), - columnHelper.display({ - header: "Status", - cell: (props) => - props.row.original.isEnabled ? ( - Active - ) : ( - Inactive - ), + header: "Email", + cell: (props) => props.row.original.email, + }), + columnHelper.display({ + header: "Perusahaan", + cell: (props) => props.row.original.company, }), columnHelper.display({ - header: "Actions", + header: "Roles", + cell: (props) => { + const roles = props.row.original.roles; // Get array of roles from data + if (roles && roles.length > 0) { + return roles.map(role => role.name).join(", "); + } + return
-
; + }, + }), + + columnHelper.display({ + header: "Aksi", cell: (props) => ( {createActionButtons([ @@ -62,14 +71,14 @@ export default function UsersPage() { icon: , }, { - label: "Edit", + label: "Ubah", permission: true, action: `?edit=${props.row.original.id}`, color: "orange", icon: , }, { - label: "Delete", + label: "Hapus", permission: true, action: `?delete=${props.row.original.id}`, color: "red", diff --git a/apps/frontend/src/routes/forgot-password/index.lazy.tsx b/apps/frontend/src/routes/forgot-password/index.lazy.tsx index 8f0a73e..e03c549 100644 --- a/apps/frontend/src/routes/forgot-password/index.lazy.tsx +++ b/apps/frontend/src/routes/forgot-password/index.lazy.tsx @@ -1,7 +1,7 @@ import { createLazyFileRoute } from "@tanstack/react-router"; import { TbArrowNarrowRight } from "react-icons/tb"; import { IoIosArrowUp } from "react-icons/io"; -import { HiOutlineGlobeAlt } from "react-icons/hi"; +import { HiOutlineGlobeAlt } from "react-icons/hi"; import { useForm, Control, FieldError } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -87,11 +87,11 @@ const CustomDropdownMenu: React.FC = ({ @@ -159,33 +159,30 @@ export function ForgotPasswordForm() { return (
-
-
-
-
-
-
-
+
+
+
+ + + +
-
+
{/* Top */}
Amati
{/* Center */} -
+

Forgot Password

-

+

No worries, we'll send you reset instructions

@@ -207,19 +204,14 @@ export function ForgotPasswordForm() {
Back to login @@ -231,8 +223,8 @@ export function ForgotPasswordForm() { {/* Bottom */}
diff --git a/apps/frontend/src/routes/forgot-password/verify.lazy.tsx b/apps/frontend/src/routes/forgot-password/verify.lazy.tsx index 23d5b94..1d8294a 100644 --- a/apps/frontend/src/routes/forgot-password/verify.lazy.tsx +++ b/apps/frontend/src/routes/forgot-password/verify.lazy.tsx @@ -91,11 +91,11 @@ const CustomDropdownMenu: React.FC = ({ @@ -142,7 +142,7 @@ export function ResetPasswordForm() { const urlParams = new URLSearchParams(window.location.search); const tokenFromURL = urlParams.get("token"); setToken(tokenFromURL); - }, []); + }, []); // Function to handle form submission const onSubmit = async (values: FormSchema) => { @@ -195,33 +195,30 @@ export function ResetPasswordForm() { return (
-
-
-
-
-
-
-
+
+
+
+ + + +
-
+
{/* Top */}
Amati
{/* Center */} -
+

Change Password

-

Enter your new password

+

Enter your new password

Back to login @@ -272,8 +264,8 @@ export function ResetPasswordForm() {
diff --git a/apps/frontend/src/routes/login/index.lazy.tsx b/apps/frontend/src/routes/login/index.lazy.tsx index 58c16ef..cd18d0d 100644 --- a/apps/frontend/src/routes/login/index.lazy.tsx +++ b/apps/frontend/src/routes/login/index.lazy.tsx @@ -19,6 +19,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useEffect, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { TbArrowNarrowRight } from "react-icons/tb"; +import logo from "@/assets/logos/amati-logo.png"; export const Route = createLazyFileRoute("/login/")({ component: LoginPage, @@ -30,8 +31,8 @@ type FormSchema = { }; const formSchema = z.object({ - username: z.string().min(1, "This field is required"), - password: z.string().min(1, "This field is required"), + username: z.string().min(1, "Kolom ini wajib diisi"), + password: z.string().min(1, "Kolom ini wajib diisi"), }); export default function LoginPage() { @@ -97,94 +98,100 @@ export default function LoginPage() { }; return ( -
-
- Amati +
+ {/* Navbar */} + + + {/* Background shapes */} +
+
+
+ + + + +
+
-
- -

Sign In

-

- New to this app?{' '} - - Register now + + {/* Sign In form */} +

+ +

Sign In

+

+ Baru mengenal aplikasi ini?{' '} + + Daftar sekarang + +

+ + +
+ {errorMessage && ( + +

{errorMessage}

+
+ )} + ( + + Email/Username + + + + + + )} + /> + ( + + Kata sandi + + + + + + )} + /> +

+ + Lupa kata sandi? -

- - -
- {errorMessage && ( - -

{errorMessage}

-
- )} - ( - - Email/Username - - - - - - )} - /> - ( - - Password - - - - - - )} - /> -

- - Forgot Password? - -

-
-
- -
- - +

+
+
+ +
+ +
); -} +} \ No newline at end of file diff --git a/apps/frontend/src/shadcn/components/ui/avatar.tsx b/apps/frontend/src/shadcn/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/frontend/src/shadcn/components/ui/badge.tsx b/apps/frontend/src/shadcn/components/ui/badge.tsx new file mode 100644 index 0000000..f000e3e --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/apps/frontend/src/shadcn/components/ui/breadcrumb.tsx b/apps/frontend/src/shadcn/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..71a5c32 --- /dev/null +++ b/apps/frontend/src/shadcn/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>