summaryrefslogtreecommitdiff
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
parentedc0eabc8f5fc44408c28023ca155bd73ddf8183 (diff)
(김준회) 메뉴접근제어(부서별) 메뉴 구현
-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
-rw-r--r--config/menuConfig.ts466
-rw-r--r--db/schema/departmentDomainAssignments.ts88
-rw-r--r--lib/users/department-domain/service.ts439
-rw-r--r--lib/users/knox-service.ts377
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: "삼성중공업"
+ };
+}