summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/admin-users/repository.ts32
-rw-r--r--lib/admin-users/validations.ts21
-rw-r--r--lib/menu-list/servcie.ts239
-rw-r--r--lib/menu-list/table/initialize-button.tsx42
-rw-r--r--lib/menu-list/table/manager-select.tsx192
-rw-r--r--lib/menu-list/table/menu-list-table.tsx280
-rw-r--r--lib/users/access-control/assign-domain-dialog.tsx253
-rw-r--r--lib/users/access-control/domain-stats-cards.tsx232
-rw-r--r--lib/users/access-control/users-table-columns.tsx149
-rw-r--r--lib/users/access-control/users-table-toolbar-actions.tsx51
-rw-r--r--lib/users/access-control/users-table.tsx166
-rw-r--r--lib/users/auth/verifyCredentails.ts5
-rw-r--r--lib/users/service.ts250
13 files changed, 1906 insertions, 6 deletions
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<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(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<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(users).where(where);
+ return res[0]?.count ?? 0;
+}
+
export async function groupByCompany(
tx: PgTransaction<any, any, any>,
) {
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<User>().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<ReturnType<typeof searchParamsCache.parse>>
+export type GetSimpleUsersSchema = Awaited<ReturnType<typeof searchParamsUsersCache.parse>>
export type CreateUserSchema = z.infer<typeof createUserSchema>
export type UpdateUserSchema = z.infer<typeof updateUserSchema>
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 (
+ <Button
+ onClick={handleInitialize}
+ disabled={isLoading}
+ variant="outline"
+ size="sm"
+ >
+ <RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} />
+ 메뉴 초기화
+ </Button>
+ );
+} \ 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 (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="w-full justify-between text-left font-normal"
+ disabled={isLoading}
+ >
+ {selectedUser ? (
+ <div className="flex items-center justify-between w-full">
+ <div className="flex flex-col min-w-0 flex-1">
+ <span className="font-medium truncate">{selectedUser.name}</span>
+ <span className="text-xs text-muted-foreground truncate">
+ {selectedUser.email}
+ </span>
+ </div>
+ {!isLoading && (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-auto p-1 hover:bg-transparent"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleClear();
+ }}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">{placeholder}</span>
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+
+ <PopoverContent className="w-[400px] p-0" align="start">
+ <Command>
+ <CommandInput
+ placeholder="사용자 검색..."
+ className="h-9"
+ />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {/* 담당자 없음 옵션 */}
+ <CommandItem
+ value="none"
+ onSelect={() => handleSelect(null)}
+ className="flex items-center gap-2"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ !selectedUser ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col">
+ <span className="font-medium">담당자 없음</span>
+ <span className="text-xs text-muted-foreground">
+ 담당자를 지정하지 않습니다
+ </span>
+ </div>
+ </CommandItem>
+
+ {/* 사용자 목록 */}
+ {availableUsers.map((user) => (
+ <CommandItem
+ key={user.id}
+ value={`${user.name} ${user.email}`} // 검색을 위해 이름과 이메일 모두 포함
+ onSelect={() => handleSelect(user.id)}
+ className="flex items-center gap-2"
+ disabled={user.id === otherManagerId && user.id !== currentManagerId}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selectedUser?.id === user.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col min-w-0 flex-1">
+ <span className="font-medium truncate">{user.name}</span>
+ <span className="text-xs text-muted-foreground truncate">
+ {user.email}
+ </span>
+ </div>
+ {user.id === otherManagerId && user.id !== currentManagerId && (
+ <span className="text-xs text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded">
+ 다른 담당자로 선택됨
+ </span>
+ )}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ );
+} \ 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<string>("all");
+ const [sectionFilter, setSectionFilter] = useState<string>("all");
+ const [statusFilter, setStatusFilter] = useState<string>("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 (
+ <div className="space-y-4">
+ {/* 필터 영역 */}
+ <div className="flex flex-col sm:flex-row gap-4">
+ <div className="flex-1">
+ <div className="relative">
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
+ <Input
+ placeholder="메뉴명, 경로, 설명으로 검색..."
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ className="pl-8"
+ />
+ </div>
+ </div>
+
+ <div className="flex gap-2">
+ <Select value={domainFilter} onValueChange={setDomainFilter}>
+ <SelectTrigger className="w-[120px]">
+ <SelectValue placeholder="도메인" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">전체 도메인</SelectItem>
+ <SelectItem value="evcp">EVCP</SelectItem>
+ <SelectItem value="partners">Partners</SelectItem>
+ </SelectContent>
+ </Select>
+
+ <Select value={sectionFilter} onValueChange={setSectionFilter}>
+ <SelectTrigger className="w-[150px]">
+ <SelectValue placeholder="섹션" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">전체 섹션</SelectItem>
+ {sections.map((section) => (
+ <SelectItem key={section} value={section}>
+ {section}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <Select value={statusFilter} onValueChange={setStatusFilter}>
+ <SelectTrigger className="w-[100px]">
+ <SelectValue placeholder="상태" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="all">전체</SelectItem>
+ <SelectItem value="active">활성</SelectItem>
+ <SelectItem value="inactive">비활성</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* 결과 요약 */}
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
+ <span>
+ 총 {filteredMenus.length}개의 메뉴
+ {searchQuery && ` (${initialMenus.length}개 중 검색 결과)`}
+ </span>
+ </div>
+
+ {/* 테이블 */}
+ <div className="rounded-md border">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-[50px]">상태</TableHead>
+ <TableHead>메뉴 정보</TableHead>
+ <TableHead className="w-[100px]">도메인</TableHead>
+ <TableHead className="w-[200px]">담당자 1</TableHead>
+ <TableHead className="w-[200px]">담당자 2</TableHead>
+ {/* <TableHead className="w-[80px]">동작</TableHead> */}
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {filteredMenus.length === 0 ? (
+ <TableRow>
+ <TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
+ 조건에 맞는 메뉴가 없습니다.
+ </TableCell>
+ </TableRow>
+ ) : (
+ filteredMenus.map((menu) => {
+ const domainUsers = getFilteredUsers(menu.domain);
+
+ return (
+ <TableRow key={menu.id}>
+ <TableCell>
+ <Switch
+ checked={menu.isActive}
+ onCheckedChange={(checked) => handleToggleActive(menu.menuPath, checked)}
+ />
+ </TableCell>
+
+ <TableCell>
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{menu.menuTitle}</span>
+ <Badge variant="outline" className="text-xs">
+ {menu.sectionTitle}
+ </Badge>
+ {menu.menuGroup && (
+ <Badge variant="secondary" className="text-xs">
+ {menu.menuGroup}
+ </Badge>
+ )}
+ </div>
+ <div className="text-xs text-muted-foreground font-mono">
+ {menu.menuPath}
+ </div>
+ {menu.menuDescription && (
+ <div className="text-xs text-muted-foreground">
+ {menu.menuDescription}
+ </div>
+ )}
+ </div>
+ </TableCell>
+
+ <TableCell>
+ <Badge
+ variant={menu.domain === "evcp" ? "default" : "secondary"}
+ className="text-xs"
+ >
+ {menu.domain.toUpperCase()}
+ </Badge>
+ </TableCell>
+
+ <TableCell>
+ <ManagerSelect
+ menuPath={menu.menuPath}
+ currentManagerId={menu.manager1Id}
+ users={domainUsers}
+ placeholder="담당자 1 선택"
+ otherManagerId={menu.manager2Id}
+ type="manager1"
+ />
+ </TableCell>
+
+ <TableCell>
+ <ManagerSelect
+ menuPath={menu.menuPath}
+ currentManagerId={menu.manager2Id}
+ users={domainUsers}
+ placeholder="담당자 2 선택"
+ otherManagerId={menu.manager1Id}
+ type="manager2"
+ />
+ </TableCell>
+
+ {/* <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(menu.menuPath, '_blank')}
+ >
+ <ExternalLink className="h-3 w-3" />
+ </Button>
+ </TableCell> */}
+ </TableRow>
+ );
+ })
+ )}
+ </TableBody>
+ </Table>
+ </div>
+ </div>
+ );
+} \ 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<string>("")
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 도메인별 사용자 그룹핑
+ const usersByDomain = React.useMemo(() => {
+ const groups: Record<string, User[]> = {}
+ 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 (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Users className="size-4" />
+ 도메인 할당 ({users.length}명)
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="size-5" />
+ 사용자 도메인 할당
+ </DialogTitle>
+ <DialogDescription>
+ 선택된 {users.length}명의 사용자에게 도메인을 할당합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 현재 사용자 도메인 분포 */}
+ <div>
+ <h4 className="text-sm font-medium mb-3">현재 도메인 분포</h4>
+ <div className="flex flex-wrap gap-2">
+ {Object.entries(usersByDomain).map(([domain, domainUsers]) => {
+ const domainInfo = domainOptions.find(opt => opt.value === domain)
+ return (
+ <Badge key={domain} variant="outline" className="gap-1">
+ <span>{domainInfo?.icon || "⚪"}</span>
+ {domainInfo?.label || domain} ({domainUsers.length}명)
+ </Badge>
+ )
+ })}
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 사용자 목록 */}
+ <div>
+ <h4 className="text-sm font-medium mb-3">대상 사용자</h4>
+ <ScrollArea className="h-32 w-full border rounded-md p-3">
+ <div className="space-y-2">
+ {users.map((user, index) => (
+ <div key={user.id} className="flex items-center justify-between text-sm">
+ <div>
+ <span className="font-medium">{user.name}</span>
+ <span className="text-muted-foreground ml-2">({user.email})</span>
+ </div>
+ <Badge variant="secondary" className="text-xs">
+ {domainOptions.find(opt => opt.value === user.domain)?.label || user.domain}
+ </Badge>
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
+ </div>
+
+ <Separator />
+
+ {/* 도메인 선택 */}
+ <div>
+ <h4 className="text-sm font-medium mb-3">할당할 도메인</h4>
+ <Select value={selectedDomain} onValueChange={setSelectedDomain}>
+ <SelectTrigger>
+ <SelectValue placeholder="도메인을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {domainOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ <div className="flex items-start gap-3 py-1">
+ <span className="text-lg">{option.icon}</span>
+ <div className="flex-1">
+ <div className="font-medium">{option.label}</div>
+ <div className="text-xs text-muted-foreground">
+ {option.description}
+ </div>
+ </div>
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* 선택된 도메인 미리보기 */}
+ {selectedDomainInfo && (
+ <div className="bg-gray-50 rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-2">
+ <CheckCircle className="size-4 text-green-600" />
+ <span className="font-medium">선택된 도메인</span>
+ </div>
+ <div className="flex items-center gap-3">
+ <span className="text-2xl">{selectedDomainInfo.icon}</span>
+ <div>
+ <div className="font-medium">{selectedDomainInfo.label}</div>
+ <div className="text-sm text-muted-foreground">
+ {selectedDomainInfo.description}
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter className="gap-2">
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleAssign}
+ disabled={!selectedDomain || isLoading}
+ >
+ {isLoading && <Loader2 className="size-4 mr-2 animate-spin" />}
+ {users.length}명에게 도메인 할당
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ 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<DomainStats[]>([])
+ 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 (
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
+ {Array.from({ length: 6 }).map((_, i) => (
+ <Card key={i} className="animate-pulse">
+ <CardContent className="p-4">
+ <div className="h-16 bg-gray-200 rounded"></div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-4">
+ {/* 요약 정보 */}
+ <div className="flex items-center justify-between">
+ <div>
+ <h3 className="text-lg font-semibold">도메인별 사용자 현황</h3>
+ <p className="text-sm text-muted-foreground">
+ 총 {totalUsers}명의 사용자가 등록되어 있습니다.
+ </p>
+ </div>
+
+ {pendingCount > 0 && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleDomainClick("pending")}
+ className={`gap-2 ${currentFilter === "pending" ? "bg-yellow-50 border-yellow-300" : ""}`}
+ >
+ <AlertCircle className="h-4 w-4 text-yellow-600" />
+ 승인 대기 {pendingCount}명
+ </Button>
+ )}
+ </div>
+
+ {/* 도메인별 통계 카드 */}
+ <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
+ {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 (
+ <Card
+ key={domain}
+ className={`cursor-pointer transition-all hover:shadow-md ${
+ isActive
+ ? `${config.bgColor} ${config.borderColor} border-2`
+ : "hover:bg-gray-50"
+ }`}
+ onClick={() => handleDomainClick(domain)}
+ >
+ <CardContent className="p-4">
+ <div className="flex items-center space-x-3">
+ <div className={`p-2 rounded-full ${config.color}`}>
+ <IconComponent className="h-4 w-4 text-white" />
+ </div>
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center justify-between">
+ <p className={`text-sm font-medium ${isActive ? config.textColor : "text-gray-900"}`}>
+ {config.label}
+ </p>
+ <Badge
+ variant={isActive ? "default" : "secondary"}
+ className="ml-2"
+ >
+ {count}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground truncate">
+ {config.description}
+ </p>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ })}
+ </div>
+
+ {/* 필터 상태 표시 */}
+ {currentFilter && (
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">필터 적용됨:</span>
+ <Badge variant="outline" className="gap-1">
+ {domainConfig[currentFilter as keyof typeof domainConfig]?.label || currentFilter}
+ <button
+ onClick={() => onDomainFilter(null)}
+ className="ml-1 hover:bg-gray-200 rounded-full p-0.5"
+ >
+ ×
+ </button>
+ </Badge>
+ </div>
+ )}
+ </div>
+ )
+} \ 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<React.SetStateAction<DataTableRowAction<User> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns(): ColumnDef<User>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<User> = {
+ 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,
+ }
+
+
+
+ const groupMap: Record<string, ColumnDef<User>[]> = {}
+
+ userAccessColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<User> = {
+ 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 === "createdAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ if (cfg.id === "domain") {
+ const domainValues = row.original.domain;
+ return (
+ <div className="flex flex-wrap gap-1">
+
+ <Badge variant="outline">
+ {domainValues}
+ </Badge>
+
+ </div>
+ );
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<User>[] = []
+
+ // 순서를 고정하고 싶다면 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<User>
+}
+
+export function UsersTableToolbarActions({ table }: UsersTableToolbarActionsProps) {
+ // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ return (
+ <div className="flex items-center gap-2">
+
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <AssignDomainDialog
+ users={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ />
+ ) : null}
+
+
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "roles",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/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<ReturnType<typeof getUsersNotPartners>>,
+ ]
+ >
+}
+
+export function UserAccessControlTable({ promises }: UsersTableProps) {
+ const [{ data, pageCount }] = React.use(promises)
+ const [currentDomainFilter, setCurrentDomainFilter] = React.useState<string | null>(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<User>[] = [
+ {
+ 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 (
+ <div className="space-y-6">
+ {/* 도메인 통계 카드 */}
+ <DomainStatsCards
+ onDomainFilter={handleDomainFilter}
+ currentFilter={currentDomainFilter}
+ />
+
+ {/* 현재 필터 상태 표시 */}
+ {/* {currentDomainFilter && (
+ <div className="flex items-center justify-between bg-blue-50 border border-blue-200 rounded-lg p-3">
+ <div className="flex items-center gap-2">
+ <span className="text-sm font-medium text-blue-900">
+ 필터 적용됨:
+ </span>
+ <span className="text-sm text-blue-700">
+ {(() => {
+ const domainLabels = {
+ pending: "🟡 승인 대기",
+ evcp: "🔵 전체 시스템",
+ procurement: "🟢 구매관리팀",
+ sales: "🟣 기술영업팀",
+ engineering: "🟠 설계관리팀",
+ partners: "🟦 협력업체"
+ }
+ return domainLabels[currentDomainFilter as keyof typeof domainLabels] || currentDomainFilter
+ })()}
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-blue-600">
+ {filteredData.length}명 표시
+ </span>
+ <button
+ onClick={() => handleDomainFilter(null)}
+ className="text-sm text-blue-600 hover:text-blue-800 hover:bg-blue-100 px-2 py-1 rounded"
+ >
+ 필터 해제
+ </button>
+ </div>
+ </div>
+ )} */}
+
+ {/* 데이터 테이블 */}
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <UsersTableToolbarActions table={table}/>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ )
+} \ 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<Role[]> {
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