summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-07-23 06:06:27 +0000
committerjoonhoekim <26rote@gmail.com>2025-07-23 06:06:27 +0000
commitf9bfc82880212e1a13f6bbb28ecfc87b89346f26 (patch)
treeee792f340ebfa7eaf30d2e79f99f41213e5c5cf3 /app
parentedc0eabc8f5fc44408c28023ca155bd73ddf8183 (diff)
(김준회) 메뉴접근제어(부서별) 메뉴 구현
Diffstat (limited to 'app')
-rw-r--r--app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-domain-assignment-dialog.tsx384
-rw-r--r--app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-menu-access-manager.tsx297
-rw-r--r--app/[lng]/evcp/(evcp)/menu-access-dept/_components/department-tree-view.tsx353
-rw-r--r--app/[lng]/evcp/(evcp)/menu-access-dept/_components/domain-constants.ts52
-rw-r--r--app/[lng]/evcp/(evcp)/menu-access-dept/page.tsx34
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>
+ );
+}