From 7643a7d5d763c955643aadab29d7514d35e1f41b Mon Sep 17 00:00:00 2001 From: percyfikri Date: Wed, 11 Sep 2024 09:55:44 +0700 Subject: [PATCH 1/9] Update : API for Users --- apps/backend/src/routes/users/route.ts | 305 ++++++++++++++++++++----- 1 file changed, 247 insertions(+), 58 deletions(-) diff --git a/apps/backend/src/routes/users/route.ts b/apps/backend/src/routes/users/route.ts index 3069960..6de294e 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 } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; @@ -12,12 +12,19 @@ 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 { 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), + 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"), roles: z .string() @@ -87,9 +94,14 @@ const usersRoute = new Hono() createdAt: users.createdAt, updatedAt: users.updatedAt, ...(includeTrashed ? { deletedAt: users.deletedAt } : {}), + company: respondents.companyName, + roles: rolesSchema.name, fullCount: totalCountQuery, }) .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), @@ -139,7 +151,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 +168,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( @@ -184,34 +202,124 @@ const usersRoute = new Hono() .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 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(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 new HTTPException(500, { 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, + }); + }); + + // 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, + }); + + // Add other roles if provided + if (userData.roles) { + const roles = JSON.parse(userData.roles) as string[]; + + for (let roleCode of roles) { + const role = ( + await trx + .select() + .from(rolesSchema) + .where(eq(rolesSchema.code, roleCode)) + .limit(1) + )[0]; + + if (role) { + await trx.insert(rolesToUsers).values({ + userId: newUser.id, + roleId: role.id, + }); + } else { + throw new HTTPException(404, { + message: `Role ${roleCode} does not exists`, + }); + } + } + } + + return newUser; + }); return c.json( { @@ -226,10 +334,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 new HTTPException(400, { + message: "Email or username has been registered by another user", + }); + } + } const user = await db .select() @@ -238,18 +368,67 @@ 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) { + const roles = JSON.parse(userData.roles) as string[]; + + // Remove existing roles for the user + await trx.delete(rolesToUsers).where(eq(rolesToUsers.userId, userId)); + + // Assign new roles + for (let roleCode of roles) { + const role = ( + await trx + .select() + .from(rolesSchema) + .where(eq(rolesSchema.code, roleCode)) + .limit(1) + )[0]; + + if (role) { + await trx.insert(rolesToUsers).values({ + userId: userId, + roleId: role.id, + }); + } else { + throw new HTTPException(404, { + message: `Role ${roleCode} does not exist`, + }); + } + } + } + }); + return c.json({ message: "User updated successfully", }); @@ -273,6 +452,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 +463,20 @@ const usersRoute = new Hono() ) ); + // Throw error if the user does not exist if (!user[0]) throw new HTTPException(404, { 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, { message: "You cannot delete yourself", }); } + // Delete or soft delete user if (skipTrash) { await db.delete(users).where(eq(users.id, userId)); } else { @@ -311,28 +494,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 new HTTPException(400, { + 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; From f369e7af9a2730f4a086354fe749b5c440b418b1 Mon Sep 17 00:00:00 2001 From: percyfikri Date: Wed, 11 Sep 2024 09:58:31 +0700 Subject: [PATCH 2/9] update : deletedAt coloumn on Users --- apps/backend/src/drizzle/schema/respondents.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }) => ({ From 22eb64d611e5b64ca6b84ec6efc855fb7830d258 Mon Sep 17 00:00:00 2001 From: percyfikri Date: Wed, 11 Sep 2024 09:59:22 +0700 Subject: [PATCH 3/9] update : table user management --- .../_dashboardLayout/users/index.lazy.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx index 85cb609..27399e7 100644 --- a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx @@ -25,7 +25,7 @@ export default function UsersPage() { modals={[, ]} columnDefs={[ columnHelper.display({ - header: "#", + header: "No", cell: (props) => props.row.index + 1, }), @@ -38,16 +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: "Company", + cell: (props) => props.row.original.company, + }), + columnHelper.display({ + header: "role", + cell: (props) => props.row.original.roles, + }), + + // columnHelper.display({ + // header: "Status", + // cell: (props) => + // props.row.original.isEnabled ? ( + // Active + // ) : ( + // Inactive + // ), + // }), columnHelper.display({ header: "Actions", From 5bebf337db88857aad0a5412bd08ad0f0537c9da Mon Sep 17 00:00:00 2001 From: percyfikri Date: Fri, 13 Sep 2024 10:33:09 +0700 Subject: [PATCH 4/9] Update : API for users (get, post, patch) --- apps/backend/src/routes/users/route.ts | 122 ++++++++++++++++--------- 1 file changed, 77 insertions(+), 45 deletions(-) diff --git a/apps/backend/src/routes/users/route.ts b/apps/backend/src/routes/users/route.ts index 6de294e..26a13e1 100644 --- a/apps/backend/src/routes/users/route.ts +++ b/apps/backend/src/routes/users/route.ts @@ -26,23 +26,24 @@ export const userFormSchema = z.object({ address: z.string().min(1), phoneNumber: z.string().min(1).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 + // .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()).optional(), }); export const userUpdateSchema = userFormSchema.extend({ @@ -58,6 +59,7 @@ const usersRoute = new Hono() * - includeTrashed: boolean (default: false)\ * - withMetadata: boolean */ + .get( "/", checkPermission("users.readAll"), @@ -73,17 +75,18 @@ const usersRoute = new Hono() .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), + limit: z.coerce.number().int().min(1).max(1000).default(100), q: z.string().default(""), }) ), async (c) => { const { includeTrashed, page, limit, q } = c.req.valid("query"); - + + // Total count for pagination 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, @@ -95,7 +98,10 @@ const usersRoute = new Hono() updatedAt: users.updatedAt, ...(includeTrashed ? { deletedAt: users.deletedAt } : {}), company: respondents.companyName, - roles: rolesSchema.name, + role: { + name: rolesSchema.name, + id: rolesSchema.id, + }, fullCount: totalCountQuery, }) .from(users) @@ -118,8 +124,47 @@ const usersRoute = new Hono() .offset(page * limit) .limit(limit); + // Group roles for each user to prevent duplication + const userMap = new Map(); + + 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 duplication and roles in array form + const groupedData = Array.from(userMap.values()); + return c.json({ - data: result.map((d) => ({ ...d, fullCount: undefined })), + data: groupedData.map((d) => ({ ...d, fullCount: undefined })), _metadata: { currentPage: page, totalPages: Math.ceil( @@ -130,7 +175,8 @@ const usersRoute = new Hono() }, }); } - ) + ) + //get user by id .get( "/:id", @@ -277,31 +323,17 @@ const usersRoute = new Hono() message: "Error creating respondent: " + err.message, }); }); - - // 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, - }); // Add other roles if provided if (userData.roles) { - const roles = JSON.parse(userData.roles) as string[]; + const roles = userData.roles; - for (let roleCode of roles) { + for (let roleId of roles) { const role = ( await trx .select() .from(rolesSchema) - .where(eq(rolesSchema.code, roleCode)) + .where(eq(rolesSchema.id, roleId)) .limit(1) )[0]; @@ -312,7 +344,7 @@ const usersRoute = new Hono() }); } else { throw new HTTPException(404, { - message: `Role ${roleCode} does not exists`, + message: `Role ${roleId} does not exists`, }); } } @@ -400,18 +432,18 @@ const usersRoute = new Hono() // Update roles if provided if (userData.roles) { - const roles = JSON.parse(userData.roles) as string[]; + const roles = userData.roles; // Remove existing roles for the user await trx.delete(rolesToUsers).where(eq(rolesToUsers.userId, userId)); // Assign new roles - for (let roleCode of roles) { + for (let roleId of roles) { const role = ( await trx .select() .from(rolesSchema) - .where(eq(rolesSchema.code, roleCode)) + .where(eq(rolesSchema.id, roleId)) .limit(1) )[0]; @@ -422,7 +454,7 @@ const usersRoute = new Hono() }); } else { throw new HTTPException(404, { - message: `Role ${roleCode} does not exist`, + message: `Role ${roleId} does not exist`, }); } } From 2b055a237ffd9bc974e4da87df9d4fe68568d530 Mon Sep 17 00:00:00 2001 From: percyfikri Date: Fri, 13 Sep 2024 10:34:20 +0700 Subject: [PATCH 5/9] Integrasi FE and BE for user-management --- .../usersManagement/modals/UserFormModal.tsx | 99 ++++++++++++++++++- .../usersManagement/queries/userQueries.ts | 10 +- .../_dashboardLayout/users/index.lazy.tsx | 59 ++++++++--- apps/frontend/vite.config.ts | 2 +- 4 files changed, 148 insertions(+), 22 deletions(-) diff --git a/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx b/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx index 05531b1..8708684 100644 --- a/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx +++ b/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx @@ -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({}); @@ -127,9 +137,14 @@ 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.email, + position: values.position, + workExperience: values.workExperience, + address: values.address, + phoneNumber: values.phoneNumber, }, }); } else { @@ -140,9 +155,14 @@ 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, }, }); } @@ -192,7 +212,7 @@ export default function UserFormModal() { - {createInputComponents({ + {/* {createInputComponents({ disableAll: mutation.isPending, readonlyAll: formType === "detail", inputs: [ @@ -237,6 +257,79 @@ export default function UserFormModal() { error: form.errors.roles, }, ], + })} */} + + {createInputComponents({ + disableAll: mutation.isPending, + readonlyAll: formType === "detail", + inputs: [ + { + type: "text", + readOnly: true, + variant: "filled", + ...form.getInputProps("id"), + hidden: !form.values.id, + }, + { + type: "text", + label: "Name", + ...form.getInputProps("name"), + }, + { + type: "text", + label: "Position", + ...form.getInputProps("position"), + }, + { + type: "text", + label: "Work Experience", + ...form.getInputProps("workExperience"), + }, + { + type: "text", + label: "Email", + ...form.getInputProps("email"), + }, + { + type: "text", + label: "Company Name", + ...form.getInputProps("companyName"), + }, + { + type: "text", + label: "Address", + ...form.getInputProps("address"), + }, + { + type: "text", + label: "Phone Number", + ...form.getInputProps("phoneNumber"), + }, + + { + type: "multi-select", + label: "Roles", + value: form.values.roles, + onChange: (values) => + form.setFieldValue("roles", values), + data: rolesQuery.data?.map((role) => ({ + value: role.id, + label: role.name, + })), + error: form.errors.roles, + }, + { + type: "text", + label: "Username", + ...form.getInputProps("username"), + }, + { + type: "password", + label: "Password", + hidden: formType !== "create", + ...form.getInputProps("password"), + }, + ], })} {/* Buttons */} 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/routes/_dashboardLayout/users/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx index 27399e7..66c8eb0 100644 --- a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx @@ -17,6 +17,42 @@ type DataType = ExtractQueryDataType; const columnHelper = createColumnHelper(); +// Fungsi untuk mengelompokkan pengguna berdasarkan perusahaan +const groupUsersByCompany = (data: DataType[]) => { + const companyMap = new Map(); + + data.forEach((item) => { + const companyName = item.company || "Unknown Company"; + + if (!companyMap.has(companyName)) { + companyMap.set(companyName, { + companyName, + users: [{ + id: item.id, + name: item.name, + username: item.username, + email: item.email ?? "", + role: item.roles.join(", ") // assuming roles is an array + }], + }); + } else { + const existingCompany = companyMap.get(companyName); + existingCompany?.users.push({ + id: item.id, + name: item.name, + username: item.username, + email: item.email ?? "", + role: item.roles.join(", "), // assuming roles is an array + }); + } + }); + + return Array.from(companyMap.values()); +}; + export default function UsersPage() { return ( props.row.original.company, }), - columnHelper.display({ - header: "role", - cell: (props) => props.row.original.roles, - }), - // columnHelper.display({ - // header: "Status", - // cell: (props) => - // props.row.original.isEnabled ? ( - // Active - // ) : ( - // Inactive - // ), - // }), + columnHelper.display({ + header: "Roles", + cell: (props) => { + const roles = props.row.original.roles; // Ambil array roles dari data + if (roles && roles.length > 0) { + return roles.map(role => role.name).join(", "); + } + return
No roles assigned
; // Jika tidak ada roles + }, + }), columnHelper.display({ header: "Actions", diff --git a/apps/frontend/vite.config.ts b/apps/frontend/vite.config.ts index 2d6bad5..6c9655b 100644 --- a/apps/frontend/vite.config.ts +++ b/apps/frontend/vite.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ plugins: [react(), TanStackRouterVite()], resolve: { alias: { - "@": path.resolve(__dirname,"/src"), + "@": path.resolve(__dirname,"src"), }, }, }); From bee5d55776832d836f6f3ec3212616b0b7ce0d67 Mon Sep 17 00:00:00 2001 From: percyfikri Date: Fri, 13 Sep 2024 11:01:36 +0700 Subject: [PATCH 6/9] Update : FE --- .../_dashboardLayout/users/index.lazy.tsx | 40 +------------------ 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx index 66c8eb0..e155d41 100644 --- a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx @@ -17,42 +17,6 @@ type DataType = ExtractQueryDataType; const columnHelper = createColumnHelper(); -// Fungsi untuk mengelompokkan pengguna berdasarkan perusahaan -const groupUsersByCompany = (data: DataType[]) => { - const companyMap = new Map(); - - data.forEach((item) => { - const companyName = item.company || "Unknown Company"; - - if (!companyMap.has(companyName)) { - companyMap.set(companyName, { - companyName, - users: [{ - id: item.id, - name: item.name, - username: item.username, - email: item.email ?? "", - role: item.roles.join(", ") // assuming roles is an array - }], - }); - } else { - const existingCompany = companyMap.get(companyName); - existingCompany?.users.push({ - id: item.id, - name: item.name, - username: item.username, - email: item.email ?? "", - role: item.roles.join(", "), // assuming roles is an array - }); - } - }); - - return Array.from(companyMap.values()); -}; - export default function UsersPage() { return ( { - const roles = props.row.original.roles; // Ambil array roles dari data + 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
No roles assigned
; // Jika tidak ada roles + return
No roles assigned
; }, }), From 33c0f73257fcb2173bc23df9cacaa9527bb2d0dc Mon Sep 17 00:00:00 2001 From: percyfikri Date: Tue, 17 Sep 2024 11:38:42 +0700 Subject: [PATCH 7/9] Update : Integrasi FE and BE for user-management --- apps/backend/src/data/sidebarMenus.ts | 2 +- apps/backend/src/routes/users/route.ts | 84 +++++++++---------- .../modals/UserDeleteModal.tsx | 10 +-- .../usersManagement/modals/UserFormModal.tsx | 76 ++++------------- apps/frontend/src/routeTree.gen.ts | 42 ++++++++++ .../_dashboardLayout/users/index.lazy.tsx | 14 ++-- 6 files changed, 109 insertions(+), 119 deletions(-) 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/routes/users/route.ts b/apps/backend/src/routes/users/route.ts index 26a13e1..00f7cd5 100644 --- a/apps/backend/src/routes/users/route.ts +++ b/apps/backend/src/routes/users/route.ts @@ -13,37 +13,20 @@ import requestValidator from "../../utils/requestValidator"; import authInfo from "../../middlewares/authInfo"; import checkPermission from "../../middlewares/checkPermission"; import { respondents } from "../../drizzle/schema/respondents"; -import { notFound } from "../../errors/DashboardError"; +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), - 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), + 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()).optional(), + roles: z.array(z.string().min(1, "Role is required")), }); export const userUpdateSchema = userFormSchema.extend({ @@ -75,7 +58,7 @@ const usersRoute = new Hono() .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(100), + limit: z.coerce.number().int().min(1).max(1000).default(1), q: z.string().default(""), }) ), @@ -225,9 +208,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; @@ -244,6 +227,7 @@ const usersRoute = new Hono() return c.json(userData); } ) + //create user .post( "/", @@ -275,15 +259,15 @@ const usersRoute = new Hono() .where(eq(respondents.phoneNumber, userData.phoneNumber)); if (existingUser.length > 0) { - throw new HTTPException(400, { + throw notFound({ message: "Email or username has been registered", - }); + }) } if (existingRespondent.length > 0) { - throw new HTTPException(400, { + throw forbidden({ message: "Phone number has been registered", - }); + }) } // Hash the password @@ -303,7 +287,9 @@ const usersRoute = new Hono() }) .returning() .catch(() => { - throw new HTTPException(500, { message: "Error creating user" }); + throw forbidden({ + message: "Error creating user", + }) }); // Create respondent @@ -325,7 +311,7 @@ const usersRoute = new Hono() }); // Add other roles if provided - if (userData.roles) { + if (userData.roles && userData.roles.length > 0) { const roles = userData.roles; for (let roleId of roles) { @@ -348,6 +334,10 @@ const usersRoute = new Hono() }); } } + } else { + throw forbidden({ + message: "Harap pilih minimal satu role", + }); } return newUser; @@ -387,9 +377,9 @@ const usersRoute = new Hono() ); if (existingUser.length > 0) { - throw new HTTPException(400, { + throw forbidden({ message: "Email or username has been registered by another user", - }); + }) } } @@ -431,7 +421,7 @@ const usersRoute = new Hono() } // Update roles if provided - if (userData.roles) { + if (userData.roles && userData.roles.length > 0) { const roles = userData.roles; // Remove existing roles for the user @@ -458,7 +448,11 @@ const usersRoute = new Hono() }); } } - } + } else { + throw forbidden({ + message: "Harap pilih minimal satu role", + }); + } }); return c.json({ @@ -497,13 +491,13 @@ 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", }); } @@ -541,7 +535,7 @@ const usersRoute = new Hono() // Throw error if the user is not deleted if (!user.deletedAt) { - throw new HTTPException(400, { + throw forbidden({ message: "The user is not deleted", }); } 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 8708684..3d96351 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: { @@ -101,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); }, @@ -130,7 +130,7 @@ export default function UserFormModal() { if (formType === "detail") return; //TODO: OPtimize this code - if (formType === "create") { + if (formType === "tambah") { await mutation.mutateAsync({ action: formType, data: { @@ -168,7 +168,7 @@ export default function UserFormModal() { } queryClient.invalidateQueries({ queryKey: ["users"] }); notifications.show({ - message: `The ser is ${formType === "create" ? "created" : "edited"}`, + message: `The ser is ${formType === "tambah" ? "created" : "edited"}`, }); navigate({ search: {} }); @@ -212,59 +212,13 @@ export default function UserFormModal() { - {/* {createInputComponents({ - disableAll: mutation.isPending, - readonlyAll: formType === "detail", - inputs: [ - { - type: "text", - readOnly: true, - variant: "filled", - ...form.getInputProps("id"), - hidden: !form.values.id, - }, - { - type: "text", - label: "Name", - ...form.getInputProps("name"), - }, - { - type: "text", - label: "Username", - ...form.getInputProps("username"), - }, - { - type: "text", - label: "Email", - ...form.getInputProps("email"), - }, - { - type: "password", - label: "Password", - hidden: formType !== "create", - ...form.getInputProps("password"), - }, - { - type: "multi-select", - label: "Roles", - value: form.values.roles, - onChange: (values) => - form.setFieldValue("roles", values), - data: rolesQuery.data?.map((role) => ({ - value: role.id, - label: role.name, - })), - error: form.errors.roles, - }, - ], - })} */} - {createInputComponents({ disableAll: mutation.isPending, readonlyAll: formType === "detail", inputs: [ { type: "text", + label: "Id Pengguna", readOnly: true, variant: "filled", ...form.getInputProps("id"), @@ -272,17 +226,17 @@ export default function UserFormModal() { }, { type: "text", - label: "Name", + label: "Nama", ...form.getInputProps("name"), }, { type: "text", - label: "Position", + label: "Jabatan", ...form.getInputProps("position"), }, { type: "text", - label: "Work Experience", + label: "Pengalaman Kerja", ...form.getInputProps("workExperience"), }, { @@ -292,17 +246,17 @@ export default function UserFormModal() { }, { type: "text", - label: "Company Name", + label: "Instansi/Perusahaan", ...form.getInputProps("companyName"), }, { type: "text", - label: "Address", + label: "Alamat", ...form.getInputProps("address"), }, { type: "text", - label: "Phone Number", + label: "Nomor Telepon", ...form.getInputProps("phoneNumber"), }, @@ -326,7 +280,7 @@ export default function UserFormModal() { { type: "password", label: "Password", - hidden: formType !== "create", + hidden: formType !== "tambah", ...form.getInputProps("password"), }, ], 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/users/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx index e155d41..3c5ec15 100644 --- a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx @@ -20,7 +20,7 @@ const columnHelper = createColumnHelper(); export default function UsersPage() { return ( , ]} columnDefs={[ @@ -30,7 +30,7 @@ export default function UsersPage() { }), columnHelper.display({ - header: "Name", + header: "Nama", cell: (props) => props.row.original.name, }), @@ -43,7 +43,7 @@ export default function UsersPage() { cell: (props) => props.row.original.email, }), columnHelper.display({ - header: "Company", + header: "Perusahaan", cell: (props) => props.row.original.company, }), @@ -54,12 +54,12 @@ export default function UsersPage() { if (roles && roles.length > 0) { return roles.map(role => role.name).join(", "); } - return
No roles assigned
; + return
Tidak ada peran yang diberikan
; }, }), columnHelper.display({ - header: "Actions", + header: "Aksi", cell: (props) => ( {createActionButtons([ @@ -71,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", From e5bc2b1a40dd1bf1e236c7291e31c8441c787f1b Mon Sep 17 00:00:00 2001 From: percyfikri Date: Tue, 17 Sep 2024 15:43:37 +0700 Subject: [PATCH 8/9] Update : getAll in users API --- apps/backend/src/routes/users/route.ts | 83 +++++++++++++++----------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/apps/backend/src/routes/users/route.ts b/apps/backend/src/routes/users/route.ts index 00f7cd5..260afcd 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, not } from "drizzle-orm"; +import { and, eq, ilike, isNull, or, sql, not, inArray } from "drizzle-orm"; import { Hono } from "hono"; import { z } from "zod"; @@ -58,7 +58,7 @@ const usersRoute = new Hono() .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), + limit: z.coerce.number().int().min(1).max(1000).default(10), q: z.string().default(""), }) ), @@ -70,42 +70,53 @@ const usersRoute = new Hono() ? 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 } : {}), - company: respondents.companyName, - role: { - name: rolesSchema.name, - id: rolesSchema.id, - }, - fullCount: totalCountQuery, - }) - .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), - ilike(users.username, q), - ilike(users.email, q), - eq(users.id, q) - ) - : undefined + // Query to get unique user IDs with pagination (Sub Query) + const userIdsQuery = db + .select({ + id: users.id, + }) + .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 ) - .offset(page * limit) - .limit(limit); + ) + .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, + }, + fullCount: totalCountQuery, + }) + .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)) // using ID from subquery + .orderBy(users.createdAt); // sort by createdAt + // Group roles for each user to prevent duplication const userMap = new Map Date: Wed, 18 Sep 2024 11:13:25 +0700 Subject: [PATCH 9/9] Update: getAll in user API with search feature --- apps/backend/src/routes/users/route.ts | 265 ++++++++++-------- .../usersManagement/modals/UserFormModal.tsx | 12 +- .../_dashboardLayout/users/index.lazy.tsx | 2 +- 3 files changed, 152 insertions(+), 127 deletions(-) diff --git a/apps/backend/src/routes/users/route.ts b/apps/backend/src/routes/users/route.ts index 260afcd..9f2e289 100644 --- a/apps/backend/src/routes/users/route.ts +++ b/apps/backend/src/routes/users/route.ts @@ -41,135 +41,168 @@ 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(10), - 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"); - - // Total count for pagination - const totalCountQuery = includeTrashed - ? sql`(SELECT count(*) FROM ${users})` - : sql`(SELECT count(*) FROM ${users} WHERE ${users.deletedAt} IS NULL)`; - - // Query to get unique user IDs with pagination (Sub Query) - const userIdsQuery = db + const { includeTrashed, page, limit, q } = c.req.valid("query"); + + // Query to count total data without duplicates + const totalCountQuery = db .select({ - id: users.id, - }) - .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 - ) - ) - .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, - }, - fullCount: totalCountQuery, + 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(inArray(users.id, userIdsQuery)) // using ID from subquery - .orderBy(users.createdAt); // sort by createdAt - - - // Group roles for each user to prevent duplication - const userMap = new Map(); - - 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 duplication and roles in array form - const groupedData = Array.from(userMap.values()); - - return c.json({ - data: groupedData.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, - }, - }); + .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 + .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( @@ -270,7 +303,7 @@ const usersRoute = new Hono() .where(eq(respondents.phoneNumber, userData.phoneNumber)); if (existingUser.length > 0) { - throw notFound({ + throw forbidden({ message: "Email or username has been registered", }) } diff --git a/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx b/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx index 3d96351..c153550 100644 --- a/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx +++ b/apps/frontend/src/modules/usersManagement/modals/UserFormModal.tsx @@ -216,14 +216,6 @@ export default function UserFormModal() { disableAll: mutation.isPending, readonlyAll: formType === "detail", inputs: [ - { - type: "text", - label: "Id Pengguna", - readOnly: true, - variant: "filled", - ...form.getInputProps("id"), - hidden: !form.values.id, - }, { type: "text", label: "Nama", @@ -293,7 +285,7 @@ export default function UserFormModal() { onClick={() => navigate({ search: {} })} disabled={mutation.isPending} > - Close + Tutup {formType !== "detail" && ( )} diff --git a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx index 3c5ec15..8b6262d 100644 --- a/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx +++ b/apps/frontend/src/routes/_dashboardLayout/users/index.lazy.tsx @@ -54,7 +54,7 @@ export default function UsersPage() { if (roles && roles.length > 0) { return roles.map(role => role.name).join(", "); } - return
Tidak ada peran yang diberikan
; + return
-
; }, }),