diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-23 06:06:27 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-23 06:06:27 +0000 |
| commit | f9bfc82880212e1a13f6bbb28ecfc87b89346f26 (patch) | |
| tree | ee792f340ebfa7eaf30d2e79f99f41213e5c5cf3 | |
| parent | edc0eabc8f5fc44408c28023ca155bd73ddf8183 (diff) | |
(김준회) 메뉴접근제어(부서별) 메뉴 구현
9 files changed, 2260 insertions, 230 deletions
diff --git a/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-domain-assignment-dialog.tsx b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-domain-assignment-dialog.tsx new file mode 100644 index 00000000..277511cb --- /dev/null +++ b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-domain-assignment-dialog.tsx @@ -0,0 +1,384 @@ +"use client"; + +import * as React from "react"; +import { Loader2, Users, Building2, AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + DepartmentNode +} from "@/lib/users/knox-service"; +import { + getDepartmentDomainAssignmentsByDepartments, + type UserDomain +} from "@/lib/users/department-domain/service"; +import { DOMAIN_OPTIONS, getDomainLabel } from "./domain-constants"; + +interface ExistingAssignment { + id: number; + companyCode: string; + departmentCode: string; + departmentName: string; + assignedDomain: string; + description?: string | null; + createdAt: Date; + updatedAt: Date; +} + +interface DepartmentDomainAssignmentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDepartments: string[]; + departments: DepartmentNode[]; + companyInfo: { code: string; name: string }; + onAssign: (assignments: { + departmentCodes: string[]; + domain: string; + description?: string; + }) => Promise<void>; + isLoading?: boolean; +} + +export function DepartmentDomainAssignmentDialog({ + open, + onOpenChange, + selectedDepartments, + departments, + companyInfo, + onAssign, + isLoading = false, +}: DepartmentDomainAssignmentDialogProps) { + const [selectedDomain, setSelectedDomain] = React.useState<string>(""); + const [description, setDescription] = React.useState<string>(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [existingAssignments, setExistingAssignments] = React.useState<ExistingAssignment[]>([]); + const [isLoadingAssignments, setIsLoadingAssignments] = React.useState(false); + + // 선택된 부서들의 정보 가져오기 + const getSelectedDepartmentInfo = React.useCallback(() => { + const findDepartment = (nodes: DepartmentNode[], code: string): DepartmentNode | null => { + for (const node of nodes) { + if (node.departmentCode === code) { + return node; + } + const found = findDepartment(node.children, code); + if (found) return found; + } + return null; + }; + + return selectedDepartments + .map(code => findDepartment(departments, code)) + .filter(Boolean) as DepartmentNode[]; + }, [departments, selectedDepartments]); + + // 회사별로 그룹화 + const selectedDepartmentsByCompany = React.useMemo(() => { + const deptInfo = getSelectedDepartmentInfo(); + const grouped = new Map<string, DepartmentNode[]>(); + + deptInfo.forEach(dept => { + if (!grouped.has(dept.companyCode)) { + grouped.set(dept.companyCode, []); + } + grouped.get(dept.companyCode)!.push(dept); + }); + + return grouped; + }, [getSelectedDepartmentInfo]); + + // 기존 할당 정보 조회 + React.useEffect(() => { + if (open && selectedDepartments.length > 0) { + const loadExistingAssignments = async () => { + setIsLoadingAssignments(true); + try { + const assignments = await getDepartmentDomainAssignmentsByDepartments(selectedDepartments); + setExistingAssignments(assignments as ExistingAssignment[]); + } catch (error) { + console.error("기존 할당 정보 조회 실패:", error); + setExistingAssignments([]); + } finally { + setIsLoadingAssignments(false); + } + }; + + loadExistingAssignments(); + } else { + setExistingAssignments([]); + } + }, [open, selectedDepartments]); + + // 폼 초기화 + React.useEffect(() => { + if (open) { + setSelectedDomain(""); + setDescription(""); + setIsSubmitting(false); + } + }, [open]); + + // 할당 처리 + const handleAssign = async () => { + if (!selectedDomain || selectedDepartments.length === 0) { + return; + } + + setIsSubmitting(true); + + try { + await onAssign({ + departmentCodes: selectedDepartments, + domain: selectedDomain, + description: description.trim() || undefined, + }); + + // 성공 시 다이얼로그 닫기 + onOpenChange(false); + } catch (error) { + console.error("도메인 할당 실패:", error); + } finally { + setIsSubmitting(false); + } + }; + + const canSubmit = selectedDomain && selectedDepartments.length > 0 && !isSubmitting && !isLoading; + const selectedDomainInfo = DOMAIN_OPTIONS.find(opt => opt.value === selectedDomain); + const hasConflicts = existingAssignments.some(a => a.assignedDomain !== selectedDomain && selectedDomain); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Building2 className="h-5 w-5" /> + 부서별 도메인 할당 + </DialogTitle> + <DialogDescription> + 선택된 {selectedDepartments.length}개 부서에 도메인을 할당합니다. + 상위 부서를 선택한 경우 하위 부서들도 자동으로 포함됩니다. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-hidden"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-6"> + {/* 선택된 부서들 표시 */} + <div className="space-y-3"> + <Label className="text-sm font-medium flex items-center gap-2"> + <Users className="h-4 w-4" /> + 선택된 부서 ({selectedDepartments.length}개) + </Label> + + <div className="border rounded-md p-3 max-h-32 overflow-y-auto"> + {Array.from(selectedDepartmentsByCompany.entries()).map(([companyCode, depts]) => ( + <div key={companyCode} className="mb-3 last:mb-0"> + <div className="text-sm font-medium text-muted-foreground mb-2"> + {companyCode} - {companyInfo.name} + </div> + <div className="flex flex-wrap gap-2"> + {depts.map((dept) => ( + <Badge + key={dept.departmentCode} + variant="outline" + className="text-xs" + > + {dept.departmentName || dept.departmentCode} + </Badge> + ))} + </div> + </div> + ))} + </div> + </div> + + {/* 기존 할당 현황 */} + {(existingAssignments.length > 0 || isLoadingAssignments) && ( + <> + <Separator /> + <div className="space-y-3"> + <Label className="text-sm font-medium flex items-center gap-2"> + <AlertCircle className="h-4 w-4" /> + 현재 할당 현황 + </Label> + + {isLoadingAssignments ? ( + <div className="flex items-center justify-center py-4"> + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + 기존 할당 정보를 조회하는 중... + </div> + ) : ( + <div className="border rounded-md"> + <Table> + <TableHeader> + <TableRow> + <TableHead>부서</TableHead> + <TableHead>현재 도메인</TableHead> + <TableHead>할당일</TableHead> + <TableHead>설명</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {existingAssignments.map((assignment) => ( + <TableRow key={assignment.id}> + <TableCell className="font-medium"> + {assignment.departmentName} + </TableCell> + <TableCell> + <Badge + variant={assignment.assignedDomain === 'evcp' ? 'default' : 'secondary'} + > + {getDomainLabel(assignment.assignedDomain)} + </Badge> + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {new Date(assignment.createdAt).toLocaleDateString('ko-KR')} + </TableCell> + <TableCell className="max-w-xs truncate text-sm"> + {assignment.description || '-'} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + )} + + {hasConflicts && ( + <div className="bg-yellow-50 border-yellow-200 border rounded-md p-3"> + <div className="flex items-start gap-2"> + <AlertCircle className="h-4 w-4 text-yellow-600 mt-0.5" /> + <div className="text-sm"> + <div className="font-medium text-yellow-800">도메인 변경 주의</div> + <div className="text-yellow-700"> + 일부 부서의 기존 도메인과 다른 도메인을 할당하려고 합니다. + 기존 할당은 자동으로 비활성화됩니다. + </div> + </div> + </div> + </div> + )} + </div> + </> + )} + + <Separator /> + + {/* 도메인 선택 */} + <div className="space-y-2"> + <Label htmlFor="domain-select" className="text-sm font-medium"> + 할당할 도메인 * + </Label> + <Select value={selectedDomain} onValueChange={setSelectedDomain}> + <SelectTrigger id="domain-select"> + <SelectValue placeholder="도메인을 선택하세요" /> + </SelectTrigger> + <SelectContent> + {DOMAIN_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <div className="flex flex-col"> + <span className="font-medium">{option.label}</span> + <span className="text-xs text-muted-foreground"> + {option.description} + </span> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + + {selectedDomainInfo && ( + <div className="text-sm text-muted-foreground"> + <Badge variant="secondary" className="mr-2"> + {selectedDomainInfo.label} + </Badge> + {selectedDomainInfo.description} + </div> + )} + </div> + + {/* 할당 사유/설명 */} + <div className="space-y-2"> + <Label htmlFor="description" className="text-sm font-medium"> + 할당 사유 또는 설명 (선택사항) + </Label> + <Textarea + id="description" + placeholder="예: 구매 업무 담당자들에게 procurement 도메인 할당" + value={description} + onChange={(e) => setDescription(e.target.value)} + rows={3} + maxLength={500} + /> + <div className="text-xs text-muted-foreground text-right"> + {description.length}/500 + </div> + </div> + + {/* 주의사항 */} + <div className="bg-muted/50 p-3 rounded-md"> + <div className="text-sm text-muted-foreground"> + <div className="font-medium mb-1">⚠️ 주의사항</div> + <ul className="list-disc list-inside space-y-1 text-xs"> + <li>도메인 할당은 해당 부서 소속 사용자들의 메뉴 접근 권한에 영향을 줍니다.</li> + <li>기존에 다른 도메인이 할당된 부서는 새로운 도메인으로 덮어씌워집니다.</li> + <li>Knox 조직도 변경으로 인해 부서가 삭제된 경우, 해당 할당은 고립된 레코드가 됩니다.</li> + </ul> + </div> + </div> + </div> + </ScrollArea> + </div> + + <DialogFooter className="border-t pt-4"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting || isLoading} + > + 취소 + </Button> + <Button + onClick={handleAssign} + disabled={!canSubmit} + > + {isSubmitting || isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 할당 중... + </> + ) : ( + `도메인 할당 (${selectedDepartments.length}개 부서)` + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-menu-access-manager.tsx b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-menu-access-manager.tsx new file mode 100644 index 00000000..bf43e7a9 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-menu-access-manager.tsx @@ -0,0 +1,297 @@ +"use client"; + +import * as React from "react"; +import { useState, useTransition, useEffect } from "react"; +import { Settings, Plus, Users } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { DepartmentTreeView } from "./department-tree-view"; +import { DepartmentDomainAssignmentDialog } from "./department-domain-assignment-dialog"; +import { + type DepartmentNode +} from "@/lib/users/knox-service"; +import { + assignDomainToDepartments, + getDepartmentDomainAssignments, + type UserDomain +} from "@/lib/users/department-domain/service"; +import { DOMAIN_OPTIONS } from "./domain-constants"; + +interface DepartmentMenuAccessManagerProps { + departmentsPromise: Promise<DepartmentNode[]>; + companyInfo: { code: string; name: string }; +} + +interface DepartmentAssignment { + id: number; + departmentCode: string; + departmentName: string; + assignedDomain: string; + description?: string | null; +} + +export function DepartmentMenuAccessManager({ + departmentsPromise, + companyInfo +}: DepartmentMenuAccessManagerProps) { + const [departments, setDepartments] = useState<DepartmentNode[]>([]); + const [selectedDepartments, setSelectedDepartments] = useState<string[]>([]); + const [assignments, setAssignments] = useState<DepartmentAssignment[]>([]); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + const [isDepartmentsLoading, setIsDepartmentsLoading] = useState(true); + const [isAssignmentsLoading, setIsAssignmentsLoading] = useState(true); + + // Promise를 해결하여 부서 데이터 로드 + useEffect(() => { + const loadDepartments = async () => { + setIsDepartmentsLoading(true); + try { + const departmentTree = await departmentsPromise; + setDepartments(departmentTree); + } catch (error) { + console.error("부서 트리 로드 실패:", error); + toast.error("부서 정보를 불러오는데 실패했습니다."); + setDepartments([]); + } finally { + setIsDepartmentsLoading(false); + } + }; + + loadDepartments(); + }, [departmentsPromise]); + + // 기존 할당 정보 로드 + useEffect(() => { + const loadAssignments = async () => { + setIsAssignmentsLoading(true); + try { + const assignmentData = await getDepartmentDomainAssignments(); + setAssignments(assignmentData as DepartmentAssignment[]); + } catch (error) { + console.error("할당 정보 로드 실패:", error); + toast.error("할당 정보를 불러오는데 실패했습니다."); + setAssignments([]); + } finally { + setIsAssignmentsLoading(false); + } + }; + + loadAssignments(); + }, []); + + // 선택된 부서들의 정보 가져오기 + const getSelectedDepartmentInfo = React.useCallback(() => { + const findDepartment = (nodes: DepartmentNode[], code: string): DepartmentNode | null => { + for (const node of nodes) { + if (node.departmentCode === code) { + return node; + } + const found = findDepartment(node.children, code); + if (found) return found; + } + return null; + }; + + return selectedDepartments + .map(code => findDepartment(departments, code)) + .filter(Boolean) as DepartmentNode[]; + }, [departments, selectedDepartments]); + + // 도메인 할당 처리 + const handleDomainAssign = async (assignmentData: { + departmentCodes: string[]; + domain: string; + description?: string; + }) => { + // 선택된 부서들의 이름 매핑 생성 + const departmentNames: Record<string, string> = {}; + const collectDepartmentNames = (nodes: DepartmentNode[]) => { + nodes.forEach(node => { + if (assignmentData.departmentCodes.includes(node.departmentCode)) { + departmentNames[node.departmentCode] = node.departmentName || node.departmentCode; + } + collectDepartmentNames(node.children); + }); + }; + collectDepartmentNames(departments); + + startTransition(async () => { + try { + const result = await assignDomainToDepartments({ + departmentCodes: assignmentData.departmentCodes, + domain: assignmentData.domain as UserDomain, + description: assignmentData.description, + departmentNames, + }); + + if (result.success) { + toast.success(result.message); + setSelectedDepartments([]); + + // 할당 정보 새로고침 + try { + const updatedAssignments = await getDepartmentDomainAssignments(); + setAssignments(updatedAssignments as DepartmentAssignment[]); + } catch (error) { + console.error("할당 정보 새로고침 실패:", error); + } + } else { + toast.error(result.message); + } + } catch (error) { + console.error("도메인 할당 실패:", error); + toast.error("도메인 할당 중 오류가 발생했습니다."); + } + }); + }; + + const canAssign = selectedDepartments.length > 0; + const selectedDepartmentInfo = getSelectedDepartmentInfo(); + + const isLoading = isDepartmentsLoading || isAssignmentsLoading; + + return ( + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> + {/* 왼쪽: 조직도 트리 */} + <div className="lg:col-span-2"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Settings className="h-5 w-5" /> + 조직도 - {companyInfo.name} + </CardTitle> + <CardDescription> + 부서를 선택하여 도메인을 할당하세요. 상위 부서 선택 시 하위 부서들도 자동으로 포함됩니다. + </CardDescription> + </CardHeader> + <CardContent className="p-0"> + {isLoading ? ( + <div className="flex items-center justify-center h-[80vh]"> + <div className="text-center"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div> + <p className="text-muted-foreground">조직도를 불러오는 중...</p> + </div> + </div> + ) : ( + <DepartmentTreeView + departments={departments} + selectedDepartments={selectedDepartments} + onSelectionChange={setSelectedDepartments} + assignments={assignments} + /> + )} + </CardContent> + </Card> + </div> + + {/* 오른쪽: 선택된 부서 정보 및 할당 버튼 */} + <div className="space-y-6"> + {/* 선택된 부서 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Users className="h-5 w-5" /> + 선택된 부서 + </CardTitle> + <CardDescription> + {selectedDepartments.length}개 부서가 선택되었습니다 + </CardDescription> + </CardHeader> + <CardContent> + {selectedDepartmentInfo.length === 0 ? ( + <div className="text-center py-4 text-muted-foreground"> + 부서를 선택해주세요 + </div> + ) : ( + <div className="space-y-2 max-h-60 overflow-y-auto"> + {selectedDepartmentInfo.map((dept) => { + const assignment = assignments.find(a => a.departmentCode === dept.departmentCode); + return ( + <div + key={dept.departmentCode} + className="flex items-center justify-between p-2 bg-accent/20 rounded-md" + > + <div className="min-w-0"> + <div className="font-medium truncate"> + {dept.departmentName || dept.departmentCode} + </div> + <div className="text-xs text-muted-foreground"> + {dept.departmentCode} + </div> + </div> + {assignment && ( + <Badge variant="outline" className="text-xs shrink-0"> + {assignment.assignedDomain} + </Badge> + )} + </div> + ); + })} + </div> + )} + </CardContent> + </Card> + + {/* 도메인 할당 버튼 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">도메인 할당</CardTitle> + <CardDescription> + 선택된 부서들에 도메인을 할당합니다 + </CardDescription> + </CardHeader> + <CardContent> + <Button + onClick={() => setIsDialogOpen(true)} + disabled={!canAssign || isPending} + size="lg" + className="w-full" + > + <Plus className="mr-2 h-4 w-4" /> + 도메인 할당 ({selectedDepartments.length}개 부서) + </Button> + + {canAssign && ( + <div className="mt-3 text-sm text-muted-foreground"> + 상위 부서를 선택한 경우 하위 부서들도 자동으로 동일한 도메인이 할당됩니다. + </div> + )} + </CardContent> + </Card> + + {/* 범례 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">도메인 범례</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-1 gap-2 text-sm"> + {DOMAIN_OPTIONS.map((option) => ( + <div key={option.value} className="flex items-center gap-2"> + <Badge className={option.color}> + {option.value} + </Badge> + <span>{option.description}</span> + </div> + ))} + </div> + </CardContent> + </Card> + </div> + + {/* 도메인 할당 다이얼로그 */} + <DepartmentDomainAssignmentDialog + open={isDialogOpen} + onOpenChange={setIsDialogOpen} + selectedDepartments={selectedDepartments} + departments={departments} + companyInfo={companyInfo} + onAssign={handleDomainAssign} + isLoading={isPending} + /> + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx new file mode 100644 index 00000000..00c375a9 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx @@ -0,0 +1,353 @@ +"use client"; + +import * as React from "react"; +import { ChevronDown, ChevronRight, Minus, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { DepartmentNode } from "@/lib/users/knox-service"; +import { getDomainLabel, getDomainColor } from "./domain-constants"; + +interface DepartmentAssignment { + id: number; + departmentCode: string; + assignedDomain: string; + description?: string | null; +} + +interface DepartmentTreeViewProps { + departments: DepartmentNode[]; + selectedDepartments: string[]; + onSelectionChange: (selected: string[]) => void; + assignments: DepartmentAssignment[]; + className?: string; +} + +interface TreeNodeProps { + node: DepartmentNode; + selectedDepartments: string[]; + onToggle: (departmentCode: string) => void; + expandedNodes: Set<string>; + onExpandToggle: (departmentCode: string) => void; + assignments: DepartmentAssignment[]; + level: number; +} + +function TreeNode({ + node, + selectedDepartments, + onToggle, + expandedNodes, + onExpandToggle, + assignments, + level +}: TreeNodeProps) { + const isExpanded = expandedNodes.has(node.departmentCode); + const hasChildren = node.children.length > 0; + + // 현재 부서에 할당된 도메인 찾기 + const assignment = assignments.find(a => a.departmentCode === node.departmentCode); + + // 현재 노드의 선택 상태 확인 + const isSelected = selectedDepartments.includes(node.departmentCode); + + // 하위 노드들 중 선택된 것이 있는지 확인 (부분 선택 상태 표시용) + const hasSelectedChildren = React.useMemo(() => { + if (!hasChildren) return false; + + const getAllChildCodes = (dept: DepartmentNode): string[] => { + const codes: string[] = []; + dept.children.forEach(child => { + codes.push(child.departmentCode); + codes.push(...getAllChildCodes(child)); + }); + return codes; + }; + + const childCodes = getAllChildCodes(node); + return childCodes.some(code => selectedDepartments.includes(code)); + }, [node, selectedDepartments, hasChildren]); + + const handleToggle = () => { + onToggle(node.departmentCode); + }; + + const handleExpandToggle = () => { + if (hasChildren) { + onExpandToggle(node.departmentCode); + } + }; + + return ( + <div className="select-none"> + <div + className={cn( + "flex items-center gap-2 py-2 px-3 hover:bg-accent/50 rounded-md transition-colors", + (isSelected || (!isSelected && hasSelectedChildren)) && "bg-accent/20" + )} + style={{ marginLeft: `${level * 16}px` }} + > + {/* 확장/축소 버튼 */} + <div className="flex items-center justify-center w-5 h-5"> + {hasChildren ? ( + <Button + variant="ghost" + size="sm" + className="h-5 w-5 p-0 hover:bg-transparent" + onClick={handleExpandToggle} + > + {isExpanded ? ( + <ChevronDown className="h-3 w-3" /> + ) : ( + <ChevronRight className="h-3 w-3" /> + )} + </Button> + ) : null} + </div> + + {/* 체크박스 */} + <div className="flex items-center"> + <Checkbox + checked={isSelected} + onCheckedChange={handleToggle} + className={cn( + "h-4 w-4", + !isSelected && hasSelectedChildren && "[&>*:first-child]:opacity-50" + )} + /> + </div> + + {/* 부서 정보 */} + <div className="flex-1 min-w-0 cursor-pointer" onClick={handleToggle}> + <div className="flex items-center gap-2"> + <span className={cn( + "font-medium truncate", + (isSelected || (!isSelected && hasSelectedChildren)) && "text-primary" + )}> + {node.departmentName || node.departmentCode} + </span> + + {/* 할당된 도메인 표시 */} + {assignment && ( + <Badge + className={cn( + "text-xs shrink-0", + getDomainColor(assignment.assignedDomain) + )} + variant="outline" + > + {getDomainLabel(assignment.assignedDomain)} + </Badge> + )} + + {/* lowDepartmentYn 표시 */} + {node.lowDepartmentYn === 'T' && ( + <Badge + variant="secondary" + className="text-xs shrink-0" + > + 하위 + </Badge> + )} + </div> + + {/* 부서 코드 */} + <div className="text-xs text-muted-foreground truncate"> + {node.departmentCode} + {assignment?.description && ( + <span className="ml-1">• {assignment.description}</span> + )} + </div> + </div> + </div> + + {/* 하위 노드들 */} + {hasChildren && isExpanded && ( + <div className="mt-1"> + {node.children.map((child) => ( + <TreeNode + key={child.departmentCode} + node={child} + selectedDepartments={selectedDepartments} + onToggle={onToggle} + expandedNodes={expandedNodes} + onExpandToggle={onExpandToggle} + assignments={assignments} + level={level + 1} + /> + ))} + </div> + )} + </div> + ); +} + +export function DepartmentTreeView({ + departments, + selectedDepartments, + onSelectionChange, + assignments, + className, +}: DepartmentTreeViewProps) { + const [expandedNodes, setExpandedNodes] = React.useState<Set<string>>(new Set()); + + // 부서 토글 핸들러 + const handleToggle = (departmentCode: string) => { + const findNode = (nodes: DepartmentNode[], code: string): DepartmentNode | null => { + for (const node of nodes) { + if (node.departmentCode === code) return node; + const found = findNode(node.children, code); + if (found) return found; + } + return null; + }; + + const getAllChildCodes = (node: DepartmentNode): string[] => { + const codes: string[] = []; + node.children.forEach(child => { + codes.push(child.departmentCode); + codes.push(...getAllChildCodes(child)); + }); + return codes; + }; + + const targetNode = findNode(departments, departmentCode); + if (!targetNode) return; + + const isCurrentlySelected = selectedDepartments.includes(departmentCode); + + let newSelected: string[]; + if (isCurrentlySelected) { + // 선택 해제: 해당 부서만 제거 (하위 부서는 유지, 상위 부서에도 영향 없음) + newSelected = selectedDepartments.filter(code => code !== departmentCode); + } else { + // 선택: 해당 부서 + 모든 하위 부서 추가 + const childCodes = getAllChildCodes(targetNode); + const codesToAdd = [departmentCode, ...childCodes].filter(code => !selectedDepartments.includes(code)); + newSelected = [...selectedDepartments, ...codesToAdd]; + } + + onSelectionChange(newSelected); + }; + + // 노드 확장/축소 핸들러 + const handleExpandToggle = (departmentCode: string) => { + const newExpanded = new Set(expandedNodes); + if (newExpanded.has(departmentCode)) { + newExpanded.delete(departmentCode); + } else { + newExpanded.add(departmentCode); + } + setExpandedNodes(newExpanded); + }; + + // 전체 확장/축소 + const handleExpandAll = () => { + if (expandedNodes.size === 0) { + const getAllCodes = (nodes: DepartmentNode[]): string[] => { + const codes: string[] = []; + nodes.forEach(node => { + if (node.children.length > 0) { + codes.push(node.departmentCode); + codes.push(...getAllCodes(node.children)); + } + }); + return codes; + }; + setExpandedNodes(new Set(getAllCodes(departments))); + } else { + setExpandedNodes(new Set()); + } + }; + + // 전체 선택/해제 + const handleSelectAll = () => { + if (selectedDepartments.length === 0) { + // 전체 선택 + const allCodes: string[] = []; + const collectCodes = (nodes: DepartmentNode[]) => { + nodes.forEach(node => { + allCodes.push(node.departmentCode); + collectCodes(node.children); + }); + }; + collectCodes(departments); + onSelectionChange(allCodes); + } else { + // 전체 해제 + onSelectionChange([]); + } + }; + + return ( + <div className={cn("border rounded-lg", className)}> + {/* 헤더 */} + <div className="flex items-center justify-between p-3 border-b bg-muted/30"> + <h3 className="font-medium">조직도</h3> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleExpandAll} + className="text-xs" + > + {expandedNodes.size === 0 ? ( + <> + <Plus className="mr-1 h-3 w-3" /> + 전체 펼치기 + </> + ) : ( + <> + <Minus className="mr-1 h-3 w-3" /> + 전체 접기 + </> + )} + </Button> + <Button + variant="outline" + size="sm" + onClick={handleSelectAll} + className="text-xs" + > + {selectedDepartments.length === 0 ? "전체 선택" : "선택 해제"} + </Button> + </div> + </div> + + {/* 트리 본문 */} + <ScrollArea className="h-[80vh] p-2"> + {departments.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + 부서 정보가 없습니다 + </div> + ) : ( + <div className="space-y-1"> + {departments.map((dept) => ( + <TreeNode + key={dept.departmentCode} + node={dept} + selectedDepartments={selectedDepartments} + onToggle={handleToggle} + expandedNodes={expandedNodes} + onExpandToggle={handleExpandToggle} + assignments={assignments} + level={0} + /> + ))} + </div> + )} + </ScrollArea> + + {/* 푸터 */} + {selectedDepartments.length > 0 && ( + <div className="border-t p-3 bg-muted/30"> + <div className="text-sm text-muted-foreground"> + 선택된 부서: <span className="font-medium text-foreground">{selectedDepartments.length}개</span> + </div> + </div> + )} + </div> + ); +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/menu-access-dept/_components/domain-constants.ts b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/domain-constants.ts new file mode 100644 index 00000000..2b104d0e --- /dev/null +++ b/app/[lng]/evcp/(evcp)/menu-access-dept/_components/domain-constants.ts @@ -0,0 +1,52 @@ + + +// 통합된 도메인 옵션 - 모든 도메인 정보를 포함 +export const DOMAIN_OPTIONS = [ + { + value: "pending", + label: "pending", + description: "승인 대기 상태", + color: "bg-yellow-100 text-yellow-800 border-yellow-200" + }, + { + value: "evcp", + label: "evcp", + description: "eVCP 시스템 관리자", + color: "bg-blue-100 text-blue-800 border-blue-200" + }, + { + value: "procurement", + label: "procurement", + description: "구매", + color: "bg-green-100 text-green-800 border-green-200" + }, + { + value: "sales", + label: "sales", + description: "기술영업", + color: "bg-purple-100 text-purple-800 border-purple-200" + }, + { + value: "engineering", + label: "engineering", + description: "설계", + color: "bg-orange-100 text-orange-800 border-orange-200" + }, +] as const; + +// 헬퍼 함수들 - 필요시 매핑 객체 생성 +export const getDomainOption = (value: string) => { + return DOMAIN_OPTIONS.find(option => option.value === value); +}; + +export const getDomainLabel = (value: string) => { + return getDomainOption(value)?.label || value; +}; + +export const getDomainColor = (value: string) => { + return getDomainOption(value)?.color || "bg-gray-100 text-gray-800 border-gray-200"; +}; + +export const getDomainDescription = (value: string) => { + return getDomainOption(value)?.description || value; +};
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/menu-access-dept/page.tsx b/app/[lng]/evcp/(evcp)/menu-access-dept/page.tsx new file mode 100644 index 00000000..dfda9172 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/menu-access-dept/page.tsx @@ -0,0 +1,34 @@ +import * as React from "react"; +import { Separator } from "@/components/ui/separator"; +import { Shell } from "@/components/shell"; +import { DepartmentMenuAccessManager } from "./_components/department-menu-access-manager"; +import { getAllDepartmentsTree, getCurrentCompanyInfo } from "@/lib/users/knox-service"; + +export default async function DepartmentMenuAccessPage() { + // Promise들을 생성하여 클라이언트 컴포넌트에 전달 + const departmentsPromise = getAllDepartmentsTree(); + const companyInfo = await getCurrentCompanyInfo(); + + return ( + <Shell> + <div className="space-y-6"> + {/* 헤더 섹션 */} + <div className="space-y-2"> + <h1 className="text-2xl font-bold tracking-tight">부서별 메뉴 접근권한 관리</h1> + <p className="text-muted-foreground"> + Knox 조직도를 기반으로 부서별 도메인을 할당하여 메뉴 접근 권한을 관리할 수 있습니다. + 상위 부서를 선택하면 하위 부서들도 자동으로 포함됩니다. + </p> + </div> + + <Separator /> + + {/* 메인 관리 컴포넌트 */} + <DepartmentMenuAccessManager + departmentsPromise={departmentsPromise} + companyInfo={companyInfo} + /> + </div> + </Shell> + ); +} diff --git a/config/menuConfig.ts b/config/menuConfig.ts index c703c7ce..c48ba508 100644 --- a/config/menuConfig.ts +++ b/config/menuConfig.ts @@ -16,429 +16,435 @@ export interface MenuSection { export const mainNav: MenuSection[] = [ { - title: "기준 정보 관리", + title: '기준 정보 관리', useGrouping: true, // 그룹핑 적용 items: [ { - title: "견적 프로젝트 리스트", - href: "/evcp/bid-projects", - description: "MDG에서 받은 견적 프로젝트 리스트(P)", + title: '견적 프로젝트 리스트', + href: '/evcp/bid-projects', + description: 'MDG에서 받은 견적 프로젝트 리스트(P)', // icon: "Briefcase", - group: "기본 정보" + group: '기본 정보', }, { - title: "프로젝트 리스트", - href: "/evcp/projects", - description: "MDG에서 받은 프로젝트 리스트(C)", + title: '프로젝트 리스트', + href: '/evcp/projects', + description: 'MDG에서 받은 프로젝트 리스트(C)', // icon: "Briefcase", - group: "기본 정보" + group: '기본 정보', }, { - title: "패키지 넘버", - href: "/evcp/items", - description: "견적(PR 발행 전), 입찰(PR 발행 전), 설계 데이터 및 문서에서 사용되는 패키지 넘버 목록 ", + title: '패키지 넘버', + href: '/evcp/items', + description: + '견적(PR 발행 전), 입찰(PR 발행 전), 설계 데이터 및 문서에서 사용되는 패키지 넘버 목록 ', // icon: "ListTodo", - group: "기본 정보" + group: '기본 정보', }, { - title: "객체 클래스 목록", - href: "/evcp/equip-class", - description: "객체 클래스 목록", + title: '객체 클래스 목록', + href: '/evcp/equip-class', + description: '객체 클래스 목록', // icon: "Database", - group: "설계 정보" + group: '설계 정보', }, { - title: "서브 클래스 목록", - href: "/evcp/sub-class", - description: "서브 클래스 목록", + title: '서브 클래스 목록', + href: '/evcp/sub-class', + description: '서브 클래스 목록', // icon: "Database", - group: "설계 정보" + group: '설계 정보', }, { - title: "태그 타입 목록", - href: "/evcp/tag-numbering", - description: "Tag Numbering을 위한 기준 정보", + title: '태그 타입 목록', + href: '/evcp/tag-numbering', + description: 'Tag Numbering을 위한 기준 정보', // icon: "Tag", - group: "설계 정보" + group: '설계 정보', }, { - title: "레지스터 목록", - href: "/evcp/form-list", - description: "협력업체 데이터 입력을 위한 Form 레지스터 목록 확인", + title: '레지스터 목록', + href: '/evcp/form-list', + description: '협력업체 데이터 입력을 위한 Form 레지스터 목록 확인', // icon: "FileCheck", - group: "설계 정보" + group: '설계 정보', }, { - title: "Document Numbering Rule (해양)", - href: "/evcp/docu-list-rule", - description: "벤더 제출 문서 리스트 작성 시에 사용되는 넘버링", + title: 'Document Numbering Rule (해양)', + href: '/evcp/docu-list-rule', + description: '벤더 제출 문서 리스트 작성 시에 사용되는 넘버링', // icon: "FileCheck", - group: "설계 정보" + group: '설계 정보', }, { - title: "Document Code", - href: "/evcp/docu-code", - description: "벤더 제출 문서 리스트 작성 시에 사용되는 Document Code", + title: 'Document Code', + href: '/evcp/docu-code', + description: '벤더 제출 문서 리스트 작성 시에 사용되는 Document Code', // icon: "FileCheck", - group: "설계 정보" + group: '설계 정보', }, { - title: "인코텀즈 관리", - href: "/evcp/incoterms", - description: "인코텀즈를 등록", + title: '인코텀즈 관리', + href: '/evcp/incoterms', + description: '인코텀즈를 등록', // icon: "ListTodo", - group: "구매 정보" + group: '구매 정보', }, { - title: "지급 조건 관리", - href: "/evcp/payment-conditions", - description: "지급 조건을 등록", + title: '지급 조건 관리', + href: '/evcp/payment-conditions', + description: '지급 조건을 등록', // icon: "ListTodo", - group: "구매 정보" + group: '구매 정보', }, { - title: "업체 유형 관리", - href: "/evcp/vendor-type", - description: "업체 유형 관리", + title: '업체 유형 관리', + href: '/evcp/vendor-type', + description: '업체 유형 관리', // icon: "ListTodo", - group: "구매 정보" + group: '구매 정보', }, { - title: "기본 계약문서 관리", - href: "/evcp/basic-contract-template", - description: "기본 계약문서 관리", + title: '기본 계약문서 관리', + href: '/evcp/basic-contract-template', + description: '기본 계약문서 관리', // icon: "ClipboardCheck", - group: "구매 정보" + group: '구매 정보', }, { - title: "PQ 항목 관리", - href: "/evcp/pq-criteria", - description: "PQ 항목 등을 관리", + title: 'PQ 항목 관리', + href: '/evcp/pq-criteria', + description: 'PQ 항목 등을 관리', // icon: "ClipboardCheck", - group: "구매 정보" + group: '구매 정보', }, { - title: "Project GTC 관리", - href: "/evcp/project-gtc", - description: "프로젝트별 GTC를 등록하여 구매 절차에서 사용", + title: 'Project GTC 관리', + href: '/evcp/project-gtc', + description: '프로젝트별 GTC를 등록하여 구매 절차에서 사용', // icon: "FileCheck", - group: "구매 정보" + group: '구매 정보', }, { - title: "협력업체 평가대상 관리", - href: "/evcp/evaluation-target-list", - description: "", + title: '협력업체 평가대상 관리', + href: '/evcp/evaluation-target-list', + description: '', // icon: "FileCheck", - group: "구매 정보" + group: '구매 정보', }, { - title: "협력업체 평가기준표 관리", - href: "/evcp/evaluation-check-list", - description: "", + title: '협력업체 평가기준표 관리', + href: '/evcp/evaluation-check-list', + description: '', // icon: "FileCheck", - group: "구매 정보" + group: '구매 정보', }, { - title: "협력업체 평가자료 문항 관리", - href: "/evcp/vendor-check-list", - description: "", + title: '협력업체 평가자료 문항 관리', + href: '/evcp/vendor-check-list', + description: '', // icon: "FileCheck", - group: "구매 정보" + group: '구매 정보', }, { - title: "ESG 자가진단평가서 항목 관리", - href: "/evcp/esg-check-list", - description: "", + title: 'ESG 자가진단평가서 항목 관리', + href: '/evcp/esg-check-list', + description: '', // icon: "FileCheck", - group: "구매 정보" + group: '구매 정보', }, ], }, { - title: "협력업체 관리", + title: '협력업체 관리', useGrouping: true, items: [ { - title: "발굴업체 등록 관리", - href: "/evcp/vendor-candidates", - description: "수집활동을 통해 발굴한 협력업체를 등록하고 관리하며 초청할 수 있음", + title: '발굴업체 등록 관리', + href: '/evcp/vendor-candidates', + description: + '수집활동을 통해 발굴한 협력업체를 등록하고 관리하며 초청할 수 있음', }, { - title: "협력업체 관리", - href: "/evcp/vendors", - description: "협력업체에 대한 요약 정보를 출력", + title: '협력업체 관리', + href: '/evcp/vendors', + description: '협력업체에 대한 요약 정보를 출력', }, { - title: "협력업체 실사 관리", - href: "/evcp/vendor-investigation", - description: "실사가 필요한 협력업체에 대한 일정 및 실사 내용 관리", + title: '협력업체 실사 관리', + href: '/evcp/vendor-investigation', + description: '실사가 필요한 협력업체에 대한 일정 및 실사 내용 관리', }, { - title: "협력업체 정기 평가", - href: "/evcp/evaluation", - description: "협력업체 평가를 실행", + title: '협력업체 정기 평가', + href: '/evcp/evaluation', + description: '협력업체 평가를 실행', }, { - title: "협력업체 정기평가 입력", - href: "/evcp/evaluation-input", - description: "협력업체 정기 평가 담당자별 입력", + title: '협력업체 정기평가 입력', + href: '/evcp/evaluation-input', + description: '협력업체 정기 평가 담당자별 입력', }, { - title: "협력업체 PQ/실사 현황", - href: "/evcp/pq_new", - description: "협력업체의 제출 PQ/실사 현황을 확인", + title: '협력업체 PQ/실사 현황', + href: '/evcp/pq_new', + description: '협력업체의 제출 PQ/실사 현황을 확인', }, { - title: "협력업체 기본 계약 관리", - href: "/evcp/basic-contract", - description: "기본 계약 현황을 확인", + title: '협력업체 기본 계약 관리', + href: '/evcp/basic-contract', + description: '기본 계약 현황을 확인', }, { - title: "프로젝트 AVL", - href: "/evcp/project-vendors", - description: "프로젝트 PQ에 따른 AVL 리스트", + title: '프로젝트 AVL', + href: '/evcp/project-vendors', + description: '프로젝트 PQ에 따른 AVL 리스트', }, { - title: "신용평가정보 입력", - href: "/evcp/risk-input", - description: "엑셀 및 수기로 수집된 신용평가 정보를 입력", - group: '리스크 관리' + title: '신용평가정보 입력', + href: '/evcp/risk-input', + description: '엑셀 및 수기로 수집된 신용평가 정보를 입력', + group: '리스크 관리', }, { - title: "신용평가사별 리스크 관리", - href: "/evcp/risk-management", - description: "신용평가사별 요약 및 관련 정보 출력", - group: '리스크 관리' + title: '신용평가사별 리스크 관리', + href: '/evcp/risk-management', + description: '신용평가사별 요약 및 관련 정보 출력', + group: '리스크 관리', }, { - title: "협력사별 리스크 관리", - href: "/evcp/risk-management2", - description: "협력사별 요약 및 관련 정보 출력", - group: '리스크 관리' + title: '협력사별 리스크 관리', + href: '/evcp/risk-management2', + description: '협력사별 요약 및 관련 정보 출력', + group: '리스크 관리', }, { - title: "리스크 관리 메일링", - href: "/evcp/risk-mailing", - description: "구매담당자에게 메일링 서비스", - group: '리스크 관리' + title: '리스크 관리 메일링', + href: '/evcp/risk-mailing', + description: '구매담당자에게 메일링 서비스', + group: '리스크 관리', }, ], }, { - title: "기술 영업", + title: '기술 영업', useGrouping: true, // 그룹핑 적용 items: [ { - title: "자재 관리", - href: "/evcp/items-tech", - description: "기술영업 조선, 해양 Top, 해양 Hull 자재 관리", + title: '자재 관리', + href: '/evcp/items-tech', + description: '기술영업 조선, 해양 Top, 해양 Hull 자재 관리', // icon: "ListTodo", - group: "공통" + group: '공통', }, { - title: "협력업체별 자재 관리", - href: "/evcp/tech-vendor-possible-items", - description: "기술영업 협력업체별 자재 관리", - group: "공통" + title: '협력업체별 자재 관리', + href: '/evcp/tech-vendor-possible-items', + description: '기술영업 협력업체별 자재 관리', + group: '공통', }, { - title: "담당자별 자재 관리", - href: "/evcp/contact-possible-items", - description: "기술영업 담당자별 자재 관리", - group: "공통" + title: '담당자별 자재 관리', + href: '/evcp/contact-possible-items', + description: '기술영업 담당자별 자재 관리', + group: '공통', }, { - title: "협력업체 관리", - href: "/evcp/tech-vendors", - description: "기술영업 협력업체 관리", - group: "공통" + title: '협력업체 관리', + href: '/evcp/tech-vendors', + description: '기술영업 협력업체 관리', + group: '공통', }, { - title: "견적 Result 전송", - href: "/evcp/tech-project-avl", - description: "기술영업 견적 Result 전송 정보", - group: "공통" + title: '견적 Result 전송', + href: '/evcp/tech-project-avl', + description: '기술영업 견적 Result 전송 정보', + group: '공통', }, { - title: "조선 Budgetary RFQ", - href: "/evcp/budgetary-tech-sales-ship", - description: "RFQ 작성을 할 수 있고 현황을 파악", + title: '조선 Budgetary RFQ', + href: '/evcp/budgetary-tech-sales-ship', + description: 'RFQ 작성을 할 수 있고 현황을 파악', // icon: "FileText", - group: "RFQ 관리" + group: 'RFQ 관리', }, { - title: "해양 TOP Budgetary RFQ", - href: "/evcp/budgetary-tech-sales-top", - description: "RFQ 작성을 할 수 있고 현황을 파악", - group: "RFQ 관리" + title: '해양 TOP Budgetary RFQ', + href: '/evcp/budgetary-tech-sales-top', + description: 'RFQ 작성을 할 수 있고 현황을 파악', + group: 'RFQ 관리', }, { - title: "해양 HULL Budgetary RFQ", - href: "/evcp/budgetary-tech-sales-hull", - description: "RFQ 작성을 할 수 있고 현황을 파악", - group: "RFQ 관리" + title: '해양 HULL Budgetary RFQ', + href: '/evcp/budgetary-tech-sales-hull', + description: 'RFQ 작성을 할 수 있고 현황을 파악', + group: 'RFQ 관리', }, - ] + ], }, { - title: "구매 관리", + title: '구매 관리', useGrouping: true, // 그룹핑 적용 items: [ { - title: "견적 RFQ", - href: "/evcp/b-rfq", - description: "예산이나 내정가를 산정하기 위해 견적을 요청하고 관리", + title: '견적 RFQ', + href: '/evcp/b-rfq', + description: '예산이나 내정가를 산정하기 위해 견적을 요청하고 관리', // icon: "FileText", - group: "견적/입찰 관리" + group: '견적/입찰 관리', }, { - title: "RFQ(PR)", - href: "/evcp/po-rfq", - description: "생성된 RFQ(PR)을 발행하고 관리", + title: 'RFQ(PR)', + href: '/evcp/po-rfq', + description: '생성된 RFQ(PR)을 발행하고 관리', // icon: "FileText", - group: "견적/입찰 관리" + group: '견적/입찰 관리', }, { - title: "입찰 관리", - href: "/evcp/bid", - description: "생성된 입찰을 발행하고 관리", + title: '입찰 관리', + href: '/evcp/bid', + description: '생성된 입찰을 발행하고 관리', // icon: "GanttChart", - group: "견적/입찰 관리" + group: '견적/입찰 관리', }, { - title: "기술(품질) 평가 (TBE) 조선", - href: "/evcp/tbe-ship", - description: "TBE와 업체의 응답에 대한 이력 관리", + title: '기술(품질) 평가 (TBE) 조선', + href: '/evcp/tbe-ship', + description: 'TBE와 업체의 응답에 대한 이력 관리', // icon: "ClipboardCheck", - group: "평가 관리" + group: '평가 관리', }, { - title: "기술(품질) 평가 (TBE) 해양", - href: "/evcp/tbe-plant", - description: "S-EDP로부터 생성된 TBE와 업체의 응답에 대한 이력 관리", + title: '기술(품질) 평가 (TBE) 해양', + href: '/evcp/tbe-plant', + description: 'S-EDP로부터 생성된 TBE와 업체의 응답에 대한 이력 관리', // icon: "DollarSign", - group: "평가 관리" + group: '평가 관리', }, { - title: "PO 발행", - href: "/evcp/po", - description: "PO(구매 발주서) 확인/서명 요청/계약 내역 저장", + title: 'PO 발행', + href: '/evcp/po', + description: 'PO(구매 발주서) 확인/서명 요청/계약 내역 저장', // icon: "FileSignature", - group: "발주 관리" + group: '발주 관리', }, { - title: "변경 PO 발행", - href: "/evcp/poa", - description: "변경 PO(구매 발주서) 생성/서명 요청/계약 내역 저장", + title: '변경 PO 발행', + href: '/evcp/poa', + description: '변경 PO(구매 발주서) 생성/서명 요청/계약 내역 저장', // icon: "FileEdit", - group: "발주 관리" + group: '발주 관리', }, { - title: "일반 계약", - href: "/evcp/contract", - description: "", + title: '일반 계약', + href: '/evcp/contract', + description: '', // icon: "FileEdit", - group: "발주 관리" + group: '발주 관리', }, ], }, { - title: "정보시스템", + title: '정보시스템', useGrouping: true, // 그룹핑 적용 items: [ { - title: "인포메이션 관리", - href: "/evcp/information", - group: "메뉴" + title: '인포메이션 관리', + href: '/evcp/information', + group: '메뉴', }, { - title: "공지사항 관리", - href: "/evcp/notice", - group: "메뉴" + title: '공지사항 관리', + href: '/evcp/notice', + group: '메뉴', }, { - title: "메뉴 리스트", - href: "/evcp/menu-list", + title: '메뉴 리스트', + href: '/evcp/menu-list', // icon: "FileText", - group: "메뉴" + group: '메뉴', }, { - title: "메뉴 접근제어", - href: "/evcp/menu-access", + title: '메뉴 접근제어', + href: '/evcp/menu-access', // icon: "FileText", - group: "메뉴" + group: '메뉴', }, { - title: "인터페이스 목록 관리", - href: "/evcp/integration", + title: '메뉴 접근제어 (부서별)', + href: '/evcp/menu-access-dept', // icon: "FileText", - group: "인터페이스" + group: '메뉴', }, { - title: "인터페이스 이력 조회", - href: "/evcp/integration-log", + title: '인터페이스 목록 관리', + href: '/evcp/integration', // icon: "FileText", - group: "인터페이스" + group: '인터페이스', }, { - title: "결재 이력 조회", - href: "/evcp/approval-log", + title: '인터페이스 이력 조회', + href: '/evcp/integration-log', + // icon: "FileText", + group: '인터페이스', + }, + { + title: '결재 이력 조회', + href: '/evcp/approval-log', // icon: "GanttChart", - group: "결재" + group: '결재', }, { - title: "결재 경로 관리", - href: "/evcp/approval-path", + title: '결재 경로 관리', + href: '/evcp/approval-path', // icon: "ClipboardCheck", - group: "결재" + group: '결재', }, { - title: "결재 후처리 관리", - href: "/evcp/approval-after", + title: '결재 후처리 관리', + href: '/evcp/approval-after', // icon: "ClipboardCheck", - group: "결재" + group: '결재', }, { - title: "이메일 서식 관리", - href: "/evcp/email-template", + title: '이메일 서식 관리', + href: '/evcp/email-template', // icon: "ClipboardCheck", - group: "이메일" + group: '이메일', }, { - title: "이메일 수신인 관리", - href: "/evcp/email-receiver", + title: '이메일 수신인 관리', + href: '/evcp/email-receiver', // icon: "ClipboardCheck", - group: "이메일" + group: '이메일', }, { - title: "이메일 발신 이력 조회", - href: "/evcp/email-log", + title: '이메일 발신 이력 조회', + href: '/evcp/email-log', // icon: "ClipboardCheck", - group: "이메일" + group: '이메일', }, { - title: "로그인/아웃 이력 조회", - href: "/evcp/login-history", + title: '로그인/아웃 이력 조회', + href: '/evcp/login-history', // icon: "ClipboardCheck", - group: "접속 이력" + group: '접속 이력', }, { - title: "페이지 접속 이력 조회", - href: "/evcp/page-visits", + title: '페이지 접속 이력 조회', + href: '/evcp/page-visits', // icon: "ClipboardCheck", - group: "접속 이력" + group: '접속 이력', }, - ], }, - ]; export const procurementNav: MenuSection[] = [ diff --git a/db/schema/departmentDomainAssignments.ts b/db/schema/departmentDomainAssignments.ts new file mode 100644 index 00000000..5a391578 --- /dev/null +++ b/db/schema/departmentDomainAssignments.ts @@ -0,0 +1,88 @@ +import { + pgTable, + varchar, + timestamp, + integer, + text, + boolean +} from "drizzle-orm/pg-core"; + +// 부서별 도메인 할당 정보 테이블 +export const departmentDomainAssignments = pgTable("department_domain_assignments", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // 부서 정보 (Knox 조직과 연결되지만 FK 제약조건은 걸지 않음) + companyCode: varchar("company_code", { length: 10 }).notNull(), + departmentCode: varchar("department_code", { length: 50 }).notNull(), + departmentName: varchar("department_name", { length: 255 }).notNull(), // 검색 편의를 위해 저장 + + // 할당할 도메인 (users.domain enum과 동일) + assignedDomain: varchar("assigned_domain", { + length: 20, + enum: ["pending", "evcp", "procurement", "sales", "engineering", "partners"] + }).notNull(), + + // 할당 설정 상태 + isActive: boolean("is_active").notNull().default(true), + + // 메타데이터 + description: text("description"), // 할당 이유나 설명 + createdBy: integer("created_by"), // 생성자 (users.id 참조하지만 FK는 걸지 않음) + updatedBy: integer("updated_by"), // 수정자 + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// 고립된 레코드 관리를 위한 매핑 테이블 (추후 구현용) +export const departmentDomainMappings = pgTable("department_domain_mappings", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + // 기존 할당 레코드 참조 + assignmentId: integer("assignment_id").notNull(), + + // 기존 부서 정보 (더 이상 Knox에 존재하지 않는 부서) + oldCompanyCode: varchar("old_company_code", { length: 10 }).notNull(), + oldDepartmentCode: varchar("old_department_code", { length: 50 }).notNull(), + oldDepartmentName: varchar("old_department_name", { length: 255 }), + + // 새로운 부서 정보 (현재 Knox에 존재하는 부서) + newCompanyCode: varchar("new_company_code", { length: 10 }), + newDepartmentCode: varchar("new_department_code", { length: 50 }), + newDepartmentName: varchar("new_department_name", { length: 255 }), + + // 매핑 상태 + mappingStatus: varchar("mapping_status", { + length: 20, + enum: ["pending", "mapped", "rejected"] + }).notNull().default("pending"), + + mappedBy: integer("mapped_by"), // 매핑 처리자 + mappedAt: timestamp("mapped_at"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// 할당 이력 추적 테이블 (감사 목적) +export const departmentDomainAssignmentHistory = pgTable("department_domain_assignment_history", { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + + assignmentId: integer("assignment_id").notNull(), + + // 변경 정보 + action: varchar("action", { + length: 20, + enum: ["created", "updated", "deleted", "activated", "deactivated"] + }).notNull(), + + // 변경 전후 값 (JSON으로 저장) + previousValues: text("previous_values"), // JSON string + newValues: text("new_values"), // JSON string + + // 변경자 정보 + changedBy: integer("changed_by"), + changeReason: text("change_reason"), + + createdAt: timestamp("created_at").defaultNow().notNull(), +});
\ No newline at end of file diff --git a/lib/users/department-domain/service.ts b/lib/users/department-domain/service.ts new file mode 100644 index 00000000..570ef2cf --- /dev/null +++ b/lib/users/department-domain/service.ts @@ -0,0 +1,439 @@ +"use server"; + +import { revalidatePath, revalidateTag } from "next/cache"; +import { unstable_cache, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { + departmentDomainAssignments, + departmentDomainAssignmentHistory +} from "@/db/schema/departmentDomainAssignments"; +import { and, eq, inArray, desc } from "drizzle-orm"; +import { getServerSession } from "next-auth/next"; +import { authOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getErrorMessage } from "@/lib/handle-error"; +import { getCurrentCompanyCode } from "@/lib/users/knox-service"; + +// 도메인 타입 +export type UserDomain = "pending" | "evcp" | "procurement" | "sales" | "engineering" | "partners"; + +// 부서별 도메인 할당 정보 조회 +export async function getDepartmentDomainAssignments() { + return unstable_cache( + async () => { + try { + const assignments = await db + .select({ + id: departmentDomainAssignments.id, + companyCode: departmentDomainAssignments.companyCode, + departmentCode: departmentDomainAssignments.departmentCode, + departmentName: departmentDomainAssignments.departmentName, + assignedDomain: departmentDomainAssignments.assignedDomain, + isActive: departmentDomainAssignments.isActive, + description: departmentDomainAssignments.description, + createdAt: departmentDomainAssignments.createdAt, + updatedAt: departmentDomainAssignments.updatedAt, + }) + .from(departmentDomainAssignments) + .where(eq(departmentDomainAssignments.isActive, true)) + .orderBy( + desc(departmentDomainAssignments.updatedAt) + ); + + return assignments; + } catch (error) { + console.error("부서별 도메인 할당 정보 조회 실패:", error); + return []; + } + }, + ["department-domain-assignments"], + { + revalidate: 3600, // 1시간 캐시 + tags: ["department-domain-assignments"], + } + )(); +} + +// 특정 부서들의 도메인 할당 정보 조회 +export async function getDepartmentDomainAssignmentsByDepartments(departmentCodes: string[]) { + return unstable_cache( + async () => { + try { + if (departmentCodes.length === 0) return []; + + const assignments = await db + .select() + .from(departmentDomainAssignments) + .where( + and( + inArray(departmentDomainAssignments.departmentCode, departmentCodes), + eq(departmentDomainAssignments.isActive, true) + ) + ); + + return assignments; + } catch (error) { + console.error("부서별 도메인 할당 정보 조회 실패:", error); + return []; + } + }, + [`department-assignments-${departmentCodes.sort().join(',')}`], + { + revalidate: 3600, + tags: ["department-domain-assignments"], + } + )(); +} + +// 부서별 도메인 할당 +export async function assignDomainToDepartments(params: { + departmentCodes: string[]; + domain: UserDomain; + description?: string; + departmentNames?: Record<string, string>; // departmentCode -> departmentName 매핑 +}) { + unstable_noStore(); + + try { + // 세션 확인 + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { + success: false, + message: "인증이 필요합니다.", + }; + } + + const { departmentCodes, domain, description, departmentNames = {} } = params; + const userId = parseInt(session.user.id); + + if (!departmentCodes.length || !domain) { + return { + success: false, + message: "부서 코드와 도메인이 필요합니다.", + }; + } + + // 현재 회사 코드 가져오기 + const companyCode = await getCurrentCompanyCode(); + + await db.transaction(async (tx) => { + // 기존 할당 정보를 비활성화 (soft delete) + const existingAssignments = await tx + .select() + .from(departmentDomainAssignments) + .where( + and( + inArray(departmentDomainAssignments.departmentCode, departmentCodes), + eq(departmentDomainAssignments.isActive, true) + ) + ); + + // 기존 할당이 있으면 비활성화하고 히스토리 기록 + for (const existing of existingAssignments) { + // 히스토리 기록 + await tx.insert(departmentDomainAssignmentHistory).values({ + assignmentId: existing.id, + action: "deactivated", + previousValues: JSON.stringify({ + assignedDomain: existing.assignedDomain, + isActive: true, + description: existing.description, + }), + newValues: JSON.stringify({ + isActive: false, + }), + changedBy: userId, + changeReason: `새로운 도메인 할당으로 인한 기존 할당 비활성화: ${domain}`, + }); + + // 기존 할당 비활성화 + await tx + .update(departmentDomainAssignments) + .set({ + isActive: false, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(departmentDomainAssignments.id, existing.id)); + } + + // 새로운 할당 생성 + const newAssignments = departmentCodes.map(departmentCode => { + return { + companyCode, + departmentCode, + departmentName: departmentNames[departmentCode] || departmentCode, + assignedDomain: domain, + description, + isActive: true, + createdBy: userId, + updatedBy: userId, + }; + }); + + const insertedAssignments = await tx + .insert(departmentDomainAssignments) + .values(newAssignments) + .returning(); + + // 신규 생성 히스토리 기록 + for (let i = 0; i < insertedAssignments.length; i++) { + const assignment = insertedAssignments[i]; + await tx.insert(departmentDomainAssignmentHistory).values({ + assignmentId: assignment.id, + action: "created", + newValues: JSON.stringify({ + companyCode: assignment.companyCode, + departmentCode: assignment.departmentCode, + assignedDomain: assignment.assignedDomain, + description: assignment.description, + }), + changedBy: userId, + changeReason: description || "부서별 도메인 할당", + }); + } + }); + + // 캐시 무효화 + revalidateTag("department-domain-assignments"); + revalidatePath("/evcp/menu-access-dept"); + + return { + success: true, + message: `${departmentCodes.length}개 부서에 ${domain} 도메인이 성공적으로 할당되었습니다.`, + }; + + } catch (error) { + console.error("부서별 도메인 할당 실패:", error); + return { + success: false, + message: getErrorMessage(error), + }; + } +} + +// 부서별 도메인 할당 수정 +export async function updateDepartmentDomainAssignment(params: { + assignmentId: number; + domain: UserDomain; + description?: string; + isActive?: boolean; +}) { + unstable_noStore(); + + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { + success: false, + message: "인증이 필요합니다.", + }; + } + + const { assignmentId, domain, description, isActive = true } = params; + const userId = parseInt(session.user.id); + + await db.transaction(async (tx) => { + // 기존 할당 정보 조회 + const existing = await tx + .select() + .from(departmentDomainAssignments) + .where(eq(departmentDomainAssignments.id, assignmentId)) + .limit(1); + + if (existing.length === 0) { + throw new Error("존재하지 않는 할당 정보입니다."); + } + + const currentAssignment = existing[0]; + + // 히스토리 기록 + await tx.insert(departmentDomainAssignmentHistory).values({ + assignmentId, + action: "updated", + previousValues: JSON.stringify({ + assignedDomain: currentAssignment.assignedDomain, + description: currentAssignment.description, + isActive: currentAssignment.isActive, + }), + newValues: JSON.stringify({ + assignedDomain: domain, + description, + isActive, + }), + changedBy: userId, + changeReason: description || "부서별 도메인 할당 수정", + }); + + // 할당 정보 업데이트 + await tx + .update(departmentDomainAssignments) + .set({ + assignedDomain: domain, + description, + isActive, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(departmentDomainAssignments.id, assignmentId)); + }); + + // 캐시 무효화 + revalidateTag("department-domain-assignments"); + revalidatePath("/evcp/menu-access-dept"); + + return { + success: true, + message: "도메인 할당 정보가 성공적으로 수정되었습니다.", + }; + + } catch (error) { + console.error("부서별 도메인 할당 수정 실패:", error); + return { + success: false, + message: getErrorMessage(error), + }; + } +} + +// 부서별 도메인 할당 삭제 (soft delete) +export async function deleteDepartmentDomainAssignment(assignmentId: number) { + unstable_noStore(); + + try { + const session = await getServerSession(authOptions); + if (!session?.user?.id) { + return { + success: false, + message: "인증이 필요합니다.", + }; + } + + const userId = parseInt(session.user.id); + + await db.transaction(async (tx) => { + // 기존 할당 정보 조회 + const existing = await tx + .select() + .from(departmentDomainAssignments) + .where(eq(departmentDomainAssignments.id, assignmentId)) + .limit(1); + + if (existing.length === 0) { + throw new Error("존재하지 않는 할당 정보입니다."); + } + + const currentAssignment = existing[0]; + + // 히스토리 기록 + await tx.insert(departmentDomainAssignmentHistory).values({ + assignmentId, + action: "deleted", + previousValues: JSON.stringify({ + assignedDomain: currentAssignment.assignedDomain, + description: currentAssignment.description, + isActive: currentAssignment.isActive, + }), + newValues: JSON.stringify({ + isActive: false, + }), + changedBy: userId, + changeReason: "부서별 도메인 할당 삭제", + }); + + // 할당 정보 비활성화 + await tx + .update(departmentDomainAssignments) + .set({ + isActive: false, + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(departmentDomainAssignments.id, assignmentId)); + }); + + // 캐시 무효화 + revalidateTag("department-domain-assignments"); + revalidatePath("/evcp/menu-access-dept"); + + return { + success: true, + message: "도메인 할당 정보가 성공적으로 삭제되었습니다.", + }; + + } catch (error) { + console.error("부서별 도메인 할당 삭제 실패:", error); + return { + success: false, + message: getErrorMessage(error), + }; + } +} + +// 부서별 도메인 할당 히스토리 조회 +export async function getDepartmentDomainAssignmentHistory(assignmentId?: number) { + return unstable_cache( + async () => { + try { + const query = db + .select({ + id: departmentDomainAssignmentHistory.id, + assignmentId: departmentDomainAssignmentHistory.assignmentId, + action: departmentDomainAssignmentHistory.action, + previousValues: departmentDomainAssignmentHistory.previousValues, + newValues: departmentDomainAssignmentHistory.newValues, + changedBy: departmentDomainAssignmentHistory.changedBy, + changeReason: departmentDomainAssignmentHistory.changeReason, + createdAt: departmentDomainAssignmentHistory.createdAt, + }) + .from(departmentDomainAssignmentHistory); + + if (assignmentId) { + query.where(eq(departmentDomainAssignmentHistory.assignmentId, assignmentId)); + } + + const history = await query + .orderBy(desc(departmentDomainAssignmentHistory.createdAt)) + .limit(100); // 최근 100개 제한 + + return history; + } catch (error) { + console.error("부서별 도메인 할당 히스토리 조회 실패:", error); + return []; + } + }, + [`department-domain-assignment-history-${assignmentId || 'all'}`], + { + revalidate: 1800, // 30분 캐시 + tags: ["department-domain-assignment-history"], + } + )(); +} + +// 도메인별 통계 조회 +export async function getDepartmentDomainStats() { + return unstable_cache( + async () => { + try { + const stats = await db + .select({ + domain: departmentDomainAssignments.assignedDomain, + count: db.$count(departmentDomainAssignments), + }) + .from(departmentDomainAssignments) + .where(eq(departmentDomainAssignments.isActive, true)) + .groupBy(departmentDomainAssignments.assignedDomain); + + return stats; + } catch (error) { + console.error("도메인별 통계 조회 실패:", error); + return []; + } + }, + ["department-domain-stats"], + { + revalidate: 3600, + tags: ["department-domain-assignments"], + } + )(); +}
\ No newline at end of file diff --git a/lib/users/knox-service.ts b/lib/users/knox-service.ts new file mode 100644 index 00000000..d5453072 --- /dev/null +++ b/lib/users/knox-service.ts @@ -0,0 +1,377 @@ +"use server"; + +import { unstable_cache } from "next/cache"; +import db from "@/db/db"; +import { organization } from "@/db/schema/knox/organization"; +import { eq, and, asc } from "drizzle-orm"; + +// 조직 트리 노드 타입 +export interface DepartmentNode { + companyCode: string; + departmentCode: string; + departmentName: string; + departmentLevel?: string; + uprDepartmentCode?: string; + lowDepartmentYn?: string; + hiddenDepartmentYn?: string; + children: DepartmentNode[]; + // UI에서 사용할 추가 필드 + label: string; + value: string; + key: string; +} + +// 기본 회사 코드 (환경변수에서 가져오되 폴백 제공) +const getCompanyCode = () => { + const envCodes = process.env.KNOX_COMPANY_CODES; + if (envCodes) { + // 쉼표로 구분된 경우 첫 번째 값 사용 + return envCodes.split(',')[0].trim(); + } + return "D60"; // 폴백 값 +}; + +const DEFAULT_COMPANY_CODE = getCompanyCode(); + +// 조직 데이터 조회 (hiddenDepartmentYn = 'F'만) +export async function getVisibleOrganizations() { + return unstable_cache( + async () => { + try { + const organizations = await db + .select({ + companyCode: organization.companyCode, + departmentCode: organization.departmentCode, + departmentName: organization.departmentName, + departmentLevel: organization.departmentLevel, + uprDepartmentCode: organization.uprDepartmentCode, + lowDepartmentYn: organization.lowDepartmentYn, + hiddenDepartmentYn: organization.hiddenDepartmentYn, + }) + .from(organization) + .where(eq(organization.hiddenDepartmentYn, 'F')) + .orderBy( + asc(organization.companyCode), + asc(organization.departmentLevel), + asc(organization.departmentCode) + ); + + return organizations; + } catch (error) { + console.error("조직 데이터 조회 실패:", error); + return []; + } + }, + ["visible-organizations"], + { + revalidate: 3600, // 1시간 캐시 + tags: ["knox-organizations"], + } + )(); +} + +// 기본 회사의 부서 트리 구조 조회 (처음부터 모든 데이터 로드) +export async function getAllDepartmentsTree(): Promise<DepartmentNode[]> { + return unstable_cache( + async () => { + try { + const organizations = await db + .select({ + companyCode: organization.companyCode, + departmentCode: organization.departmentCode, + departmentName: organization.departmentName, + departmentLevel: organization.departmentLevel, + uprDepartmentCode: organization.uprDepartmentCode, + lowDepartmentYn: organization.lowDepartmentYn, + hiddenDepartmentYn: organization.hiddenDepartmentYn, + }) + .from(organization) + .where( + and( + eq(organization.companyCode, DEFAULT_COMPANY_CODE), + eq(organization.hiddenDepartmentYn, 'F') + ) + ) + .orderBy( + asc(organization.departmentLevel), + asc(organization.departmentCode) + ); + + // 트리 구조 생성 + const tree = buildDepartmentTree(organizations); + return tree; + } catch (error) { + console.error("모든 부서 트리 구성 실패:", error); + return []; + } + }, + [`all-departments-tree-${DEFAULT_COMPANY_CODE}`], + { + revalidate: 3600, + tags: ["knox-organizations"], + } + )(); +} + +// 회사별 조직 트리 구조 생성 (기존 호환성 유지) +export async function getDepartmentTreeByCompany(companyCode: string): Promise<DepartmentNode[]> { + return unstable_cache( + async () => { + try { + const organizations = await db + .select({ + companyCode: organization.companyCode, + departmentCode: organization.departmentCode, + departmentName: organization.departmentName, + departmentLevel: organization.departmentLevel, + uprDepartmentCode: organization.uprDepartmentCode, + lowDepartmentYn: organization.lowDepartmentYn, + hiddenDepartmentYn: organization.hiddenDepartmentYn, + }) + .from(organization) + .where( + and( + eq(organization.companyCode, companyCode), + eq(organization.hiddenDepartmentYn, 'F') + ) + ) + .orderBy( + asc(organization.departmentLevel), + asc(organization.departmentCode) + ); + + // 트리 구조 생성 + const tree = buildDepartmentTree(organizations); + return tree; + } catch (error) { + console.error(`회사 ${companyCode} 조직 트리 구성 실패:`, error); + return []; + } + }, + [`department-tree-${companyCode}`], + { + revalidate: 3600, + tags: ["knox-organizations", `company-${companyCode}`], + } + )(); +} + +// 전체 회사의 조직 트리 구조 생성 +export async function getAllDepartmentTrees(): Promise<Record<string, DepartmentNode[]>> { + return unstable_cache( + async () => { + try { + const organizations = await getVisibleOrganizations(); + + // 회사별로 그룹화 + const companiesMap = new Map<string, typeof organizations>(); + + organizations.forEach((org) => { + if (!companiesMap.has(org.companyCode)) { + companiesMap.set(org.companyCode, []); + } + companiesMap.get(org.companyCode)!.push(org); + }); + + // 각 회사별로 트리 구조 생성 + const result: Record<string, DepartmentNode[]> = {}; + + for (const [companyCode, orgs] of companiesMap) { + result[companyCode] = buildDepartmentTree(orgs); + } + + return result; + } catch (error) { + console.error("전체 조직 트리 구성 실패:", error); + return {}; + } + }, + ["all-department-trees"], + { + revalidate: 3600, + tags: ["knox-organizations"], + } + )(); +} + +// 부서 트리 구조 빌더 헬퍼 함수 (개선) +function buildDepartmentTree( + organizations: Array<{ + companyCode: string; + departmentCode: string; + departmentName: string | null; + departmentLevel?: string | null; + uprDepartmentCode?: string | null; + lowDepartmentYn?: string | null; + hiddenDepartmentYn?: string | null; + }> +): DepartmentNode[] { + // 맵으로 빠른 조회를 위한 인덱스 생성 + const orgMap = new Map<string, DepartmentNode>(); + const rootNodes: DepartmentNode[] = []; + + // 1단계: 모든 노드를 맵에 추가 + organizations.forEach((org) => { + const node: DepartmentNode = { + companyCode: org.companyCode, + departmentCode: org.departmentCode, + departmentName: org.departmentName || "", + departmentLevel: org.departmentLevel || undefined, + uprDepartmentCode: org.uprDepartmentCode || undefined, + lowDepartmentYn: org.lowDepartmentYn || undefined, + hiddenDepartmentYn: org.hiddenDepartmentYn || undefined, + children: [], + // UI용 필드 + label: org.departmentName || org.departmentCode, + value: org.departmentCode, + key: `${org.companyCode}-${org.departmentCode}`, + }; + + orgMap.set(org.departmentCode, node); + }); + + // 2단계: 부모-자식 관계 설정 + organizations.forEach((org) => { + const currentNode = orgMap.get(org.departmentCode); + if (!currentNode) return; + + if (org.uprDepartmentCode && orgMap.has(org.uprDepartmentCode)) { + // 부모가 있으면 부모의 children에 추가 + const parentNode = orgMap.get(org.uprDepartmentCode); + parentNode!.children.push(currentNode); + } else { + // 부모가 없으면 루트 노드로 처리 + // 하지만 상위 부서가 없는 부서들은 depth 1에 배치 + rootNodes.push(currentNode); + } + }); + + // 3단계: 고립된 부서들 처리 (상위도 하위도 없는 부서들) + // lowDepartmentYn이 'F'이거나 null이고, uprDepartmentCode가 없거나 존재하지 않는 부서들을 확인 + organizations.forEach((org) => { + const currentNode = orgMap.get(org.departmentCode); + if (!currentNode) return; + + // 이미 루트에 추가되었거나 다른 부서의 자식이 된 경우는 스킵 + const isAlreadyPlaced = rootNodes.includes(currentNode) || + organizations.some(otherOrg => { + const otherNode = orgMap.get(otherOrg.departmentCode); + return otherNode && otherNode.children.includes(currentNode); + }); + + if (!isAlreadyPlaced) { + // 고립된 부서를 루트에 추가 + rootNodes.push(currentNode); + } + }); + + // 4단계: 각 노드의 children을 정렬 + const sortChildren = (node: DepartmentNode) => { + node.children.sort((a, b) => { + // departmentLevel이 있으면 그걸로 정렬, 없으면 departmentCode로 정렬 + const aLevel = parseInt(a.departmentLevel || "999"); + const bLevel = parseInt(b.departmentLevel || "999"); + + if (aLevel !== bLevel) { + return aLevel - bLevel; + } + + return a.departmentCode.localeCompare(b.departmentCode); + }); + + // 재귀적으로 자식들도 정렬 + node.children.forEach(sortChildren); + }; + + // 5단계: 루트 노드들도 정렬 + rootNodes.sort((a, b) => { + const aLevel = parseInt(a.departmentLevel || "1"); + const bLevel = parseInt(b.departmentLevel || "1"); + + if (aLevel !== bLevel) { + return aLevel - bLevel; + } + + return a.departmentCode.localeCompare(b.departmentCode); + }); + + rootNodes.forEach(sortChildren); + + return rootNodes; +} + +// 특정 부서의 모든 하위 부서 코드 조회 (재귀) - 기본 회사 대상 +export async function getChildDepartmentCodes(departmentCode: string): Promise<string[]> { + const tree = await getAllDepartmentsTree(); + const result: string[] = []; + + const findAndCollectChildren = (nodes: DepartmentNode[], targetCode: string): boolean => { + for (const node of nodes) { + if (node.departmentCode === targetCode) { + // 타겟 노드 발견, 모든 하위 부서 코드 수집 + collectAllDepartmentCodes(node, result); + return true; + } + + // 자식 노드들에서 재귀 검색 + if (findAndCollectChildren(node.children, targetCode)) { + return true; + } + } + return false; + }; + + const collectAllDepartmentCodes = (node: DepartmentNode, codes: string[]) => { + codes.push(node.departmentCode); + node.children.forEach(child => collectAllDepartmentCodes(child, codes)); + }; + + findAndCollectChildren(tree, departmentCode); + return result; +} + +// 회사 목록 조회 (호환성 유지용) +export async function getCompanies(): Promise<Array<{ code: string; name: string }>> { + return unstable_cache( + async () => { + try { + const companies = await db + .selectDistinct({ + code: organization.companyCode, + name: organization.companyName, + }) + .from(organization) + .where(eq(organization.hiddenDepartmentYn, 'F')) + .orderBy(asc(organization.companyCode)); + + return companies + .filter(company => company.code && company.name) + .map(company => ({ + code: company.code, + name: company.name!, + })); + } catch (error) { + console.error("회사 목록 조회 실패:", error); + return []; + } + }, + ["companies"], + { + revalidate: 3600, + tags: ["knox-organizations"], + } + )(); +} + +// 현재 사용 중인 회사 코드 반환 +export async function getCurrentCompanyCode(): Promise<string> { + return DEFAULT_COMPANY_CODE; +} + +// 현재 사용 중인 회사 정보 반환 +export async function getCurrentCompanyInfo(): Promise<{ code: string; name: string }> { + return { + code: DEFAULT_COMPANY_CODE, + name: "삼성중공업" + }; +} |
