diff options
Diffstat (limited to 'app')
5 files changed, 1120 insertions, 0 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> + ); +} |
