From 5b6313f16f508882a0ea67716b7dbaa1c6967f04 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 30 Jun 2025 08:28:13 +0000 Subject: (대표님) 20250630 16시 - 유저 도메인별 라우터 분리와 보안성검토 대응 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/admin-users/repository.ts | 32 +++ lib/admin-users/validations.ts | 21 +- lib/menu-list/servcie.ts | 239 ++++++++++++++++++ lib/menu-list/table/initialize-button.tsx | 42 ++++ lib/menu-list/table/manager-select.tsx | 192 ++++++++++++++ lib/menu-list/table/menu-list-table.tsx | 280 +++++++++++++++++++++ lib/users/access-control/assign-domain-dialog.tsx | 253 +++++++++++++++++++ lib/users/access-control/domain-stats-cards.tsx | 232 +++++++++++++++++ lib/users/access-control/users-table-columns.tsx | 149 +++++++++++ .../access-control/users-table-toolbar-actions.tsx | 51 ++++ lib/users/access-control/users-table.tsx | 166 ++++++++++++ lib/users/auth/verifyCredentails.ts | 5 +- lib/users/service.ts | 250 +++++++++++++++++- 13 files changed, 1906 insertions(+), 6 deletions(-) create mode 100644 lib/menu-list/servcie.ts create mode 100644 lib/menu-list/table/initialize-button.tsx create mode 100644 lib/menu-list/table/manager-select.tsx create mode 100644 lib/menu-list/table/menu-list-table.tsx create mode 100644 lib/users/access-control/assign-domain-dialog.tsx create mode 100644 lib/users/access-control/domain-stats-cards.tsx create mode 100644 lib/users/access-control/users-table-columns.tsx create mode 100644 lib/users/access-control/users-table-toolbar-actions.tsx create mode 100644 lib/users/access-control/users-table.tsx (limited to 'lib') diff --git a/lib/admin-users/repository.ts b/lib/admin-users/repository.ts index aff2da28..63e98b4e 100644 --- a/lib/admin-users/repository.ts +++ b/lib/admin-users/repository.ts @@ -49,6 +49,30 @@ export async function selectUsersWithCompanyAndRoles( return rows } +export async function selectUsers( + tx: PgTransaction, + params: { + where?: any + orderBy?: (ReturnType | ReturnType)[] + offset?: number + limit?: number + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params + + // 1) 쿼리 빌더 생성 + const queryBuilder = tx + .select() + .from(users) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit) + + const rows = await queryBuilder + return rows +} + /** 총 개수 count */ export async function countUsers( @@ -59,6 +83,14 @@ export async function countUsers( return res[0]?.count ?? 0; } +export async function countUsersSimple( + tx: PgTransaction, + where?: any +) { + const res = await tx.select({ count: count() }).from(users).where(where); + return res[0]?.count ?? 0; +} + export async function groupByCompany( tx: PgTransaction, ) { diff --git a/lib/admin-users/validations.ts b/lib/admin-users/validations.ts index e505067d..3c2fdb9c 100644 --- a/lib/admin-users/validations.ts +++ b/lib/admin-users/validations.ts @@ -1,4 +1,4 @@ -import { userRoles, users, type UserView } from "@/db/schema/users"; +import { User, userRoles, users, type UserView } from "@/db/schema/users"; import { createSearchParamsCache, parseAsArrayOf, @@ -29,6 +29,24 @@ export const searchParamsCache = createSearchParamsCache({ }) + +export const searchParamsUsersCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ + { id: "createdAt", 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() @@ -61,5 +79,6 @@ export const updateUserSchema = z.object({ }); export type GetUsersSchema = Awaited> +export type GetSimpleUsersSchema = Awaited> export type CreateUserSchema = z.infer export type UpdateUserSchema = z.infer diff --git a/lib/menu-list/servcie.ts b/lib/menu-list/servcie.ts new file mode 100644 index 00000000..35362e6d --- /dev/null +++ b/lib/menu-list/servcie.ts @@ -0,0 +1,239 @@ +// app/evcp/menu-list/actions.ts + +"use server"; + +import db from "@/db/db"; +import { menuAssignments, users } from "@/db/schema"; +import { eq, and } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { mainNav, mainNavVendor, additionalNav, additionalNavVendor } from "@/config/menuConfig"; + +// 메뉴 데이터 타입 정의 +interface MenuData { + menuPath: string; + menuTitle: string; + menuDescription?: string; + menuGroup?: string; + sectionTitle: string; + domain: "evcp" | "partners"; +} + +// Config에서 메뉴 데이터 추출 +function extractMenusFromConfig(): MenuData[] { + const menus: MenuData[] = []; + + // EVCP 메인 네비게이션 + mainNav.forEach(section => { + section.items.forEach(item => { + menus.push({ + menuPath: item.href, + menuTitle: item.title, + menuDescription: item.description, + menuGroup: item.group, + sectionTitle: section.title, + domain: "evcp" + }); + }); + }); + + // EVCP 추가 네비게이션 + additionalNav.forEach(item => { + menus.push({ + menuPath: item.href, + menuTitle: item.title, + menuDescription: item.description, + menuGroup: undefined, + sectionTitle: "추가 메뉴", + domain: "evcp" + }); + }); + + // Partners 메인 네비게이션 + mainNavVendor.forEach(section => { + section.items.forEach(item => { + menus.push({ + menuPath: item.href, + menuTitle: item.title, + menuDescription: item.description, + menuGroup: item.group, + sectionTitle: section.title, + domain: "partners" + }); + }); + }); + + // Partners 추가 네비게이션 + additionalNavVendor.forEach(item => { + menus.push({ + menuPath: item.href, + menuTitle: item.title, + menuDescription: item.description, + menuGroup: undefined, + sectionTitle: "추가 메뉴", + domain: "partners" + }); + }); + + return menus; +} + +// 초기 메뉴 데이터 생성 또는 업데이트 +export async function initializeMenuAssignments() { + try { + const configMenus = extractMenusFromConfig(); + const existingMenus = await db.select().from(menuAssignments); + const existingPaths = new Set(existingMenus.map(m => m.menuPath)); + + // 새로운 메뉴만 추가 + const newMenus = configMenus.filter(menu => !existingPaths.has(menu.menuPath)); + + console.log(newMenus, newMenus) + + if (newMenus.length > 0) { + await db.insert(menuAssignments).values( + newMenus.map(menu => ({ + menuPath: menu.menuPath, + menuTitle: menu.menuTitle, + menuDescription: menu.menuDescription || null, + menuGroup: menu.menuGroup || null, + sectionTitle: menu.sectionTitle, + domain: menu.domain, + isActive: true, + })) + ); + } + + // 기존 메뉴 정보 업데이트 (title, description 등이 변경될 수 있음) + for (const configMenu of configMenus) { + if (existingPaths.has(configMenu.menuPath)) { + await db + .update(menuAssignments) + .set({ + menuTitle: configMenu.menuTitle, + menuDescription: configMenu.menuDescription || null, + menuGroup: configMenu.menuGroup || null, + sectionTitle: configMenu.sectionTitle, + updatedAt: new Date(), + }) + .where(eq(menuAssignments.menuPath, configMenu.menuPath)); + } + } + + revalidatePath("/evcp/menu-list"); + return { success: true, message: `${newMenus.length}개의 새로운 메뉴가 추가되었습니다.` }; + } catch (error) { + console.error("메뉴 초기화 오류:", error); + return { success: false, message: "메뉴 초기화 중 오류가 발생했습니다." }; + } +} + +// 메뉴 담당자 업데이트 +export async function updateMenuManager( + menuPath: string, + manager1Id?: number | null, + manager2Id?: number | null +) { + try { + + console.log(menuPath, manager1Id) + + await db + .update(menuAssignments) + .set({ + manager1Id: manager1Id || null, + manager2Id: manager2Id || null, + updatedAt: new Date(), + }) + .where(eq(menuAssignments.menuPath, menuPath)); + + revalidatePath("/evcp/menu-list"); + return { success: true, message: "담당자가 업데이트되었습니다." }; + } catch (error) { + console.error("담당자 업데이트 오류:", error); + return { success: false, message: "담당자 업데이트 중 오류가 발생했습니다." }; + } +} + +// 메뉴 리스트 조회 (담당자 정보 포함) +export async function getMenuAssignments(domain?: "evcp" | "partners") { + try { + const whereCondition = domain + ? eq(menuAssignments.domain, domain) + : undefined; + + const result = await db + .select({ + id: menuAssignments.id, + menuPath: menuAssignments.menuPath, + menuTitle: menuAssignments.menuTitle, + menuDescription: menuAssignments.menuDescription, + menuGroup: menuAssignments.menuGroup, + sectionTitle: menuAssignments.sectionTitle, + domain: menuAssignments.domain, + isActive: menuAssignments.isActive, + createdAt: menuAssignments.createdAt, + updatedAt: menuAssignments.updatedAt, + manager1Id: menuAssignments.manager1Id, + manager2Id: menuAssignments.manager2Id, + manager1Name: users.name, + manager1Email: users.email, + manager2Name: users.name, + manager2Email: users.email, + }) + .from(menuAssignments) + .leftJoin(users, eq(menuAssignments.manager1Id, users.id)) + .where(whereCondition) + .orderBy(menuAssignments.sectionTitle, menuAssignments.menuGroup, menuAssignments.menuTitle); + + return { success: true, data: result }; + } catch (error) { + console.error("메뉴 조회 오류:", error); + return { success: false, message: "메뉴 조회 중 오류가 발생했습니다.", data: [] }; + } +} + +// 활성 사용자 리스트 조회 +export async function getActiveUsers(domain?: "evcp" | "partners") { + try { + const whereCondition = and( + eq(users.isActive, true), + domain ? eq(users.domain, domain) : undefined + ); + + const result = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + domain: users.domain, + }) + .from(users) + .where(whereCondition) + .orderBy(users.name); + + return { success: true, data: result }; + } catch (error) { + console.error("사용자 조회 오류:", error); + return { success: false, message: "사용자 조회 중 오류가 발생했습니다.", data: [] }; + } +} + +// 메뉴 활성화/비활성화 +export async function toggleMenuActive(menuPath: string, isActive: boolean) { + try { + await db + .update(menuAssignments) + .set({ + isActive, + updatedAt: new Date(), + }) + .where(eq(menuAssignments.menuPath, menuPath)); + + revalidatePath("/evcp/menu-list"); + + return { success: true, message: `메뉴가 ${isActive ? '활성화' : '비활성화'}되었습니다.` }; + } catch (error) { + console.error("메뉴 상태 변경 오류:", error); + return { success: false, message: "메뉴 상태 변경 중 오류가 발생했습니다." }; + } +} \ No newline at end of file diff --git a/lib/menu-list/table/initialize-button.tsx b/lib/menu-list/table/initialize-button.tsx new file mode 100644 index 00000000..1b8a2458 --- /dev/null +++ b/lib/menu-list/table/initialize-button.tsx @@ -0,0 +1,42 @@ +// app/evcp/menu-list/components/initialize-button.tsx + +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { RefreshCw } from "lucide-react"; +import { toast } from "sonner"; +import { initializeMenuAssignments } from "../servcie"; + +export function InitializeButton() { + const [isLoading, setIsLoading] = useState(false); + + const handleInitialize = async () => { + setIsLoading(true); + try { + const result = await initializeMenuAssignments(); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("메뉴 초기화 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +} \ No newline at end of file diff --git a/lib/menu-list/table/manager-select.tsx b/lib/menu-list/table/manager-select.tsx new file mode 100644 index 00000000..a4bcccd7 --- /dev/null +++ b/lib/menu-list/table/manager-select.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { Check, ChevronsUpDown, Search, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { toast } from "sonner"; +import { updateMenuManager } from "../servcie"; + +interface User { + id: number; + name: string; + email: string; + domain: string; +} + +interface ManagerSelectProps { + menuPath: string; + currentManagerId?: number | null; + users: User[]; + placeholder: string; + otherManagerId?: number | null; // 다른 담당자 ID (중복 선택 방지용) + type: "manager1" | "manager2"; +} + +export function ManagerSelect({ + menuPath, + currentManagerId, + users, + placeholder, + otherManagerId, + type +}: ManagerSelectProps) { + const [open, setOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // 선택 가능한 사용자 필터링 (다른 담당자로 이미 선택된 사용자 제외) + const availableUsers = users.filter(user => + user.id !== otherManagerId || user.id === currentManagerId + ); + + // 현재 선택된 사용자 찾기 + const selectedUser = availableUsers.find(user => user.id === currentManagerId); + + const handleSelect = async (userId: number | null) => { + setIsLoading(true); + setOpen(false); + + try { + // 현재 담당자 정보 구성 + const updateData = type === "manager1" + ? { manager1Id: userId, manager2Id: otherManagerId } + : { manager1Id: otherManagerId, manager2Id: userId }; + + const result = await updateMenuManager( + menuPath, + updateData.manager1Id, + updateData.manager2Id + ); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("담당자 업데이트 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const handleClear = () => { + handleSelect(null); + }; + + return ( + + + + )} + + ) : ( + {placeholder} + )} + + + + + + + + + 검색 결과가 없습니다. + + {/* 담당자 없음 옵션 */} + handleSelect(null)} + className="flex items-center gap-2" + > + +
+ 담당자 없음 + + 담당자를 지정하지 않습니다 + +
+
+ + {/* 사용자 목록 */} + {availableUsers.map((user) => ( + handleSelect(user.id)} + className="flex items-center gap-2" + disabled={user.id === otherManagerId && user.id !== currentManagerId} + > + +
+ {user.name} + + {user.email} + +
+ {user.id === otherManagerId && user.id !== currentManagerId && ( + + 다른 담당자로 선택됨 + + )} +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/lib/menu-list/table/menu-list-table.tsx b/lib/menu-list/table/menu-list-table.tsx new file mode 100644 index 00000000..097be082 --- /dev/null +++ b/lib/menu-list/table/menu-list-table.tsx @@ -0,0 +1,280 @@ +// app/evcp/menu-list/components/menu-list-table.tsx + +"use client"; + +import { useState, useMemo } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Button } from "@/components/ui/button"; +import { Search, Filter, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { ManagerSelect } from "./manager-select"; +import { toggleMenuActive } from "../servcie"; + +interface MenuAssignment { + id: number; + menuPath: string; + menuTitle: string; + menuDescription?: string | null; + menuGroup?: string | null; + sectionTitle: string; + domain: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + manager1Id?: number | null; + manager2Id?: number | null; + manager1Name?: string | null; + manager1Email?: string | null; + manager2Name?: string | null; + manager2Email?: string | null; +} + +interface User { + id: number; + name: string; + email: string; + domain: string; +} + +interface MenuListTableProps { + initialMenus: MenuAssignment[]; + initialUsers: User[]; +} + +export function MenuListTable({ initialMenus, initialUsers }: MenuListTableProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [domainFilter, setDomainFilter] = useState("all"); + const [sectionFilter, setSectionFilter] = useState("all"); + const [statusFilter, setStatusFilter] = useState("all"); + + // 필터링된 메뉴 데이터 + const filteredMenus = useMemo(() => { + return initialMenus.filter((menu) => { + const matchesSearch = + menu.menuTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.menuPath.toLowerCase().includes(searchQuery.toLowerCase()) || + menu.sectionTitle.toLowerCase().includes(searchQuery.toLowerCase()) || + (menu.menuDescription?.toLowerCase().includes(searchQuery.toLowerCase()) ?? false); + + const matchesDomain = domainFilter === "all" || menu.domain === domainFilter; + const matchesSection = sectionFilter === "all" || menu.sectionTitle === sectionFilter; + const matchesStatus = statusFilter === "all" || + (statusFilter === "active" && menu.isActive) || + (statusFilter === "inactive" && !menu.isActive); + + return matchesSearch && matchesDomain && matchesSection && matchesStatus; + }); + }, [initialMenus, searchQuery, domainFilter, sectionFilter, statusFilter]); + + // 섹션 리스트 추출 + const sections = useMemo(() => { + const sectionSet = new Set(initialMenus.map(menu => menu.sectionTitle)); + return Array.from(sectionSet).sort(); + }, [initialMenus]); + + // 도메인별 사용자 필터링 + const getFilteredUsers = (domain: string) => { + return initialUsers.filter(user => user.domain === domain); + }; + + // 메뉴 활성화/비활성화 토글 + const handleToggleActive = async (menuPath: string, isActive: boolean) => { + try { + const result = await toggleMenuActive(menuPath, isActive); + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + } + } catch (error) { + toast.error("메뉴 상태 변경 중 오류가 발생했습니다."); + } + }; + + return ( +
+ {/* 필터 영역 */} +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+
+ +
+ + + + + +
+
+ + {/* 결과 요약 */} +
+ + 총 {filteredMenus.length}개의 메뉴 + {searchQuery && ` (${initialMenus.length}개 중 검색 결과)`} + +
+ + {/* 테이블 */} +
+ + + + 상태 + 메뉴 정보 + 도메인 + 담당자 1 + 담당자 2 + {/* 동작 */} + + + + {filteredMenus.length === 0 ? ( + + + 조건에 맞는 메뉴가 없습니다. + + + ) : ( + filteredMenus.map((menu) => { + const domainUsers = getFilteredUsers(menu.domain); + + return ( + + + handleToggleActive(menu.menuPath, checked)} + /> + + + +
+
+ {menu.menuTitle} + + {menu.sectionTitle} + + {menu.menuGroup && ( + + {menu.menuGroup} + + )} +
+
+ {menu.menuPath} +
+ {menu.menuDescription && ( +
+ {menu.menuDescription} +
+ )} +
+
+ + + + {menu.domain.toUpperCase()} + + + + + + + + + + + + {/* + + */} +
+ ); + }) + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/lib/users/access-control/assign-domain-dialog.tsx b/lib/users/access-control/assign-domain-dialog.tsx new file mode 100644 index 00000000..fda06b28 --- /dev/null +++ b/lib/users/access-control/assign-domain-dialog.tsx @@ -0,0 +1,253 @@ +// components/assign-domain-dialog.tsx +"use client" + +import * as React from "react" +import { User } from "@/db/schema/users" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Users, Loader2, CheckCircle } from "lucide-react" +import { toast } from "sonner" +import { assignUsersDomain } from "../service" + +interface AssignDomainDialogProps { + users: User[] +} + +// 도메인 옵션 정의 +const domainOptions = [ + { + value: "pending", + label: "승인 대기", + description: "신규 사용자 (기본 메뉴만)", + color: "yellow", + icon: "🟡" + }, + { + value: "evcp", + label: "전체 시스템", + description: "모든 메뉴 접근 (관리자급)", + color: "blue", + icon: "🔵" + }, + { + value: "procurement", + label: "구매관리팀", + description: "구매, 협력업체, 계약 관리", + color: "green", + icon: "🟢" + }, + { + value: "sales", + label: "기술영업팀", + description: "기술영업, 견적, 프로젝트 관리", + color: "purple", + icon: "🟣" + }, + { + value: "engineering", + label: "설계관리팀", + description: "설계, 기술평가, 문서 관리", + color: "orange", + icon: "🟠" + }, + { + value: "partners", + label: "협력업체", + description: "외부 협력업체용 기능", + color: "indigo", + icon: "🟦" + } +] + +export function AssignDomainDialog({ users }: AssignDomainDialogProps) { + const [open, setOpen] = React.useState(false) + const [selectedDomain, setSelectedDomain] = React.useState("") + const [isLoading, setIsLoading] = React.useState(false) + + // 도메인별 사용자 그룹핑 + const usersByDomain = React.useMemo(() => { + const groups: Record = {} + users.forEach(user => { + const domain = user.domain || "pending" + if (!groups[domain]) { + groups[domain] = [] + } + groups[domain].push(user) + }) + return groups + }, [users]) + + const handleAssign = async () => { + if (!selectedDomain) { + toast.error("도메인을 선택해주세요.") + return + } + + setIsLoading(true) + try { + const userIds = users.map(user => user.id) + const result = await assignUsersDomain(userIds, selectedDomain as any) + + if (result.success) { + toast.success(`${users.length}명의 사용자에게 ${selectedDomain} 도메인이 할당되었습니다.`) + setOpen(false) + setSelectedDomain("") + // 테이블 새로고침을 위해 router.refresh() 또는 revalidation 필요 + window.location.reload() // 간단한 방법 + } else { + toast.error(result.message || "도메인 할당 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("도메인 할당 오류:", error) + toast.error("도메인 할당 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + const selectedDomainInfo = domainOptions.find(option => option.value === selectedDomain) + + return ( + + + + + + + + + + 사용자 도메인 할당 + + + 선택된 {users.length}명의 사용자에게 도메인을 할당합니다. + + + +
+ {/* 현재 사용자 도메인 분포 */} +
+

현재 도메인 분포

+
+ {Object.entries(usersByDomain).map(([domain, domainUsers]) => { + const domainInfo = domainOptions.find(opt => opt.value === domain) + return ( + + {domainInfo?.icon || "⚪"} + {domainInfo?.label || domain} ({domainUsers.length}명) + + ) + })} +
+
+ + + + {/* 사용자 목록 */} +
+

대상 사용자

+ +
+ {users.map((user, index) => ( +
+
+ {user.name} + ({user.email}) +
+ + {domainOptions.find(opt => opt.value === user.domain)?.label || user.domain} + +
+ ))} +
+
+
+ + + + {/* 도메인 선택 */} +
+

할당할 도메인

+ +
+ + {/* 선택된 도메인 미리보기 */} + {selectedDomainInfo && ( +
+
+ + 선택된 도메인 +
+
+ {selectedDomainInfo.icon} +
+
{selectedDomainInfo.label}
+
+ {selectedDomainInfo.description} +
+
+
+
+ )} +
+ + + + + +
+
+ ) +} \ No newline at end of file diff --git a/lib/users/access-control/domain-stats-cards.tsx b/lib/users/access-control/domain-stats-cards.tsx new file mode 100644 index 00000000..e6320e12 --- /dev/null +++ b/lib/users/access-control/domain-stats-cards.tsx @@ -0,0 +1,232 @@ +// components/domain-stats-cards.tsx +"use client" + +import * as React from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Users, + Clock, + Shield, + ShoppingCart, + TrendingUp, + Settings, + Building, + AlertCircle +} from "lucide-react" +import { toast } from "sonner" +import { getUserDomainStats } from "../service" + +interface DomainStatsCardsProps { + onDomainFilter: (domain: string | null) => void + currentFilter?: string | null +} + +// 도메인별 설정 +const domainConfig = { + pending: { + label: "승인 대기", + description: "신규 사용자", + icon: Clock, + color: "bg-yellow-500", + textColor: "text-yellow-700", + bgColor: "bg-yellow-50", + borderColor: "border-yellow-200" + }, + evcp: { + label: "전체 시스템", + description: "관리자급", + icon: Shield, + color: "bg-blue-500", + textColor: "text-blue-700", + bgColor: "bg-blue-50", + borderColor: "border-blue-200" + }, + procurement: { + label: "구매관리팀", + description: "구매/계약 관리", + icon: ShoppingCart, + color: "bg-green-500", + textColor: "text-green-700", + bgColor: "bg-green-50", + borderColor: "border-green-200" + }, + sales: { + label: "기술영업팀", + description: "영업/프로젝트", + icon: TrendingUp, + color: "bg-purple-500", + textColor: "text-purple-700", + bgColor: "bg-purple-50", + borderColor: "border-purple-200" + }, + engineering: { + label: "설계관리팀", + description: "설계/기술평가", + icon: Settings, + color: "bg-orange-500", + textColor: "text-orange-700", + bgColor: "bg-orange-50", + borderColor: "border-orange-200" + }, + // partners: { + // label: "협력업체", + // description: "외부 업체", + // icon: Building, + // color: "bg-indigo-500", + // textColor: "text-indigo-700", + // bgColor: "bg-indigo-50", + // borderColor: "border-indigo-200" + // } +} + +interface DomainStats { + domain: string + count: number +} + +export function DomainStatsCards({ onDomainFilter, currentFilter }: DomainStatsCardsProps) { + const [stats, setStats] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(true) + const [totalUsers, setTotalUsers] = React.useState(0) + + // 통계 데이터 로드 + React.useEffect(() => { + const loadStats = async () => { + setIsLoading(true) + try { + const result = await getUserDomainStats() + if (result.success) { + setStats(result.data) + setTotalUsers(result.data.reduce((sum, item) => sum + item.count, 0)) + } else { + toast.error(result.message) + } + } catch (error) { + console.error("통계 로드 오류:", error) + toast.error("통계를 불러오는 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + } + } + + loadStats() + }, []) + + // 도메인별 카드 클릭 핸들러 + const handleDomainClick = (domain: string) => { + if (currentFilter === domain) { + // 이미 선택된 도메인이면 필터 해제 + onDomainFilter(null) + } else { + // 새로운 도메인 필터 적용 + onDomainFilter(domain) + } + } + + // pending 사용자 수 가져오기 + const pendingCount = stats.find(s => s.domain === "pending")?.count || 0 + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + +
+
+
+ ))} +
+ ) + } + + return ( +
+ {/* 요약 정보 */} +
+
+

도메인별 사용자 현황

+

+ 총 {totalUsers}명의 사용자가 등록되어 있습니다. +

+
+ + {pendingCount > 0 && ( + + )} +
+ + {/* 도메인별 통계 카드 */} +
+ {Object.entries(domainConfig).map(([domain, config]) => { + const domainStat = stats.find(s => s.domain === domain) + const count = domainStat?.count || 0 + const isActive = currentFilter === domain + const IconComponent = config.icon + + return ( + handleDomainClick(domain)} + > + +
+
+ +
+
+
+

+ {config.label} +

+ + {count} + +
+

+ {config.description} +

+
+
+
+
+ ) + })} +
+ + {/* 필터 상태 표시 */} + {currentFilter && ( +
+ 필터 적용됨: + + {domainConfig[currentFilter as keyof typeof domainConfig]?.label || currentFilter} + + +
+ )} +
+ ) +} \ No newline at end of file diff --git a/lib/users/access-control/users-table-columns.tsx b/lib/users/access-control/users-table-columns.tsx new file mode 100644 index 00000000..7e510b96 --- /dev/null +++ b/lib/users/access-control/users-table-columns.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { type User } 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 { getErrorMessage } from "@/lib/handle-error" + +import { toast } from "sonner" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { MultiSelect } from "@/components/ui/multi-select" +import { userAccessColumnsConfig } from "@/config/userAccessColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns(): ColumnDef[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef = { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + + + const groupMap: Record[]> = {} + + userAccessColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + if (cfg.id === "domain") { + const domainValues = row.original.domain; + return ( +
+ + + {domainValues} + + +
+ ); + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef[] = [] + + // 순서를 고정하고 싶다면 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/users/access-control/users-table-toolbar-actions.tsx b/lib/users/access-control/users-table-toolbar-actions.tsx new file mode 100644 index 00000000..6a431016 --- /dev/null +++ b/lib/users/access-control/users-table-toolbar-actions.tsx @@ -0,0 +1,51 @@ +"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" +import { User } from "@/db/schema/users" +import { AssignDomainDialog } from "./assign-domain-dialog" + +interface UsersTableToolbarActionsProps { + table: Table +} + +export function UsersTableToolbarActions({ table }: UsersTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef(null) + + return ( +
+ + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + row.original)} + /> + ) : null} + + + + {/** 4) Export 버튼 */} + +
+ ) +} \ No newline at end of file diff --git a/lib/users/access-control/users-table.tsx b/lib/users/access-control/users-table.tsx new file mode 100644 index 00000000..50ce4dee --- /dev/null +++ b/lib/users/access-control/users-table.tsx @@ -0,0 +1,166 @@ +"use client" + +import * as React from "react" +import { User} from "@/db/schema/users" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +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 { getUsersNotPartners } from "@/lib/users/service"; +import { getColumns } from "./users-table-columns" +import { UsersTableToolbarActions } from "./users-table-toolbar-actions" +import { DomainStatsCards } from "./domain-stats-cards" + +interface UsersTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + +export function UserAccessControlTable({ promises }: UsersTableProps) { + const [{ data, pageCount }] = React.use(promises) + const [currentDomainFilter, setCurrentDomainFilter] = React.useState(null) + + const columns = React.useMemo(() => getColumns(), []) + + // 도메인 필터에 따른 데이터 필터링 + const filteredData = React.useMemo(() => { + if (!currentDomainFilter) { + return data // 필터가 없으면 전체 데이터 + } + return data.filter(user => user.domain === currentDomainFilter) + }, [data, currentDomainFilter]) + + // 필터링된 데이터의 페이지 수 재계산 + const filteredPageCount = React.useMemo(() => { + if (!currentDomainFilter) { + return pageCount // 필터가 없으면 원본 페이지 수 + } + // 필터링된 데이터는 페이지 수를 1로 설정 (클라이언트 필터링이므로) + return 1 + }, [pageCount, currentDomainFilter]) + + /** + * Advanced filter fields for the data table. + */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "name", + label: "사용자명", + type: "text", + }, + { + id: "email", + label: "이메일", + type: "text", + }, + { + id: "deptName", + label: "부서", + type: "text", + }, + { + id: "domain", + label: "도메인", + type: "multi-select", + options: [ + { label: "🟡 승인 대기", value: "pending" }, + { label: "🔵 전체 시스템", value: "evcp" }, + { label: "🟢 구매관리팀", value: "procurement" }, + { label: "🟣 기술영업팀", value: "sales" }, + { label: "🟠 설계관리팀", value: "engineering" }, + { label: "🟦 협력업체", value: "partners" }, + ], + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data: filteredData, // 필터링된 데이터 사용 + columns, + pageCount: filteredPageCount, // 필터링된 페이지 수 사용 + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.id}`, + shallow: false, + clearOnDefault: true, + }) + + // 도메인 필터 핸들러 (단순화) + const handleDomainFilter = React.useCallback((domain: string | null) => { + setCurrentDomainFilter(domain) + }, []) + + return ( +
+ {/* 도메인 통계 카드 */} + + + {/* 현재 필터 상태 표시 */} + {/* {currentDomainFilter && ( +
+
+ + 필터 적용됨: + + + {(() => { + const domainLabels = { + pending: "🟡 승인 대기", + evcp: "🔵 전체 시스템", + procurement: "🟢 구매관리팀", + sales: "🟣 기술영업팀", + engineering: "🟠 설계관리팀", + partners: "🟦 협력업체" + } + return domainLabels[currentDomainFilter as keyof typeof domainLabels] || currentDomainFilter + })()} + +
+
+ + {filteredData.length}명 표시 + + +
+
+ )} */} + + {/* 데이터 테이블 */} + + + + + +
+ ) +} \ No newline at end of file diff --git a/lib/users/auth/verifyCredentails.ts b/lib/users/auth/verifyCredentails.ts index ec3159a8..1b67b874 100644 --- a/lib/users/auth/verifyCredentails.ts +++ b/lib/users/auth/verifyCredentails.ts @@ -458,8 +458,11 @@ export async function verifySGipsCredentials( error?: string; }> { try { + + const sgipsUrl = process.env.S_GIPS_URL || "http://qa.shi-api.com/evcp/Common/verifySgipsUser" + // 1. S-Gips API 호출로 인증 확인 - const response = await fetch(process.env.S_GIPS_URL, { + const response = await fetch(sgipsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/lib/users/service.ts b/lib/users/service.ts index ad01c22a..9671abfb 100644 --- a/lib/users/service.ts +++ b/lib/users/service.ts @@ -6,15 +6,15 @@ import { getAllUsers, createUser, getUserById, updateUser, deleteUser, getUserBy import logger from '@/lib/logger'; import { Role, userRoles, users, userView, type User } from '@/db/schema/users'; import { saveDocument } from '../storage'; -import { GetUsersSchema } from '../admin-users/validations'; -import { revalidateTag, unstable_cache, unstable_noStore } from 'next/cache'; +import { GetSimpleUsersSchema, GetUsersSchema } from '../admin-users/validations'; +import { revalidatePath, revalidateTag, unstable_cache, unstable_noStore } from 'next/cache'; import { filterColumns } from '../filter-columns'; -import { countUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository'; +import { countUsers, countUsersSimple, selectUsers, selectUsersWithCompanyAndRoles } from '../admin-users/repository'; import db from "@/db/db"; import { getErrorMessage } from "@/lib/handle-error"; import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" -import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm"; +import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray, ne } from "drizzle-orm"; interface AssignUsersArgs { roleId: number @@ -340,6 +340,82 @@ export async function getUsersEVCP(input: GetUsersSchema) { )(); } + +export async function getUsersNotPartners(input: GetSimpleUsersSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // (1) advancedWhere + const advancedWhere = filterColumns({ + table: users, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // (2) globalWhere + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(users.name, s), + ilike(users.email, s), + ilike(users.deptName, s), + ); + } + + // (3) 디폴트 domainWhere = eq(userView.domain, "partners") + // 다만, 사용자가 이미 domain 필터를 줬다면 적용 X + let domainWhere; + const hasDomainFilter = input.filters?.some((f) => f.id === "domain"); + if (!hasDomainFilter) { + domainWhere = ne(users.domain, "partners"); + } + + // (4) 최종 where + const finalWhere = and(advancedWhere, globalWhere, domainWhere); + + // (5) 정렬 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(users[item.id]) : asc(users[item.id]) + ) + : [desc(users.createdAt)]; + + // ... + const { data, total } = await db.transaction(async (tx) => { + const data = await selectUsers(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + + console.log(data) + + const total = await countUsersSimple(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data, pageCount }; + } catch (err) { + + console.log(err) + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["users-access-control"], + } + )(); +} + export async function getAllRoles(): Promise { try { return await findAllRoles(); @@ -507,3 +583,169 @@ export async function assignUsersToRole(roleId: number, userIds: number[]) { } } + + +export type UserDomain = "pending" | "evcp" | "procurement" | "sales" | "engineering" | "partners" + +/** + * 여러 사용자에게 도메인을 일괄 할당하는 함수 + */ +export async function assignUsersDomain( + userIds: number[], + domain: UserDomain +) { + try { + if (!userIds.length) { + return { + success: false, + message: "할당할 사용자가 없습니다." + } + } + + if (!domain) { + return { + success: false, + message: "도메인을 선택해주세요." + } + } + + // 사용자들의 도메인 업데이트 + const result = await db + .update(users) + .set({ + domain, + updatedAt: new Date(), + }) + .where(inArray(users.id, userIds)) + .returning({ + id: users.id, + name: users.name, + email: users.email, + domain: users.domain, + }) + + // 관련 페이지들 revalidate + revalidatePath("/evcp/user-management") + revalidatePath("/") + + return { + success: true, + message: `${result.length}명의 사용자 도메인이 업데이트되었습니다.`, + data: result + } + } catch (error) { + console.error("사용자 도메인 할당 오류:", error) + return { + success: false, + message: "도메인 할당 중 오류가 발생했습니다." + } + } +} + +/** + * 단일 사용자의 도메인을 변경하는 함수 + */ +export async function assignUserDomain( + userId: number, + domain: UserDomain +) { + try { + const result = await db + .update(users) + .set({ + domain, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)) + .returning({ + id: users.id, + name: users.name, + email: users.email, + domain: users.domain, + }) + + if (result.length === 0) { + return { + success: false, + message: "사용자를 찾을 수 없습니다." + } + } + + revalidatePath("/evcp/user-management") + revalidatePath("/evcp/users") + + return { + success: true, + message: `${result[0].name}님의 도메인이 ${domain}으로 변경되었습니다.`, + data: result[0] + } + } catch (error) { + console.error("사용자 도메인 할당 오류:", error) + return { + success: false, + message: "도메인 할당 중 오류가 발생했습니다." + } + } +} + +/** + * 도메인별 사용자 통계를 조회하는 함수 + */ +export async function getUserDomainStats() { + try { + const stats = await db + .select({ + domain: users.domain, + count: count(), + }) + .from(users) + .where(eq(users.isActive, true)) + .groupBy(users.domain) + + return { + success: true, + data: stats + } + } catch (error) { + console.error("도메인 통계 조회 오류:", error) + return { + success: false, + message: "통계 조회 중 오류가 발생했습니다.", + data: [] + } + } +} + +/** + * pending 도메인 사용자 목록을 조회하는 함수 (관리자용) + */ +export async function getPendingUsers() { + try { + const pendingUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + createdAt: users.createdAt, + domain: users.domain, + }) + .from(users) + .where(and( + eq(users.domain, "pending"), + eq(users.isActive, true) + )) + .orderBy(desc(users.createdAt)) + + return { + success: true, + data: pendingUsers + } + } catch (error) { + console.error("pending 사용자 조회 오류:", error) + return { + success: false, + message: "사용자 조회 중 오류가 발생했습니다.", + data: [] + } + } +} \ No newline at end of file -- cgit v1.2.3