diff options
Diffstat (limited to 'lib/users')
| -rw-r--r-- | lib/users/access-control/assign-domain-dialog.tsx | 253 | ||||
| -rw-r--r-- | lib/users/access-control/domain-stats-cards.tsx | 232 | ||||
| -rw-r--r-- | lib/users/access-control/users-table-columns.tsx | 149 | ||||
| -rw-r--r-- | lib/users/access-control/users-table-toolbar-actions.tsx | 51 | ||||
| -rw-r--r-- | lib/users/access-control/users-table.tsx | 166 | ||||
| -rw-r--r-- | lib/users/auth/verifyCredentails.ts | 5 | ||||
| -rw-r--r-- | lib/users/service.ts | 250 |
7 files changed, 1101 insertions, 5 deletions
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 |
