diff options
| author | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-03-25 15:55:45 +0900 |
| commit | 1a2241c40e10193c5ff7008a7b7b36cc1d855d96 (patch) | |
| tree | 8a5587f10ca55b162d7e3254cb088b323a34c41b /lib/admin-users | |
initial commit
Diffstat (limited to 'lib/admin-users')
| -rw-r--r-- | lib/admin-users/repository.ts | 171 | ||||
| -rw-r--r-- | lib/admin-users/service.ts | 531 | ||||
| -rw-r--r-- | lib/admin-users/table/add-ausers-dialog.tsx | 348 | ||||
| -rw-r--r-- | lib/admin-users/table/ausers-table-columns.tsx | 228 | ||||
| -rw-r--r-- | lib/admin-users/table/ausers-table-floating-bar.tsx | 389 | ||||
| -rw-r--r-- | lib/admin-users/table/ausers-table-toolbar-actions.tsx | 118 | ||||
| -rw-r--r-- | lib/admin-users/table/ausers-table.tsx | 180 | ||||
| -rw-r--r-- | lib/admin-users/table/delete-ausers-dialog.tsx | 149 | ||||
| -rw-r--r-- | lib/admin-users/table/update-auser-sheet.tsx | 225 | ||||
| -rw-r--r-- | lib/admin-users/validations.ts | 65 |
10 files changed, 2404 insertions, 0 deletions
diff --git a/lib/admin-users/repository.ts b/lib/admin-users/repository.ts new file mode 100644 index 00000000..aff2da28 --- /dev/null +++ b/lib/admin-users/repository.ts @@ -0,0 +1,171 @@ +import db from "@/db/db"; +import { users, userRoles,userView,roles, type User, type UserRole, type UserView, Role } from "@/db/schema/users"; +import { companies, type Company } from "@/db/schema/companies"; +import { + eq, + inArray, + asc, + desc, + and, + count, + gt, + sql, + SQL, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; +import { Vendor, vendors } from "@/db/schema/vendors"; + +// ============================================================ +// 타입 +// ============================================================ + +export type NewUser = typeof users.$inferInsert; // User insert 시 필요한 타입 +export type NewUserRole = typeof userRoles.$inferInsert; // UserRole insert 시 필요한 타입 +export type NewCompany = typeof companies.$inferInsert; // Company insert 시 필요한 타입 + + + +export async function selectUsersWithCompanyAndRoles( + 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 + + // 1) 쿼리 빌더 생성 + const queryBuilder = tx + .select() + .from(userView) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit) + + const rows = await queryBuilder + return rows +} + + +/** 총 개수 count */ +export async function countUsers( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(userView).where(where); + return res[0]?.count ?? 0; +} + +export async function groupByCompany( + tx: PgTransaction<any, any, any>, +) { + return tx + .select({ + companyId: users.companyId, + count: count(), + }) + .from(users) + .groupBy(users.companyId) + .having(gt(count(), 0)); +} + +export async function groupByRole(tx: PgTransaction<any, any, any>) { + return tx + .select({ + roleId: userRoles.roleId, + count: sql<number>`COUNT(*)`.as("count"), + }) + .from(users) + .leftJoin(userRoles, eq(userRoles.userId, users.id)) + .leftJoin(roles, eq(roles.id, userRoles.roleId)) + .groupBy(userRoles.roleId, roles.id, roles.name) + .having(gt(sql<number>`COUNT(*)` /* 또는 count()와 동일 */, 0)); +} + +export async function insertUser( + tx: PgTransaction<any, any, any>, + data: NewUser +) { + return tx.insert(users).values(data).returning(); +} + +export async function insertUserRole( + tx: PgTransaction<any, any, any>, + data: NewUserRole +) { + return tx.insert(userRoles).values(data).returning(); +} + +export async function updateUser( + tx: PgTransaction<any, any, any>, + userId: number, + data: Partial<User> +) { + return tx + .update(users) + .set(data) + .where(eq(users.id, userId)) + .returning(); +} + +/** 복수 업데이트 */ +export async function updateUsers( + tx: PgTransaction<any, any, any>, +ids: number[], +data: Partial<User> +) { +return tx + .update(users) + .set(data) + .where(inArray(users.id, ids)) + .returning({ companyId: users.companyId }); +} + +export async function deleteRolesByUserId( + tx: PgTransaction<any, any, any>, + userId: number +) { + return tx.delete(userRoles).where(eq(userRoles.userId, userId)); +} + + +export async function deleteRolesByUserIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(userRoles).where(inArray(userRoles.userId, ids)); +} + +export async function deleteUserById( + tx: PgTransaction<any, any, any>, + userId: number +) { + return tx.delete(users).where(eq(users.id, userId)); +} + + +export async function deleteUsersByIds( + tx: PgTransaction<any, any, any>, + ids: number[] +) { + return tx.delete(users).where(inArray(users.id, ids)); +} + +export async function findAllCompanies(): Promise<Vendor[]> { + return db.select().from(vendors).orderBy(asc(vendors.vendorName)); +} + +export async function findAllRoles(): Promise<Role[]> { + return db.select().from(roles).where(eq(roles.domain ,'partners')).orderBy(asc(roles.name)); +} + +export const getUserById = async (id: number): Promise<UserView | null> => { + const userFouned = await db.select().from(userView).where(eq(userView.user_id, id)).execute(); + if (userFouned.length === 0) return null; + + const user = userFouned[0]; + return user +}; diff --git a/lib/admin-users/service.ts b/lib/admin-users/service.ts new file mode 100644 index 00000000..5d738d38 --- /dev/null +++ b/lib/admin-users/service.ts @@ -0,0 +1,531 @@ +"use server"; + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import logger from '@/lib/logger'; + +import { Role, roles, users, userView, type User, type UserView } from "@/db/schema/users"; // User 테이블 +import { type Company } from "@/db/schema/companies"; // User 테이블 +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; + +// 레포지토리 함수들 (예시) - 아래처럼 작성했다고 가정 +import { + selectUsersWithCompanyAndRoles, + countUsers, + insertUser, + insertUserRole, + updateUser, deleteRolesByUserId, deleteRolesByUserIds, + deleteUserById, + deleteUsersByIds, + groupByCompany, + groupByRole, + findAllCompanies, getUserById, updateUsers, + findAllRoles +} from "./repository"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +// types +import type { CreateUserSchema, UpdateUserSchema, GetUsersSchema } from "./validations"; + +import { sendEmail } from "@/lib//mail/sendEmail"; +import { Vendor } from "@/db/schema/vendors"; + +/** + * 복잡한 조건으로 User 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ + + +export async function getUsers(input: GetUsersSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: userView, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(userView.user_name, s), + ilike(userView.user_email, s), + ilike(userView.company_name, s) + ); + } + + // (3) 디폴트 domainWhere = eq(userView.domain, "partners") + // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X + let domainWhere; + const hasDomainFilter = input.filters?.some((f) => f.id === "user_domain"); + if (!hasDomainFilter) { + domainWhere = eq(userView.user_domain, "partners"); + } + + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, domainWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(userView[item.id]) : asc(userView[item.id]) + ) + : [desc(users.createdAt)]; + + // ... + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsersWithCompanyAndRoles(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countUsers(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["users"], + } + )(); +} + +export async function findUserById(id: number) { + try { + logger.info({ id }, 'Fetching user by ID'); + const user = await getUserById(id); + if (!user) { + logger.warn({ id }, 'User not found'); + } else { + logger.debug({ user }, 'User fetched successfully'); + } + return user; + } catch (error) { + logger.error({ error }, 'Error fetching user by ID'); + throw new Error('Failed to fetch user'); + } +}; + +/** + * User 생성 + * 필요 시 companyId, roles, etc. 함께 처리 + */ +// export async function createUser(input: CreateUserSchema & { language?: string }) { +// unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용) +// try { +// const userLang = input.language || "en"; // 클라이언트가 안 주면 기본 "en" +// // 예시 subject 분기 +// const subject = +// userLang === "ko" +// ? "[eVCP] 어드민 계정이 생성되었습니다." +// : "[eVCP] Admin Account Created"; + +// const loginUrl = +// userLang === "ko" +// ? "http://3.36.56.124:3000/ko/login" +// : "http://3.36.56.124:3000/en/login"; + +// // 실제 sendEmail +// await sendEmail({ +// to: input.email, +// subject, +// template: "admin-created", +// context: { +// name: input.name, +// loginUrl, // 위에서 분기한 URL +// language: userLang, // 템플릿에서 {{t ... lng=language}} 처럼 쓸 수도 +// }, +// }); + +// await db.transaction(async (tx) => { +// // insertUser는 단건 생성 +// const [newUser] = await insertUser(tx, { +// name: input.name, +// email: input.email, +// domain: input.domain, +// companyId: input.companyId ?? null, +// // 기타 필요한 필드 +// }); + +// // 만약 roles를 함께 생성하려면, +// await insertUserRole(tx, { userId: newUser.id, roleId: Number(r) }); +// } +// }); + +// // 캐시 무효화 +// revalidateTag("users"); +// revalidateTag("user-company-counts"); + + + +// return { data: null, error: null }; +// } catch (err) { +// return { data: null, error: getErrorMessage(err) }; +// } +// } + +export async function createAdminUser(input: CreateUserSchema & { language?: string }) { + unstable_noStore(); // Next.js 캐싱 방지 + + try { + // 예) 관리자 메일 알림 로직 + // roles에 'Vendor Admin'을 넣을 거라면, 사실상 input.roles.includes("admin") 체크 대신 + // 아래에서 직접 메일 보내도 됨. 질문 예시대로 유지하겠습니다. + const userLang = input.language || "en"; + const subject = userLang === "ko" + ? "[eVCP] 어드민 계정이 생성되었습니다." + : "[eVCP] Admin Account Created"; + + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + + const loginUrl = userLang === "ko" + ? `${baseUrl}/ko/partners` + : `${baseUrl}/en/partners`; + + await sendEmail({ + to: input.email, + subject, + template: "admin-created", // 예: nodemailer + handlebars 등 + context: { + name: input.name, + loginUrl, + language: userLang, + }, + }); + + // 트랜잭션 시작 + await db.transaction(async (tx) => { + // 1. 먼저 roles 테이블에서 name = "Vendor Admin" AND domain = input.domain 인 것을 찾는다. + let [vendorAdminRole] = await tx + .select() + .from(roles) + .where( + and( + eq(roles.name, "Vendor Admin"), + eq(roles.domain, input.domain), + eq(roles.companyId, input.companyId as number), + ) + ) + .limit(1); + + // 2. 만약 없다면, 새롭게 생성한다. + if (!vendorAdminRole) { + // companyId나 description 등은 필요에 따라 조정 + const insertedRoles = await tx + .insert(roles) + .values({ + name: "Vendor Admin", + domain: input.domain, + companyId: input.companyId ?? null, + description: "Auto created Vendor Admin role", + }) + .returning(); + vendorAdminRole = insertedRoles[0]; // 방금 insert한 row + } + + // 3. 유저 생성 + const [newUser] = await insertUser(tx, { + name: input.name, + email: input.email, + domain: input.domain, + companyId: input.companyId ?? null, + // 기타 필요한 필드 추가 + }); + + // 4. Vendor Admin role을 user_roles 에 할당 (반복문 없이 단일 insert) + await insertUserRole(tx, { + userId: newUser.id, + roleId: vendorAdminRole.id, // Number()로 캐스팅할 필요 없이 정수로 관리한다고 가정 + }); + }); + + // 캐시 무효화 + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 회사별 유저 개수 groupBy + */ +export async function getUserCountGroupByCompany() { + return unstable_cache( + async () => { + try { + // 예: { [companyId: number]: number } + const result = await db.transaction(async (tx) => { + const rows = await groupByCompany(tx); + // groupByCompany(tx): SELECT companyId, COUNT(*) FROM users GROUP BY companyId HAVING COUNT(*) > 0 + // 예: [{ companyId: 1, count: 10 }, { companyId: 2, count: 3 }, ...] + + // reduce해서 {1: 10, 2: 3, ...} 형태로 만들거나 그대로 반환할 수 있음 + const obj: Record<number, number> = {}; + for (const row of rows) { + if (row.companyId !== null) { + obj[row.companyId] = row.count; + } else { + // companyId가 null인 유저 수 + obj[-1] = (obj[-1] ?? 0) + row.count; + } + } + return obj; + }); + return result; + } catch (err) { + return {}; + } + }, + ["user-company-counts"], + { + revalidate: 3600, + } + )(); +} + +/** + * 롤별 유저 개수 groupBy + */ +export async function getUserCountGroupByRole() { + return unstable_cache( + async () => { + try { + const result = await db.transaction(async (tx) => { + const rows = await groupByRole(tx); + + const obj: Record<number, number> = {}; + for (const row of rows) { + if (row.roleId !== null) { + obj[row.roleId] = row.count; + } else { + // roleId가 null인 유저 수 + obj[-1] = (obj[-1] ?? 0) + row.count; + } + } + return obj; + }); + + // 여기서 result를 반환해 줘야 함! + return result; + } catch (err) { + console.error("getUserCountGroupByRole error:", err); + return {}; + } + }, + ["user-role-counts"], + { + revalidate: 3600, + } + )(); +} +/** + * 단건 업데이트 + */ +export async function modifiUser(input: UpdateUserSchema & { id: number } & { language?: string }) { + unstable_noStore(); + + try { + + const oldUser = await getUserById(input.id) + const oldEmail = oldUser?.user_email ?? null; + + const data = await db.transaction(async (tx) => { + // 1) 먼저 User 테이블 업데이트 + const [res] = await updateUser(tx, input.id, { + name: input.name, + companyId: input.companyId, + email: input.email, + }); + + // 2) roles가 함께 왔다면, 기존 roles 삭제 → 새 roles 삽입 + if (input.roles) { + // 기존 roles 삭제 + await deleteRolesByUserId(tx, input.id); + + // 새 roles 삽입 + for (const r of input.roles) { + await insertUserRole(tx, { + userId: input.id, + roleId: Number(r), + }); + } + } + + return res; + }); + + // 3) 캐시 무효화 + revalidateTag("users"); + + // 4) 이메일이 변경되었고, roles 중에 "admin"이 있다면 → 메일 발송 + const isEmailChanged = oldEmail && input.email && oldEmail !== input.email; + const hasAdminRole = input.roles?.includes("admin") ?? false; + + if (isEmailChanged && hasAdminRole && input.email) { + await sendEmail({ + to: input.email, + subject: "[EVCP] Admin Email Changed", + template: "admin-email-changed", + context: { + name: input.name, + oldEmail, + newEmail: input.email, + language: input.language ?? "en", + }, + }); + } + + // 예: companyId 변경 시 회사별 count도 다시 계산해야 하는 경우 + if (data.companyId === input.companyId) { + revalidateTag("user-company-counts"); + } + + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + + +/** 복수 업데이트 */ +export async function modifiUsers(input: { + ids: number[]; // 업데이트 대상 유저 ID 배열 + companyId?: User["companyId"]; // 회사 ID (있으면 업데이트) + roles?: UserView["roles"]; // 새 roles 배열 (있으면 업데이트) +}) { + unstable_noStore() // Next.js 서버 액션 캐싱 방지 + + try { + await db.transaction(async (tx) => { + // 1) 회사 정보 업데이트 + if (typeof input.companyId !== "undefined") { + // companyId가 주어졌으면, 해당 사용자들의 companyId 변경 + await updateUsers(tx, input.ids, { companyId: input.companyId }) + } + + // 2) roles 업데이트 + // (있으면 기존 roles 삭제 → 새 roles 삽입) + if (Array.isArray(input.roles)) { + // (a) 기존 roles 전부 삭제 + await deleteRolesByUserIds(tx, input.ids) + + // (b) 새 roles 삽입 + for (const userId of input.ids) { + for (const r of input.roles) { + await insertUserRole(tx, { + userId, + roleId: Number(r), + }) + } + } + } + }) + + // 캐시 무효화 + revalidateTag("users") + + return { data: null, error: null } + } catch (err) { + return { data: null, error: getErrorMessage(err) } + } +} +/** + * 단건 삭제 + */ +export async function removeUser(input: { id: number }) { + unstable_noStore(); + + try { + await db.transaction(async (tx) => { + // 유저 삭제 + await deleteRolesByUserId(tx, input.id); + await deleteUserById(tx, input.id); + // roles, otps 등도 함께 삭제해야 하면 여기서 처리 + }); + + // 캐시 무효화 + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** + * 복수 삭제 + */ +export async function removeUsers(input: { ids: number[] }) { + unstable_noStore(); + + try { + await db.transaction(async (tx) => { + // user_roles도 있으면 먼저 삭제해야 할 수 있음 + await deleteRolesByUserIds(tx, input.ids); + await deleteUsersByIds(tx, input.ids); + }); + + revalidateTag("users"); + revalidateTag("user-company-counts"); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +export async function getAllCompanies(): Promise<Vendor[]> { + try { + return await findAllCompanies(); // Company[] + } catch (err) { + throw new Error("Failed to get companies"); + } +} + +export async function getAllRoles(): Promise<Role[]> { + try { + return await findAllRoles(); + } catch (err) { + throw new Error("Failed to get roles"); + } +} + +/** + * 이미 해당 이메일이 users 테이블에 존재하는지 확인하는 함수 + * @param email 확인할 이메일 + * @returns boolean - 존재하면 true, 없으면 false + */ +export async function checkEmailExists(email: string): Promise<boolean> { + const result = await db + .select({ id: users.id }) // 굳이 모든 컬럼 필요 없으니 id만 + .from(users) + .where(eq(users.email, email)) + .limit(1); + + return result.length > 0; // 1건 이상 있으면 true +} diff --git a/lib/admin-users/table/add-ausers-dialog.tsx b/lib/admin-users/table/add-ausers-dialog.tsx new file mode 100644 index 00000000..dd29c190 --- /dev/null +++ b/lib/admin-users/table/add-ausers-dialog.tsx @@ -0,0 +1,348 @@ +"use client" + +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" + +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Role, userRoles } from "@/db/schema/users" +import { createUserSchema, type CreateUserSchema } from "@/lib/admin-users/validations" +import { createAdminUser, getAllCompanies, getAllRoles } from "@/lib/admin-users/service" +import { type Company } from "@/db/schema/companies" +import { MultiSelect } from "@/components/ui/multi-select" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { Vendor } from "@/db/schema/vendors" + +const languageOptions = [ + { value: "ko", label: "한국어" }, + { value: "en", label: "English" }, +] + + +export function AddUserDialog() { + const [open, setOpen] = React.useState(false) + const [companies, setCompanies] = React.useState<Vendor[]>([]) // 회사 목록 + const [roles, setRoles] = React.useState<Role[]>([]) + const [isAddPending, startAddTransition] = React.useTransition() + + + + React.useEffect(() => { + // 회사 목록 불러오기 (예시) + getAllCompanies().then((res) => { + setCompanies(res) + }) + + getAllRoles().then((res) => { + setRoles(res) + }) + }, []) + + // react-hook-form 세팅 + const form = useForm<CreateUserSchema & { language?: string }>({ + resolver: zodResolver(createUserSchema), + defaultValues: { + name: "", + email: "", + companyId: null, + language:'en', + // roles는 array<string>, 여기서는 단일 선택 시 [role]로 담음 + roles: [], + domain:'partners' + // domain, etc. 필요하다면 추가 + }, + }) + + + async function onSubmit(data: CreateUserSchema & { language?: string }) { + data.domain = "partners" + + // 만약 단일 Select로 role을 정했다면, data.roles = ["manager"] 이런 식 + startAddTransition(async ()=> { + const result = await createAdminUser(data) + if (result.error) { + toast.error(`에러: ${result.error}`) + return + } + form.reset() + setOpen(false) + toast.success("User added") + }) + + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add User + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>Create New User</DialogTitle> + <DialogDescription> + 새 User 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + {/* 사용자 이름 */} + <FormField + control={form.control} + name="name" + render={({ field }) => ( + <FormItem> + <FormLabel>User Name</FormLabel> + <FormControl> + <Input + placeholder="e.g. dujin" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 이메일 */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input + placeholder="e.g. user@example.com" + type="email" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 회사 선택 (companyId) */} + <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.vendorName} ${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.vendorName} ${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.vendorName}${comp.taxId}` + const label2 = `${comp.vendorName} ${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> + ) + }} + /> + {/* Role (Vendor Admin) - 읽기 전용 */} + <FormField + control={form.control} + name="roles" // 실제 필드: z.array(z.string()) + render={({ field }) => ( + <FormItem> + <FormLabel>Role</FormLabel> + {/* UI에선 그냥 Vendor Admin이라고 표시만 (disabled) */} + <FormControl> + <Input + readOnly + disabled + value="Vendor Admin" + className="bg-gray-50 text-gray-500" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* language Select */} + <FormField + control={form.control} + name="language" + render={({ field }) => ( + <FormItem> + <FormLabel>Language</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로 + value={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select language" /> + </SelectTrigger> + <SelectContent> + {languageOptions.map((v, index) => ( + <SelectItem key={index} value={v.value}> + {v.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + disabled={isAddPending} + > + {isAddPending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 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/admin-users/table/ausers-table-columns.tsx b/lib/admin-users/table/ausers-table-columns.tsx new file mode 100644 index 00000000..38281c7e --- /dev/null +++ b/lib/admin-users/table/ausers-table-columns.tsx @@ -0,0 +1,228 @@ +"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 { userColumnsConfig } from "@/config/userColumnsConfig" +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 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<UserView> = { + 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> + + {/* <DropdownMenuSub> + <DropdownMenuSubTrigger>Roles</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <MultiSelect + defaultValue={row.original.roles} + options={userRoles.role.enumValues.map((role) => ({ + value: role, + label: role, + }))} + value={row.original.roles} + onValueChange={(value) => { + startUpdateTransition(() => { + + toast.promise( + modifiUser({ + id: row.original.user_id, + roles: value as ("admin"|"normal")[], + }), + { + loading: "Updating...", + success: "Roles updated", + error: (err) => getErrorMessage(err), + } + ); + }); + }} + + /> + </DropdownMenuSubContent> + </DropdownMenuSub> */} + + <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<UserView>[]> = {} + + userColumnsConfig.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) => ( + <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, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/admin-users/table/ausers-table-floating-bar.tsx b/lib/admin-users/table/ausers-table-floating-bar.tsx new file mode 100644 index 00000000..ae950252 --- /dev/null +++ b/lib/admin-users/table/ausers-table-floating-bar.tsx @@ -0,0 +1,389 @@ +"use client" + +import * as React from "react" +import { userRoles, users, UserView, type User } from "@/db/schema/users" +import { SelectTrigger } from "@radix-ui/react-select" +import { type Table } from "@tanstack/react-table" +import { + ArrowUp, + CheckCircle2, + Download, + Loader, + Trash2, + X, Check +} from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { Portal } from "@/components/ui/portal" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, +} from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { Kbd } from "@/components/kbd" + +import { modifiUsers, getAllCompanies, removeUsers } from "@/lib//admin-users/service" +import { type Company } from "@/db/schema/companies" +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover" +import { + Command, + CommandInput, + CommandList, + CommandGroup, + CommandItem, + CommandEmpty, +} from "@/components/ui/command" +import { cn } from "@/lib/utils" +import { MultiSelect } from "@/components/ui/multi-select" +import { ActionConfirmDialog } from "@/components/ui/action-dialog" + +interface AusersTableFloatingBarProps { + table: Table<UserView> +} + + +export function AusersTableFloatingBar({ table }: AusersTableFloatingBarProps) { + const rows = table.getFilteredSelectedRowModel().rows + + const [isPending, startTransition] = React.useTransition() + const [action, setAction] = React.useState< + "update-company" | "update-roles" | "export" | "delete" + >() + const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록 + + // Clear selection on Escape key press + React.useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + table.toggleAllRowsSelected(false) + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [table]) + + React.useEffect(() => { + // 회사 목록 불러오기 (예시) + getAllCompanies().then((res) => { + setCompanies(res) + }) + }, []) + + const [popoverOpen, setPopoverOpen] = React.useState(false) + const [rolesPopoverOpen, setRolesPopoverOpen] = React.useState(false) + + + // 공용 confirm dialog state + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) + const [confirmProps, setConfirmProps] = React.useState<{ + title: string + description?: string + onConfirm: () => Promise<void> | void + }>({ + title: "", + description: "", + onConfirm: () => { }, + }) + + // 1) "삭제" Confirm 열기 + function handleDeleteConfirm() { + setAction("delete") + setConfirmProps({ + title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + description: "This action cannot be undone.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await removeUsers({ + ids: rows.map((row) => row.original.user_id), + }) + if (error) { + toast.error(error) + return + } + toast.success("Users deleted") + table.toggleAllRowsSelected(false) + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 2) "회사 업데이트"에서 회사 선택 시 → Confirm Dialog + function handleSelectCompany(comp: Company) { + setAction("update-company") + setPopoverOpen(false) + + // Confirm Dialog에 전달할 내용 + setConfirmProps({ + title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} to "${comp.name}"?`, + description: `TaxID: ${comp.taxID}. This action will overwrite their current company.`, + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiUsers({ + ids: rows.map((row) => row.original.user_id), + companyId: comp.id, + }) + if (error) { + toast.error(error) + return + } + toast.success("Users updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + // 3) "역할 업데이트" MultiSelect 후 → Confirm Dialog + function handleSelectRoles(newRoles: string[]) { + setAction("update-roles") + setRolesPopoverOpen(false) + + setConfirmProps({ + title: `Update ${rows.length} user${rows.length > 1 ? "s" : ""} with roles: ${newRoles.join(", ")}?`, + description: "This action will override their current roles.", + onConfirm: async () => { + startTransition(async () => { + const { error } = await modifiUsers({ + ids: rows.map((row) => row.original.user_id), + roles: newRoles as ("admin" | "normal")[], + }) + if (error) { + toast.error(error) + return + } + toast.success("Users updated") + setConfirmDialogOpen(false) + }) + }, + }) + setConfirmDialogOpen(true) + } + + return ( + <Portal > + <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> + <div className="w-full overflow-x-auto"> + <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> + <div className="flex items-center gap-1.5"> + <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> + + <Tooltip> + <PopoverTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-company" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 + className="size-3.5" + aria-hidden="true" + /> + )} + </Button> + </TooltipTrigger> + </PopoverTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update company</p> + </TooltipContent> + </Tooltip> + + <PopoverContent className="w-80 p-0"> + <Command> + <CommandInput placeholder="Search company..." className="h-9" /> + <CommandList> + <CommandEmpty>No company found.</CommandEmpty> + <CommandGroup> + {companies.map((comp) => { + const label = `${comp.name} (${comp.taxID})` + return ( + <CommandItem + key={comp.id} + value={label} + onSelect={() => handleSelectCompany(comp)} + > + {label} + </CommandItem> + ) + })} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + <Popover open={rolesPopoverOpen} onOpenChange={setRolesPopoverOpen}> + + <Tooltip> + <PopoverTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-roles" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <ArrowUp className="size-3.5" aria-hidden="true" /> + + )} + </Button> + </TooltipTrigger> + </PopoverTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Update roles</p> + </TooltipContent> + </Tooltip> + <PopoverContent> + <MultiSelect + defaultValue={["999999999"]} + options={[ + /* ... */ + { value: "999999999", label: "admin" } + ]} + onValueChange={(newRoles) => { + handleSelectRoles(newRoles) + }} + /> + </PopoverContent> + + </Popover> + + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export users</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete users</p> + </TooltipContent> + </Tooltip> + </div> + </div> + </div> + </div> + + {/* 공용 Confirm Dialog */} + <ActionConfirmDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + title={confirmProps.title} + description={confirmProps.description} + onConfirm={confirmProps.onConfirm} + isLoading={isPending && (action === "delete" || action === "update-company" || action === "update-roles")} + confirmLabel={ + action === "delete" + ? "Delete" + : action === "update-company" || action === "update-roles" + ? "Update" + : "Confirm" + } + confirmVariant={ + action === "delete" ? "destructive" : "default" + } + /> + </Portal> + ) +} diff --git a/lib/admin-users/table/ausers-table-toolbar-actions.tsx b/lib/admin-users/table/ausers-table-toolbar-actions.tsx new file mode 100644 index 00000000..5472c3ac --- /dev/null +++ b/lib/admin-users/table/ausers-table-toolbar-actions.tsx @@ -0,0 +1,118 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +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" + +// 삭제, 추가 다이얼로그 +import { DeleteUsersDialog } from "./delete-ausers-dialog" +import { AddUserDialog } from "./add-ausers-dialog" + +// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import +import { importTasksExcel } from "@/lib/tasks/service" // 예시 +import { type UserView } from "@/db/schema/users" + +interface AdmUserTableToolbarActionsProps { + table: Table<UserView> +} + +export function AdmUserTableToolbarActions({ table }: AdmUserTableToolbarActionsProps) { + // 파일 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"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteUsersDialog + users={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 2) 새 Task 추가 다이얼로그 */} + <AddUserDialog /> + + {/** 3) Import 버튼 (파일 업로드) */} + <Button variant="outline" size="sm" className="gap-2" onClick={handleImportClick}> + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + {/* + 실제로는 숨겨진 input과 연결: + - accept=".xlsx,.xls" 등으로 Excel 파일만 업로드 허용 + */} + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + className="hidden" + onChange={onFileChange} + /> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + 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/admin-users/table/ausers-table.tsx b/lib/admin-users/table/ausers-table.tsx new file mode 100644 index 00000000..ed575e75 --- /dev/null +++ b/lib/admin-users/table/ausers-table.tsx @@ -0,0 +1,180 @@ +"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 { + getUserCountGroupByCompany, + getUserCountGroupByRole, + getUsers, getAllCompanies, + getAllRoles +} from "@/lib//admin-users/service" +import { getColumns } from "./ausers-table-columns" +import { AdmUserTableToolbarActions } from "./ausers-table-toolbar-actions" +import { DeleteUsersDialog } from "./delete-ausers-dialog" +import { AusersTableFloatingBar } from "./ausers-table-floating-bar" +import { UpdateAuserSheet } from "./update-auser-sheet" + +interface UsersTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getUsers>>, + Record<number, number>, + Record<number, number>, + Awaited<ReturnType<typeof getAllCompanies>>, + Awaited<ReturnType<typeof getAllRoles>> + ] + > +} +type RoleCounts = Record<string, number> + +export function AdmUserTable({ promises }: UsersTableProps) { + + const [{ data, pageCount }, companyCounts,roleCountsRaw, companies, roles] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<UserView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + const roleCounts = roleCountsRaw as RoleCounts + + + /** + * 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: "company_name", + label: "Company", + type: "multi-select", + options: companies.map((comp) => ({ + label: comp.vendorName, + value: comp.vendorName, + count: companyCounts[comp.id] + })), + }, + + { + id: "roles", + label: "Roles", + type: "multi-select", + options: roles.map((role) => { + return { + label: toSentenceCase(role.name), + value: role.id, + count: roleCounts[role.id], // 이 값이 undefined인지 확인 + }; + }), + }, + { + 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, + }) + + return ( + <> + <DataTable + table={table} + floatingBar={<AusersTableFloatingBar table={table}/>} + + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <AdmUserTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + + <DeleteUsersDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + users={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + <UpdateAuserSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + user={rowAction?.row.original ?? null} + /> + + </> + ) +} diff --git a/lib/admin-users/table/delete-ausers-dialog.tsx b/lib/admin-users/table/delete-ausers-dialog.tsx new file mode 100644 index 00000000..0699bb95 --- /dev/null +++ b/lib/admin-users/table/delete-ausers-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 { removeUsers } from "@/lib//admin-users/service" +import { type UserView } from "@/db/schema/users" + +interface DeleteUsersDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + users: Row<UserView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteUsersDialog({ + users, + showTrigger = true, + onSuccess, + ...props +}: DeleteUsersDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeUsers({ + ids: users.map((user) => Number(user.user_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 ({users.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">{users.length}</span> + {users.length === 1 ? " user" : " users"} 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 ({users.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">{users.length}</span> + {users.length === 1 ? " user" : " users"} 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/admin-users/table/update-auser-sheet.tsx b/lib/admin-users/table/update-auser-sheet.tsx new file mode 100644 index 00000000..ddf1f932 --- /dev/null +++ b/lib/admin-users/table/update-auser-sheet.tsx @@ -0,0 +1,225 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +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 { MultiSelect } from "@/components/ui/multi-select" + +import { userRoles, type UserView } from "@/db/schema/users" +import { updateUserSchema, type UpdateUserSchema } from "@/lib/admin-users/validations" +import { modifiUser } from "@/lib/admin-users/service" + +export interface UpdateAuserSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + user: UserView | null +} + +const languageOptions = [ + { value: "ko", label: "한국어" }, + { value: "en", label: "English" }, +] + + +export function UpdateAuserSheet({ user, ...props }: UpdateAuserSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + // 1) RHF 설정 + const form = useForm<UpdateUserSchema & { language?: string }>({ + resolver: zodResolver(updateUserSchema), + defaultValues: { + name: user?.user_name ?? "", + email: user?.user_email ?? "", + companyId: user?.company_id ?? null, + roles: user?.roles ?? [], + language:'en', + }, + }) + + // 2) user prop 바뀔 때마다 form.reset + React.useEffect(() => { + if (user) { + form.reset({ + name: user.user_name, + email: user.user_email, + companyId: user.company_id, + roles: user.roles, + }) + } + }, [user, form]) + + + // 3) onSubmit + async function onSubmit(input: UpdateUserSchema & { language?: string }) { + startUpdateTransition(async () => { + if (!user) return + + const { error } = await modifiUser({ + id: user.user_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>User Name</FormLabel> + <FormControl> + <Input placeholder="e.g. dujin" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* email */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input type="email" placeholder="user@example.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* roles */} + <FormField + control={form.control} + name="roles" + render={({ field }) => ( + <FormItem> + <FormLabel>Roles</FormLabel> + <FormControl> + <MultiSelect + // 예: userRoles.role.enumValues = ["admin","normal"] + defaultValue={form?.getValues().roles} + options={[ + { value: "999999999", label: "admin" } + ]} + value={field.value} + onValueChange={(vals) => field.onChange(vals)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="language" + render={({ field }) => ( + <FormItem> + <FormLabel>Language</FormLabel> + <FormControl> + <Select + onValueChange={field.onChange} + // 'value'로 현재 값 연결. defaultValue 대신 Controlled 컴포넌트로 + value={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Select language" /> + </SelectTrigger> + <SelectContent> + {languageOptions.map((v, index) => ( + <SelectItem key={index} value={v.value}> + {v.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </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/admin-users/validations.ts b/lib/admin-users/validations.ts new file mode 100644 index 00000000..e505067d --- /dev/null +++ b/lib/admin-users/validations.ts @@ -0,0 +1,65 @@ +import { userRoles, users, type UserView } from "@/db/schema/users"; +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { checkEmailExists } from "./service"; + + + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<UserView>().withDefault([ + { id: "created_at", desc: true }, + ]), + email: parseAsString.withDefault(""), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + +export const createUserSchema = z.object({ + email: z + .string() + .email() + .refine( + async (email) => { + // 1) DB 조회해서 이미 같은 email이 있으면 false 반환 + const isUsed = await checkEmailExists(email); + return !isUsed; + }, + { + message: "This email is already in use", + } + ), + name: z.string().min(1), // 최소 길이 1 + domain: z.enum(users.domain.enumValues), // "evcp" | "partners" + companyId: z.number().nullable().optional(), // number | null | undefined + roles:z.array(z.string()).min(1, "At least one role must be selected"), + language: z.enum(["ko", "en"]).optional(), + +}); + +export const updateUserSchema = z.object({ + name: z.string().optional(), + email: z.string().email().optional(), + domain: z.enum(users.domain.enumValues).optional(), + companyId: z.number().nullable().optional(), + roles: z.array(z.string()).optional(), + language: z.enum(["ko", "en"]).optional(), + +}); +export type GetUsersSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type CreateUserSchema = z.infer<typeof createUserSchema> +export type UpdateUserSchema = z.infer<typeof updateUserSchema> |
