summaryrefslogtreecommitdiff
path: root/lib/roles
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
committerjoonhoekim <26rote@gmail.com>2025-03-25 15:55:45 +0900
commit1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch)
tree8a5587f10ca55b162d7e3254cb088b323a34c41b /lib/roles
initial commit
Diffstat (limited to 'lib/roles')
-rw-r--r--lib/roles/repository.ts94
-rw-r--r--lib/roles/services.ts300
-rw-r--r--lib/roles/table/add-role-dialog.tsx308
-rw-r--r--lib/roles/table/assign-roles-sheet.tsx87
-rw-r--r--lib/roles/table/delete-roles-dialog.tsx149
-rw-r--r--lib/roles/table/role-table-toolbar-actions.tsx101
-rw-r--r--lib/roles/table/roles-table-columns.tsx223
-rw-r--r--lib/roles/table/roles-table.tsx169
-rw-r--r--lib/roles/table/update-roles-sheet.tsx331
-rw-r--r--lib/roles/userTable/assginedUsers-table-columns.tsx164
-rw-r--r--lib/roles/userTable/assignedUsers-table.tsx159
-rw-r--r--lib/roles/validations.ts80
12 files changed, 2165 insertions, 0 deletions
diff --git a/lib/roles/repository.ts b/lib/roles/repository.ts
new file mode 100644
index 00000000..99ffdf29
--- /dev/null
+++ b/lib/roles/repository.ts
@@ -0,0 +1,94 @@
+// repository.ts
+import { sql, and, eq, inArray ,desc,asc} from "drizzle-orm";
+import type { PgTransaction } from "drizzle-orm/pg-core";
+import { roles, users, userRoles, Role, roleView, RoleView } from "@/db/schema/users"; // 수정
+import db from "@/db/db";
+import { companies } from "@/db/schema/companies";
+
+export type NewRole = typeof roles.$inferInsert; // User insert 시 필요한 타입
+
+
+// (A) SELECT roles + userCount
+export async function selectRolesWithUserCount(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]
+ offset?: number,
+ limit?: number,
+ }
+) {
+
+ const { where, orderBy, offset = 0, limit = 10 } = params
+
+ const query = tx
+ .select()
+ .from(roleView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit)
+
+ const rows = await query
+ return rows
+}
+// (B) countRoles
+export async function countRoles(
+tx: PgTransaction<any, any, any>,
+ where?: ReturnType<typeof and>
+) {
+ // COUNT(*) from roles
+ const [{ count }] = await tx
+ .select({ count: sql<number>`COUNT(*)`.as("count") })
+ .from(roles)
+ .where(where ?? undefined);
+
+ return count; // number
+}
+
+export async function insertRole(
+ tx: PgTransaction<any, any, any>,
+ data: NewRole
+) {
+ return tx.insert(roles).values(data).returning();
+}
+
+export const getRoleById = async (id: number): Promise<Role | null> => {
+ const roleFouned = await db.select().from(roles).where(eq(roles.id, id)).execute();
+ if (roleFouned.length === 0) return null;
+
+ const role = roleFouned[0];
+ return role
+};
+
+
+export async function updateRole(
+ tx: PgTransaction<any, any, any>,
+ roleId: number,
+ data: Partial<Role>
+) {
+ return tx
+ .update(roles)
+ .set(data)
+ .where(eq(roles.id, roleId))
+ .returning();
+}
+
+
+export async function deleteRolesByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(roles).where(inArray(roles.id, ids));
+}
+
+export async function deleteUserRolesByIds(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+) {
+ return tx.delete(userRoles).where(inArray(userRoles.roleId, ids));
+}
+
+export async function findAllRoleView(domain?: "evcp" | "partners"): Promise<RoleView[]> {
+ return db.select().from(roleView).where(eq(roleView.domain,domain)).orderBy(asc(roleView.name));
+} \ No newline at end of file
diff --git a/lib/roles/services.ts b/lib/roles/services.ts
new file mode 100644
index 00000000..1a91d4fa
--- /dev/null
+++ b/lib/roles/services.ts
@@ -0,0 +1,300 @@
+"use server";
+
+import { revalidateTag, unstable_cache, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { permissions, Role, rolePermissions, roles, RoleView, roleView, userRoles } from "@/db/schema/users";
+import { and, or, asc, desc, ilike, eq, inArray } from "drizzle-orm";
+import { filterColumns } from "@/lib/filter-columns";
+import {
+ selectRolesWithUserCount,
+ countRoles,
+ insertRole,
+ getRoleById,
+ updateRole,
+ deleteRolesByIds,
+ deleteUserRolesByIds,
+ findAllRoleView,
+} from "./repository";
+import { CreateRoleSchema, GetRolesSchema, UpdateRoleSchema } from "./validations";
+import { getErrorMessage } from "@/lib/handle-error";
+
+interface UpsertPermissionsInput {
+ roleIds: number[];
+ permissionKeys: string[];
+ itemTitle?: string;
+}
+
+export async function getRolesWithCount(input: GetRolesSchema) {
+ // unstable_cache: 특정 키와 함께 캐싱
+ return unstable_cache(
+ async () => {
+ try {
+ // 1) pagination
+ const offset = (input.page - 1) * input.perPage;
+
+ // 2) advanced filter
+ const advancedWhere = filterColumns({
+ table: roleView, // 또는 roleView
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 3) 글로벌 검색
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ // 예: roles.name 에 ilike 검색
+ globalWhere = or(ilike(roles.name, s));
+ }
+
+ // 4) 최종 where
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // (5) 정렬
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(roleView[item.id]) : asc(roleView[item.id])
+ )
+ : [desc(roleView.created_at)];
+
+
+ // 6) 트랜잭션 + Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ // 실제 SELECT
+ const data = await selectRolesWithUserCount(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ // 전체 개수
+ const total = await countRoles(tx, finalWhere);
+
+ return { data, total };
+ });
+
+ // 7) pageCount
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러시 기본값
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["roles"], // revalidateTag("roles")로 무효화
+ }
+ )();
+}
+
+export async function createRole(input: CreateRoleSchema) {
+ unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용)
+ try {
+
+ await db.transaction(async (tx) => {
+ const [newRole] = await insertRole(tx, {
+ name: input.name,
+ domain: input.domain,
+ description: input.description ?? "",
+ companyId: input.domain === "partners" ? input.companyId ?? null : null,
+ });
+ });
+
+ revalidateTag("roles");
+
+ return { data: null, error: null };
+
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+export async function modifiRole(input: UpdateRoleSchema & { id: number }) {
+ unstable_noStore();
+
+ try {
+
+ const data = await db.transaction(async (tx) => {
+ // 1) 먼저 User 테이블 업데이트
+ const [res] = await updateRole(tx, input.id, {
+ name: input.name,
+ description: input.description,
+ domain: input.domain
+ });
+
+ return res;
+ });
+
+ // 3) 캐시 무효화
+ revalidateTag("roles");
+
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+export async function removeRoles(input: { ids: number[] }) {
+ unstable_noStore();
+
+ try {
+ await db.transaction(async (tx) => {
+ // user_roles도 있으면 먼저 삭제해야 할 수 있음
+
+ await deleteUserRolesByIds(tx, input.ids);
+ await deleteRolesByIds(tx, input.ids);
+
+ });
+
+ revalidateTag("roles");
+ revalidateTag("user-role-counts");
+ revalidateTag("users");
+
+ return { data: null, error: null };
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+
+export async function assignRolesToUsers(roleIds: number[], userIds: number[]) {
+ // Next.js 서버 액션에서 캐싱 방지
+ unstable_noStore()
+
+ try {
+ await db.transaction(async (tx) => {
+ // 1) 기존 userRoles 삭제: userIds, roleIds에 해당하는 레코드만
+ await tx
+ .delete(userRoles)
+ .where(
+ and(
+ inArray(userRoles.roleId, roleIds),
+ inArray(userRoles.userId, userIds)
+ )
+ )
+
+ // 2) 새로 삽입
+ if (roleIds.length > 0 && userIds.length > 0) {
+ const newRows = []
+ for (const rid of roleIds) {
+ for (const uid of userIds) {
+ newRows.push({ roleId: rid, userId: uid })
+ }
+ }
+ await tx.insert(userRoles).values(newRows)
+ }
+ })
+
+ // 캐시 무효화
+ revalidateTag("users")
+ revalidateTag("roles")
+
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
+ }
+}
+
+export async function getAllRoleView(domain?: "evcp" | "partners"): Promise<RoleView[]> {
+ try {
+ return await findAllRoleView(domain)
+ } catch (err) {
+ throw new Error("Failed to get roles")
+ }
+}
+
+export async function upsertPermissions(input: UpsertPermissionsInput) {
+ unstable_noStore();
+ try {
+ const { roleIds, permissionKeys, itemTitle } = input;
+ if (!roleIds.length || !permissionKeys.length) {
+ return; // nothing to do
+ }
+
+ const roleIdNums = roleIds
+
+ await db.transaction(async (tx) => {
+ for (const permKey of permissionKeys) {
+ // A) Check if permissionKey exists in "permissions" table
+ const [existingPerm] = await tx
+ .select({ id: permissions.id })
+ .from(permissions)
+ .where(eq(permissions.permissionKey, permKey))
+ .limit(1);
+
+ let permissionId: number;
+ if (!existingPerm) {
+ // Insert new permission
+ // description를 어떻게 만들지는 자유: itemTitle + permKey 등
+ const [inserted] = await tx
+ .insert(permissions)
+ .values({
+ permissionKey: permKey,
+ description: itemTitle ? `Menu: ${itemTitle} perm: ${permKey}` : permKey,
+ })
+ .returning({ id: permissions.id });
+
+ permissionId = inserted.id;
+ } else {
+ permissionId = existingPerm.id;
+ }
+
+ // B) now link (roleId, permissionId) in role_permissions
+ for (const rId of roleIdNums) {
+ // check if already exists
+ const [rp] = await tx
+ .select({ p: rolePermissions.permissionId })
+ .from(rolePermissions)
+ .where(and(eq(rolePermissions.roleId, rId), eq(rolePermissions.permissionId, permissionId)))
+ .limit(1);
+
+ if (!rp) {
+ // insert
+ await tx.insert(rolePermissions).values({
+ roleId: rId,
+ permissionId,
+ });
+ }
+ // if rp exists, skip
+ }
+ }
+ });
+
+ return { data: null, error: null };
+
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) };
+ }
+}
+
+
+export async function getMenuPermissions(
+ itemKey: string
+): Promise<{ roleId: number; permKey: string }[]> {
+ // itemKey = "alert-dialog"
+ // permKey = "alert-dialog.create", "alert-dialog.viewOwn", ...
+ const pattern = `${itemKey}.%`
+
+ // SELECT rp.role_id, p.permission_key
+ // FROM role_permissions rp
+ // JOIN permissions p ON p.id = rp.permissionId
+ // WHERE p.permission_key LIKE 'alert-dialog.%'
+ const rows = await db
+ .select({
+ roleId: rolePermissions.roleId,
+ permKey: permissions.permissionKey,
+ })
+ .from(rolePermissions)
+ .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId))
+ .where(ilike(permissions.permissionKey, pattern));
+
+ return rows;
+} \ No newline at end of file
diff --git a/lib/roles/table/add-role-dialog.tsx b/lib/roles/table/add-role-dialog.tsx
new file mode 100644
index 00000000..365daf29
--- /dev/null
+++ b/lib/roles/table/add-role-dialog.tsx
@@ -0,0 +1,308 @@
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { cn } from "@/lib/utils"
+import { toast } from "sonner"
+
+import { createRoleSchema, type CreateRoleSchema } from "../validations"
+import { createRole } from "../services"
+import { Textarea } from "@/components/ui/textarea"
+import { Company } from "@/db/schema/companies"
+import { getAllCompanies } from "@/lib/admin-users/service"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+
+
+
+const domainOptions = [
+ { value: "partners", label: "협력업체" },
+ { value: "evcp", label: "삼성중공업" },
+]
+
+export function AddRoleDialog() {
+ const [open, setOpen] = React.useState(false)
+ const [isAddPending, startAddTransition] = React.useTransition()
+ const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록
+
+ React.useEffect(() => {
+ getAllCompanies().then((res) => {
+ setCompanies(res)
+ })
+ }, [])
+
+ // react-hook-form 세팅
+ const form = useForm<CreateRoleSchema>({
+ resolver: zodResolver(createRoleSchema),
+ defaultValues: {
+ name: "",
+ domain: "evcp", // 기본값
+ description: "",
+ // companyId: null, // optional
+ },
+ })
+
+ async function onSubmit(data: CreateRoleSchema) {
+ startAddTransition(async () => {
+ const result = await createRole(data)
+ if (result.error) {
+ toast.error(`에러: ${result.error}`)
+ return
+ }
+ form.reset()
+ setOpen(false)
+ toast.success("Role added")
+ })
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ // domain이 partners일 경우 companyId 입력 필드 보이게
+ const selectedDomain = form.watch("domain")
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Role
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Create New Role</DialogTitle>
+ <DialogDescription>
+ 새 Role 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)}>
+ <div className="space-y-4 py-4">
+ {/* 1) Role Name */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. admin"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 2) Description */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Describe role"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 3) Domain Select */}
+ <FormField
+ control={form.control}
+ name="domain"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Domain</FormLabel>
+ <FormControl>
+ <Select
+ // domain이 바뀔 때마다 form state에도 반영
+ onValueChange={field.onChange}
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select Domain" />
+ </SelectTrigger>
+ <SelectContent>
+ {domainOptions.map((v, index) => (
+ <SelectItem key={index} value={v.value}>
+ {v.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 4) companyId => domain이 partners인 경우만 노출 */}
+ {selectedDomain === "partners" && (
+ <FormField
+ control={form.control}
+ name="companyId"
+ render={({ field }) => {
+ // 현재 선택된 회사 ID (number) → 문자열
+ const valueString = field.value ? String(field.value) : ""
+
+
+ // 현재 선택된 회사
+ const selectedCompany = companies.find(
+ (c) => String(c.id) === valueString
+ )
+
+ const selectedCompanyLabel = selectedCompany && `${selectedCompany.name} ${selectedCompany.taxID}`
+
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+
+ return (
+ <FormItem>
+ <FormLabel>Company</FormLabel>
+ <FormControl>
+ <Popover
+ open={popoverOpen}
+ onOpenChange={setPopoverOpen}
+ modal={true}
+ >
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ >
+ {selectedCompany
+ ? `${selectedCompany.name} ${selectedCompany.taxID}`
+ : "Select company..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput
+ placeholder="Search company..."
+ className="h-9"
+
+ />
+ <CommandList>
+ <CommandEmpty>No company found.</CommandEmpty>
+ <CommandGroup>
+ {companies.map((comp) => {
+ // string(comp.id)
+ const compIdStr = String(comp.id)
+ const label = `${comp.name}${comp.taxID}`
+ const label2 = `${comp.name} ${comp.taxID}`
+ return (
+ <CommandItem
+ key={comp.id}
+ value={label2}
+ onSelect={() => {
+ // 회사 ID를 number로
+ field.onChange(Number(comp.id))
+ setPopoverOpen(false)
+
+ }}
+ >
+ {label2}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedCompanyLabel === label2
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+ )}
+ </div>
+
+ {/* Footer */}
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isAddPending}
+ >
+ Cancel
+ </Button>
+ <Button
+ type="submit"
+ disabled={form.formState.isSubmitting || isAddPending}
+ >
+ {isAddPending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Create
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/roles/table/assign-roles-sheet.tsx b/lib/roles/table/assign-roles-sheet.tsx
new file mode 100644
index 00000000..11c6a1ff
--- /dev/null
+++ b/lib/roles/table/assign-roles-sheet.tsx
@@ -0,0 +1,87 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Loader } from "lucide-react"
+import { AssginedUserTable } from "../userTable/assignedUsers-table"
+import { assignUsersToRole } from "@/lib/users/service"
+import { RoleView } from "@/db/schema/users"
+
+export interface UpdateRoleSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ role: RoleView | null
+
+ // ★ 새로 추가: 테이블에 필요한 데이터 로딩 promise
+ assignedTablePromises: Promise<[
+ { data: any[]; pageCount: number }
+
+ ]>
+}
+
+export function AssignRolesSheet({ role, assignedTablePromises, ...props }: UpdateRoleSheetProps) {
+
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [selectedUserIds, setSelectedUserIds] = React.useState<number[]>([])
+
+ // 2) 자식에서 호출될 콜백
+ function handleSelectedChange(ids: number[]) {
+ setSelectedUserIds(ids)
+ }
+
+ async function handleAssign() {
+ startUpdateTransition(async () => {
+ if (!role) return
+ const { error } = await assignUsersToRole(role.id, selectedUserIds)
+ if (error) {
+ toast.error(error)
+ return
+ }
+ props.onOpenChange?.(false)
+ toast.success(`Assigned ${selectedUserIds.length} users!`)
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>"{role?.name}"에 유저를 할당하세요</SheetTitle>
+ <SheetDescription>
+ 현재 {role?.name}에는 {role?.user_count}명이 할당되어있습니다. 이 롤은 다음과 같습니다.<br/> {role?.description}
+ </SheetDescription>
+ </SheetHeader>
+
+ <AssginedUserTable promises={assignedTablePromises} onSelectedChange={handleSelectedChange} />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ {/* <Button disabled={isUpdatePending} onClick={onSubmitAssignUsers}> */}
+ <Button disabled={isUpdatePending} onClick={handleAssign}>
+ {isUpdatePending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ Assign
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/roles/table/delete-roles-dialog.tsx b/lib/roles/table/delete-roles-dialog.tsx
new file mode 100644
index 00000000..269bc7c3
--- /dev/null
+++ b/lib/roles/table/delete-roles-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+
+import { removeRoles } from "../services"
+import { RoleView } from "@/db/schema/users"
+
+interface DeleteRolesDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ roles: Row<RoleView>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteRolesDialog({
+ roles,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRolesDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const { error } = await removeRoles({
+ ids: roles.map((role) => Number(role.id)),
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Users deleted")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({roles.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{roles.length}</span>
+ {roles.length === 1 ? " role" : " roles"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({roles.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{roles.length}</span>
+ {roles.length === 1 ? " role" : " roles"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/roles/table/role-table-toolbar-actions.tsx b/lib/roles/table/role-table-toolbar-actions.tsx
new file mode 100644
index 00000000..66e279d6
--- /dev/null
+++ b/lib/roles/table/role-table-toolbar-actions.tsx
@@ -0,0 +1,101 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Upload } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+
+// 삭제, 추가 다이얼로그
+
+// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import
+import { importTasksExcel } from "@/lib/tasks/service" // 예시
+import { AddRoleDialog } from "./add-role-dialog"
+import { DeleteRolesDialog } from "./delete-roles-dialog"
+import { RoleView } from "@/db/schema/users"
+
+interface RoleTableToolbarActionsProps {
+ table: Table<RoleView>
+}
+
+export function RoleTableToolbarActions({ table }: RoleTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일이 선택되었을 때 처리
+ async function onFileChange(event: React.ChangeEvent<HTMLInputElement>) {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ // 파일 초기화 (동일 파일 재업로드 시에도 onChange가 트리거되도록)
+ event.target.value = ""
+
+ // 서버 액션 or API 호출
+ try {
+ // 예: 서버 액션 호출
+ const { errorFile, errorMessage } = await importTasksExcel(file)
+
+ if (errorMessage) {
+ toast.error(errorMessage)
+ }
+ if (errorFile) {
+ // 에러 엑셀을 다운로드
+ const url = URL.createObjectURL(errorFile)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "errors.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+ } else {
+ // 성공
+ toast.success("Import success")
+ // 필요 시 revalidateTag("tasks") 등
+ }
+
+ } catch (err) {
+ toast.error("파일 업로드 중 오류가 발생했습니다.")
+
+ }
+ }
+
+ function handleImportClick() {
+ // 숨겨진 <input type="file" /> 요소를 클릭
+ fileInputRef.current?.click()
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeleteRolesDialog
+ roles={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ <AddRoleDialog />
+
+
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "roles",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/roles/table/roles-table-columns.tsx b/lib/roles/table/roles-table-columns.tsx
new file mode 100644
index 00000000..3a491585
--- /dev/null
+++ b/lib/roles/table/roles-table-columns.tsx
@@ -0,0 +1,223 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { UserWithCompanyAndRoles } from "@/types/user"
+import { getErrorMessage } from "@/lib/handle-error"
+
+import { modifiUser } from "@/lib/admin-users/service"
+import { toast } from "sonner"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { MultiSelect } from "@/components/ui/multi-select"
+import { roleColumnsConfig } from "@/config/roleColumnsConfig"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
+import { RoleView } from "@/db/schema/users"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RoleView> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RoleView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<RoleView> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<RoleView> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "user" })}
+ >
+ User Assignment
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<User>[] }
+ const groupMap: Record<string, ColumnDef<RoleView>[]> = {}
+
+ roleColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<RoleView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "created_at") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "domain") {
+ const dateVal = cell.getValue() as string
+ return (
+ <div className="flex w-[6.25rem] items-center">
+ {dateVal === "evcp"?"삼성중공업":"협력업체"}
+ </div>)
+ }
+
+ if (cfg.id === "user_count") {
+ const dateVal = cell.getValue() as number
+ return (
+ <div className="flex w-[3.25rem] items-center">
+ {dateVal}
+ </div>)
+ }
+
+ if (cfg.id === "description") {
+ const val = cell.getValue() as string;
+ return (
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="line-clamp-2 w-[400px]">
+ {val}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ {val}
+ </TooltipContent>
+ </Tooltip>
+ );
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<RoleView>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/roles/table/roles-table.tsx b/lib/roles/table/roles-table.tsx
new file mode 100644
index 00000000..cd7c2a3b
--- /dev/null
+++ b/lib/roles/table/roles-table.tsx
@@ -0,0 +1,169 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+
+import { getRolesWithCount } from "@/lib/roles/services"
+import { getColumns } from "./roles-table-columns"
+import { RoleTableToolbarActions } from "./role-table-toolbar-actions"
+import { UpdateRolesSheet } from "./update-roles-sheet"
+import { AssignRolesSheet } from "./assign-roles-sheet"
+import { getUsersAll } from "@/lib/users/service"
+import { DeleteRolesDialog } from "./delete-roles-dialog"
+import { RoleView } from "@/db/schema/users"
+
+
+interface RolesTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getRolesWithCount>>,
+ ]
+ >
+ promises2: Promise<
+ [
+ Awaited<ReturnType<typeof getUsersAll>>,
+ ]
+>
+}
+
+export function RolesTable({ promises ,promises2 }: RolesTableProps) {
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<RoleView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ *
+ * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
+ *
+ * Each `option` object has the following properties:
+ * @prop {string} label - The label for the filter option.
+ * @prop {string} value - The value for the filter option.
+ * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
+ * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
+ */
+ const filterFields: DataTableFilterField<RoleView>[] = [
+ {
+ id: "name",
+ label: "Role Name",
+ placeholder: "Filter role name...",
+ },
+
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ * These fields provide more complex filtering options compared to the regular filterFields.
+ *
+ * Key differences from regular filterFields:
+ * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
+ * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
+ * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
+ * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<RoleView>[] = [
+ {
+ id: "name",
+ label: "Role Name",
+ type: "text",
+ },
+
+ {
+ id: "domain",
+ label: "룰 도메인",
+ type: "text",
+ },
+
+ {
+ id: "company_name",
+ label: "회사명",
+ type: "text",
+ },
+
+ {
+ id: "created_at",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "created_at", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RoleTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+ <UpdateRolesSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ role={rowAction?.row.original ?? null}
+ />
+
+ <AssignRolesSheet
+ open={rowAction?.type === "user"}
+ onOpenChange={() => setRowAction(null)}
+ role={rowAction?.row.original ?? null}
+ assignedTablePromises={promises2}
+ />
+
+ <DeleteRolesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ roles={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+
+
+
+ </>
+ )
+}
diff --git a/lib/roles/table/update-roles-sheet.tsx b/lib/roles/table/update-roles-sheet.tsx
new file mode 100644
index 00000000..cbe20352
--- /dev/null
+++ b/lib/roles/table/update-roles-sheet.tsx
@@ -0,0 +1,331 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Select,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectValue,
+ SelectGroup,
+} from "@/components/ui/select"
+// import your MultiSelect or other role selection
+import { RoleView, userRoles, type UserView } from "@/db/schema/users"
+import { getAllCompanies, modifiUser } from "@/lib/admin-users/service"
+import { modifiRole } from "../services"
+import { updateRoleSchema, UpdateRoleSchema } from "../validations"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandList,
+ CommandGroup,
+ CommandItem,
+ CommandEmpty,
+} from "@/components/ui/command"
+import { Company } from "@/db/schema/companies"
+import { cn } from "@/lib/utils"
+
+export interface UpdateRoleSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ role: RoleView | null
+}
+
+const domainOptions = [
+ { value: "partners", label: "협력업체" },
+ { value: "evcp", label: "삼성중공업" },
+]
+
+
+
+export function UpdateRolesSheet({ role, ...props }: UpdateRoleSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록
+
+ React.useEffect(() => {
+ getAllCompanies().then((res) => {
+ setCompanies(res)
+ })
+ }, [])
+
+
+ // 1) RHF 설정
+ const form = useForm<UpdateRoleSchema>({
+ resolver: zodResolver(updateRoleSchema),
+ defaultValues: {
+ name: role?.name ?? "",
+ description: role?.description ?? "",
+ domain: (role?.domain === "evcp" || role?.domain === "partners")
+ ? role?.domain
+ : undefined,
+ },
+ })
+
+ // 2) user prop 바뀔 때마다 form.reset
+ React.useEffect(() => {
+ if (role) {
+ form.reset({
+ name: role.name,
+ description: role.description,
+ domain: role.domain as "evcp" | "partners" | undefined,
+ })
+ }
+ }, [role, form])
+
+ const selectedDomain = form.watch("domain")
+
+
+ // 3) onSubmit
+ async function onSubmit(input: UpdateRoleSchema) {
+ startUpdateTransition(async () => {
+ if (!role) return
+
+ const { error } = await modifiRole({
+ id: role.id, // user.userId
+ ...input,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ // 성공 시
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("User updated successfully!")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update user</SheetTitle>
+ <SheetDescription>
+ Update the user details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+
+ {/* 4) RHF Form */}
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* name */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="e.g. admin"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Role Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Describe role"
+ className="resize-none"
+ {...field}
+ />
+ </FormControl>
+ {/* <FormDescription>
+ You can <span>@mention</span> other users and organizations to
+ link to them.
+ </FormDescription> */}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+
+ {/* language Select */}
+ <FormField
+ control={form.control}
+ name="domain"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Domain</FormLabel>
+ <FormControl>
+ <Select
+ onValueChange={field.onChange}
+ // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로
+ value={field.value}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select Domain" />
+ </SelectTrigger>
+ <SelectContent>
+ {domainOptions.map((v, index) => (
+ <SelectItem key={index} value={v.value}>
+ {v.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {selectedDomain === "partners" && (
+ <FormField
+ control={form.control}
+ name="company_id"
+ render={({ field }) => {
+ // 현재 선택된 회사 ID (number) → 문자열
+ const valueString = field.value ? String(field.value) : ""
+
+
+ // 현재 선택된 회사
+ const selectedCompany = companies.find(
+ (c) => String(c.id) === valueString
+ )
+
+ const selectedCompanyLabel = selectedCompany && `${selectedCompany.name} ${selectedCompany.taxID}`
+
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+
+ return (
+ <FormItem>
+ <FormLabel>Company</FormLabel>
+ <FormControl>
+ <Popover
+ open={popoverOpen}
+ onOpenChange={setPopoverOpen}
+ modal={true}
+ >
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ >
+ {selectedCompany
+ ? `${selectedCompany.name} ${selectedCompany.taxID}`
+ : "Select company..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-full p-0" side="bottom" >
+ <Command>
+ <CommandInput
+ placeholder="Search company..."
+ className="h-9"
+
+ />
+ <CommandList>
+ <CommandEmpty>No company found.</CommandEmpty>
+ <CommandGroup>
+ {companies.map((comp) => {
+ // string(comp.id)
+ const compIdStr = String(comp.id)
+ const label = `${comp.name}${comp.taxID}`
+ const label2 = `${comp.name} ${comp.taxID}`
+ return (
+ <CommandItem
+ key={comp.id}
+ value={label2}
+ onSelect={() => {
+ // 회사 ID를 number로
+ field.onChange(Number(comp.id))
+ setPopoverOpen(false)
+
+ }}
+ >
+ {label2}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedCompanyLabel === label2
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+ )}
+
+ {/* 5) Footer: Cancel, Save */}
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ <Button type="submit" disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/roles/userTable/assginedUsers-table-columns.tsx b/lib/roles/userTable/assginedUsers-table-columns.tsx
new file mode 100644
index 00000000..b317a465
--- /dev/null
+++ b/lib/roles/userTable/assginedUsers-table-columns.tsx
@@ -0,0 +1,164 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+import { userRoles, type UserView } from "@/db/schema/users"
+
+import { formatDate } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { UserWithCompanyAndRoles } from "@/types/user"
+import { getErrorMessage } from "@/lib/handle-error"
+
+import { modifiUser } from "@/lib/admin-users/service"
+import { toast } from "sonner"
+
+import { euserColumnsConfig } from "@/config/euserColumnsConfig"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { MultiSelect } from "@/components/ui/multi-select"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<UserView> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<UserView>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<UserView> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<User>[] }
+ const groupMap: Record<string, ColumnDef<UserView>[]> = {}
+
+ euserColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<UserView> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "created_at") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "roles") {
+ const roleValues = row.original.roles;
+ return (
+ <div className="flex flex-wrap gap-1">
+ {roleValues.map((v) => (
+ v === null?"":
+
+ <Badge key={v} variant="outline">
+ {v}
+ </Badge>
+
+ ))}
+ </div>
+ );
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<UserView>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+
+ ]
+} \ No newline at end of file
diff --git a/lib/roles/userTable/assignedUsers-table.tsx b/lib/roles/userTable/assignedUsers-table.tsx
new file mode 100644
index 00000000..5ac52f13
--- /dev/null
+++ b/lib/roles/userTable/assignedUsers-table.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import * as React from "react"
+import { userRoles , type UserView} from "@/db/schema/users"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
+
+import type {
+ getAllRoles, getUsersAll, getUsersEVCP
+} from "@/lib//users/service"
+import { getColumns } from "./assginedUsers-table-columns"
+
+
+
+interface UsersTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getUsersAll>>
+
+ ]
+ >
+ onSelectedChange:any
+}
+
+export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps) {
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<UserView> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ *
+ * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
+ *
+ * Each `option` object has the following properties:
+ * @prop {string} label - The label for the filter option.
+ * @prop {string} value - The value for the filter option.
+ * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
+ * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
+ */
+ const filterFields: DataTableFilterField<UserView>[] = [
+ {
+ id: "user_email",
+ label: "Email",
+ placeholder: "Filter email...",
+ },
+
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ * These fields provide more complex filtering options compared to the regular filterFields.
+ *
+ * Key differences from regular filterFields:
+ * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
+ * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
+ * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
+ * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<UserView>[] = [
+ {
+ id: "user_name",
+ label: "User Name",
+ type: "text",
+ },
+ {
+ id: "user_email",
+ label: "Email",
+ type: "text",
+ },
+
+
+ {
+ id: "created_at",
+ label: "Created at",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "created_at", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => `${originalRow.user_id}`,
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const rowSelection = table.getState().rowSelection
+
+ function shallowEqual(arrA: number[], arrB: number[]): boolean {
+ if (arrA.length !== arrB.length) return false
+ for (let i = 0; i < arrA.length; i++) {
+ if (arrA[i] !== arrB[i]) return false
+ }
+ return true
+ }
+ const previousUserIdsRef = React.useRef<number[]>([])
+
+ React.useEffect(() => {
+ // 선택 상태가 바뀌었을 때만 실행
+ if (!onSelectedChange) return
+
+ const rows = table.getSelectedRowModel().rows
+ const newUserIds = rows.map((r) => r.original.user_id)
+
+ // 이전/새 userIds 비교
+ if (!shallowEqual(previousUserIdsRef.current, newUserIds)) {
+ previousUserIdsRef.current = newUserIds
+ onSelectedChange(newUserIds)
+ }
+ }, [rowSelection, onSelectedChange])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+
+
+ </>
+ )
+}
diff --git a/lib/roles/validations.ts b/lib/roles/validations.ts
new file mode 100644
index 00000000..10cfe33b
--- /dev/null
+++ b/lib/roles/validations.ts
@@ -0,0 +1,80 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { RoleView, users } from "@/db/schema/users";
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<RoleView>().withDefault([
+ { id: "created_at", desc: true },
+ ]),
+ name: parseAsString.withDefault(""),
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+ })
+
+ export const createRoleSchema = z.object({
+ name: z.string().min(1),
+ description: z.string().min(1),
+ companyId:z
+ .number()
+ .int()
+ .positive()
+ .nullish(), // number | nullish
+ domain: z.enum(users.domain.enumValues), // "evcp" | "partners"
+ });
+
+ export const createRoleAssignmentSchema = z.object({
+ evcpRoles:z.array(z.string()),
+
+ });
+
+
+
+ export const updateRoleSchema = z.object({
+ name: z.string().min(1),
+ description: z.string().min(1),
+ domain: z.enum(users.domain.enumValues), // "evcp" | "partners"
+ company_id: z
+ .number()
+ .int()
+ .positive()
+ .nullish(), // number | nullish
+ }).superRefine((data, ctx) => {
+ // domain이 partners 이면 companyId는 필수
+ if (data.domain === "partners" && !data.company_id) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["company_id"],
+ message: "협력업체(domain=partners)일 경우 companyId는 필수입니다.",
+ })
+ }
+
+ // domain이 evcp 이면 companyId는 null이어야 한다면(정책상)
+ if (data.domain === "evcp" && data.company_id) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["company_id"],
+ message: "domain=evcp이면 companyId를 입력할 수 없습니다.",
+ })
+ }
+ })
+
+// TypeScript에서 사용할 타입
+export type GetRolesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+export type CreateRoleSchema = z.infer<typeof createRoleSchema>
+export type UpdateRoleSchema = z.infer<typeof updateRoleSchema>
+export type CreateRoleAssignmentSchema = z.infer<typeof createRoleAssignmentSchema> \ No newline at end of file