2025-11-03 16:31:03 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-11-05 16:36:32 +09:00
|
|
|
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
2025-11-03 16:31:03 +09:00
|
|
|
import { Input } from "@/components/ui/input";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
2025-11-03 17:42:46 +09:00
|
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
|
import type { Department } from "@/types/department";
|
2025-11-03 16:31:03 +09:00
|
|
|
import * as departmentAPI from "@/lib/api/department";
|
|
|
|
|
|
|
|
|
|
interface DepartmentStructureProps {
|
|
|
|
|
companyCode: string;
|
|
|
|
|
selectedDepartment: Department | null;
|
|
|
|
|
onSelectDepartment: (department: Department | null) => void;
|
2025-11-03 17:28:12 +09:00
|
|
|
refreshTrigger?: number;
|
2025-11-03 16:31:03 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 부서 구조 컴포넌트 (트리 형태)
|
|
|
|
|
*/
|
|
|
|
|
export function DepartmentStructure({
|
|
|
|
|
companyCode,
|
|
|
|
|
selectedDepartment,
|
|
|
|
|
onSelectDepartment,
|
2025-11-03 17:28:12 +09:00
|
|
|
refreshTrigger,
|
2025-11-03 16:31:03 +09:00
|
|
|
}: DepartmentStructureProps) {
|
2025-11-03 17:42:46 +09:00
|
|
|
const { toast } = useToast();
|
2025-11-03 16:31:03 +09:00
|
|
|
const [departments, setDepartments] = useState<Department[]>([]);
|
|
|
|
|
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
|
|
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
// 부서 추가 모달
|
|
|
|
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
|
|
|
|
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
|
|
|
|
|
const [newDeptName, setNewDeptName] = useState("");
|
2025-11-03 17:28:12 +09:00
|
|
|
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
|
2025-11-03 17:42:46 +09:00
|
|
|
|
2025-11-03 17:28:12 +09:00
|
|
|
// 부서 삭제 확인 모달
|
|
|
|
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
|
|
|
|
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
|
2025-11-03 17:42:46 +09:00
|
|
|
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
|
2025-11-03 16:31:03 +09:00
|
|
|
|
|
|
|
|
// 부서 목록 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadDepartments();
|
2025-11-03 17:28:12 +09:00
|
|
|
}, [companyCode, refreshTrigger]);
|
2025-11-03 16:31:03 +09:00
|
|
|
|
|
|
|
|
const loadDepartments = async () => {
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await departmentAPI.getDepartments(companyCode);
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
setDepartments(response.data);
|
|
|
|
|
} else {
|
|
|
|
|
console.error("부서 목록 로드 실패:", response.error);
|
|
|
|
|
setDepartments([]);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("부서 목록 로드 실패:", error);
|
|
|
|
|
setDepartments([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 부서 트리 구조 생성
|
|
|
|
|
const buildTree = (parentCode: string | null): Department[] => {
|
|
|
|
|
return departments
|
|
|
|
|
.filter((dept) => dept.parent_dept_code === parentCode)
|
|
|
|
|
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 부서 추가 핸들러
|
|
|
|
|
const handleAddDepartment = (parentDeptCode: string | null = null) => {
|
|
|
|
|
setParentDeptForAdd(parentDeptCode);
|
|
|
|
|
setNewDeptName("");
|
|
|
|
|
setIsAddModalOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 부서 저장
|
|
|
|
|
const handleSaveDepartment = async () => {
|
|
|
|
|
if (!newDeptName.trim()) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await departmentAPI.createDepartment(companyCode, {
|
|
|
|
|
dept_name: newDeptName,
|
|
|
|
|
parent_dept_code: parentDeptForAdd,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (response.success) {
|
|
|
|
|
setIsAddModalOpen(false);
|
2025-11-03 17:28:12 +09:00
|
|
|
setNewDeptName("");
|
|
|
|
|
setParentDeptForAdd(null);
|
2025-11-03 16:31:03 +09:00
|
|
|
loadDepartments();
|
2025-11-03 17:42:46 +09:00
|
|
|
|
|
|
|
|
// 성공 Toast 표시
|
|
|
|
|
toast({
|
|
|
|
|
title: "부서 생성 완료",
|
|
|
|
|
description: `"${newDeptName}" 부서가 생성되었습니다.`,
|
|
|
|
|
variant: "default",
|
|
|
|
|
});
|
2025-11-03 16:31:03 +09:00
|
|
|
} else {
|
2025-11-03 17:28:12 +09:00
|
|
|
if ((response as any).isDuplicate) {
|
|
|
|
|
setDuplicateMessage(response.error || "이미 존재하는 부서명입니다.");
|
|
|
|
|
} else {
|
2025-11-03 17:42:46 +09:00
|
|
|
toast({
|
|
|
|
|
title: "부서 생성 실패",
|
|
|
|
|
description: response.error || "부서 추가에 실패했습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
2025-11-03 17:28:12 +09:00
|
|
|
}
|
2025-11-03 16:31:03 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("부서 추가 실패:", error);
|
2025-11-03 17:42:46 +09:00
|
|
|
toast({
|
|
|
|
|
title: "부서 생성 실패",
|
|
|
|
|
description: "부서 추가 중 오류가 발생했습니다.",
|
|
|
|
|
variant: "destructive",
|
|
|
|
|
});
|
2025-11-03 16:31:03 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-03 17:28:12 +09:00
|
|
|
// 부서 삭제 확인 요청
|
|
|
|
|
const handleDeleteDepartmentRequest = (deptCode: string, deptName: string) => {
|
|
|
|
|
setDeptToDelete({ code: deptCode, name: deptName });
|
|
|
|
|
setDeleteConfirmOpen(true);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 부서 삭제 실행
|
|
|
|
|
const handleDeleteDepartmentConfirm = async () => {
|
|
|
|
|
if (!deptToDelete) return;
|
2025-11-03 16:31:03 +09:00
|
|
|
|
|
|
|
|
try {
|
2025-11-03 17:28:12 +09:00
|
|
|
const response = await departmentAPI.deleteDepartment(deptToDelete.code);
|
2025-11-03 16:31:03 +09:00
|
|
|
|
|
|
|
|
if (response.success) {
|
2025-11-03 17:42:46 +09:00
|
|
|
// 삭제된 부서가 선택되어 있었다면 선택 해제
|
|
|
|
|
if (selectedDepartment?.dept_code === deptToDelete.code) {
|
|
|
|
|
onSelectDepartment(null);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-03 17:28:12 +09:00
|
|
|
setDeleteConfirmOpen(false);
|
|
|
|
|
setDeptToDelete(null);
|
2025-11-03 16:31:03 +09:00
|
|
|
loadDepartments();
|
2025-11-03 17:42:46 +09:00
|
|
|
|
|
|
|
|
// 성공 메시지 Toast로 표시 (부서원 수 포함)
|
|
|
|
|
toast({
|
|
|
|
|
title: "부서 삭제 완료",
|
|
|
|
|
description: response.message || "부서가 삭제되었습니다.",
|
|
|
|
|
variant: "default",
|
|
|
|
|
});
|
2025-11-03 16:31:03 +09:00
|
|
|
} else {
|
2025-11-03 17:42:46 +09:00
|
|
|
// 삭제 확인 모달을 닫고 에러 모달을 표시
|
|
|
|
|
setDeleteConfirmOpen(false);
|
|
|
|
|
setDeptToDelete(null);
|
|
|
|
|
setDeleteErrorMessage(response.error || "부서 삭제에 실패했습니다.");
|
2025-11-03 16:31:03 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("부서 삭제 실패:", error);
|
2025-11-03 17:42:46 +09:00
|
|
|
setDeleteConfirmOpen(false);
|
|
|
|
|
setDeptToDelete(null);
|
|
|
|
|
setDeleteErrorMessage("부서 삭제 중 오류가 발생했습니다.");
|
2025-11-03 16:31:03 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 확장/축소 토글
|
|
|
|
|
const toggleExpand = (deptCode: string) => {
|
|
|
|
|
const newExpanded = new Set(expandedDepts);
|
|
|
|
|
if (newExpanded.has(deptCode)) {
|
|
|
|
|
newExpanded.delete(deptCode);
|
|
|
|
|
} else {
|
|
|
|
|
newExpanded.add(deptCode);
|
|
|
|
|
}
|
|
|
|
|
setExpandedDepts(newExpanded);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 부서 트리 렌더링 (재귀)
|
|
|
|
|
const renderDepartmentTree = (parentCode: string | null, level: number = 0) => {
|
|
|
|
|
const children = buildTree(parentCode);
|
|
|
|
|
|
|
|
|
|
return children.map((dept) => {
|
|
|
|
|
const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code);
|
|
|
|
|
const isExpanded = expandedDepts.has(dept.dept_code);
|
|
|
|
|
const isSelected = selectedDepartment?.dept_code === dept.dept_code;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div key={dept.dept_code}>
|
|
|
|
|
{/* 부서 항목 */}
|
|
|
|
|
<div
|
2025-11-03 17:42:46 +09:00
|
|
|
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
|
2025-11-03 16:31:03 +09:00
|
|
|
isSelected ? "bg-primary/10 text-primary" : ""
|
|
|
|
|
}`}
|
|
|
|
|
style={{ marginLeft: `${level * 16}px` }}
|
|
|
|
|
>
|
2025-11-03 17:42:46 +09:00
|
|
|
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
|
2025-11-03 16:31:03 +09:00
|
|
|
{/* 확장/축소 아이콘 */}
|
|
|
|
|
{hasChildren ? (
|
|
|
|
|
<button
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
toggleExpand(dept.dept_code);
|
|
|
|
|
}}
|
|
|
|
|
className="h-4 w-4"
|
|
|
|
|
>
|
2025-11-03 17:42:46 +09:00
|
|
|
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
2025-11-03 16:31:03 +09:00
|
|
|
</button>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 부서명 */}
|
|
|
|
|
<span className="font-medium">{dept.dept_name}</span>
|
|
|
|
|
|
|
|
|
|
{/* 인원수 */}
|
2025-11-03 17:42:46 +09:00
|
|
|
<div className="text-muted-foreground flex items-center gap-1 text-xs">
|
2025-11-03 16:31:03 +09:00
|
|
|
<Users className="h-3 w-3" />
|
|
|
|
|
<span>{dept.memberCount || 0}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼 */}
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="h-6 w-6"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleAddDepartment(dept.dept_code);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Plus className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
2025-11-03 17:42:46 +09:00
|
|
|
className="text-destructive h-6 w-6"
|
2025-11-03 16:31:03 +09:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
2025-11-03 17:28:12 +09:00
|
|
|
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
|
2025-11-03 16:31:03 +09:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Trash2 className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 하위 부서 (재귀) */}
|
|
|
|
|
{hasChildren && isExpanded && renderDepartmentTree(dept.dept_code, level + 1)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<h3 className="text-lg font-semibold">부서 구조</h3>
|
|
|
|
|
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
|
|
|
|
|
<Plus className="h-4 w-4" />
|
|
|
|
|
최상위 부서 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 부서 트리 */}
|
2025-11-03 17:42:46 +09:00
|
|
|
<div className="bg-card space-y-1 rounded-lg border p-4 shadow-sm">
|
2025-11-03 16:31:03 +09:00
|
|
|
{isLoading ? (
|
2025-11-03 17:42:46 +09:00
|
|
|
<div className="text-muted-foreground py-8 text-center text-sm">로딩 중...</div>
|
2025-11-03 16:31:03 +09:00
|
|
|
) : departments.length === 0 ? (
|
2025-11-03 17:42:46 +09:00
|
|
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
2025-11-03 16:31:03 +09:00
|
|
|
부서가 없습니다. 최상위 부서를 추가해주세요.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
renderDepartmentTree(null)
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 부서 추가 모달 */}
|
|
|
|
|
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
|
|
|
|
<DialogContent className="sm:max-w-[425px]">
|
|
|
|
|
<DialogHeader>
|
2025-11-03 17:42:46 +09:00
|
|
|
<DialogTitle>{parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"}</DialogTitle>
|
2025-11-03 16:31:03 +09:00
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4 py-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="dept_name">
|
|
|
|
|
부서명 <span className="text-destructive">*</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="dept_name"
|
|
|
|
|
value={newDeptName}
|
|
|
|
|
onChange={(e) => setNewDeptName(e.target.value)}
|
|
|
|
|
placeholder="부서명을 입력하세요"
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={() => setIsAddModalOpen(false)}>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={handleSaveDepartment} disabled={!newDeptName.trim()}>
|
|
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2025-11-03 17:28:12 +09:00
|
|
|
|
|
|
|
|
{/* 중복 알림 모달 */}
|
|
|
|
|
<Dialog open={!!duplicateMessage} onOpenChange={() => setDuplicateMessage(null)}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="text-base sm:text-lg">중복 알림</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="py-4">
|
|
|
|
|
<p className="text-sm">{duplicateMessage}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
2025-11-03 17:42:46 +09:00
|
|
|
<Button onClick={() => setDuplicateMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
2025-11-03 17:28:12 +09:00
|
|
|
확인
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
|
|
|
|
{/* 부서 삭제 확인 모달 */}
|
|
|
|
|
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="text-base sm:text-lg">부서 삭제 확인</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="py-4">
|
|
|
|
|
<p className="text-sm">
|
|
|
|
|
<span className="font-semibold">{deptToDelete?.name}</span> 부서를 삭제하시겠습니까?
|
|
|
|
|
</p>
|
2025-11-03 17:42:46 +09:00
|
|
|
<p className="text-muted-foreground mt-2 text-xs">이 작업은 되돌릴 수 없습니다.</p>
|
2025-11-03 17:28:12 +09:00
|
|
|
</div>
|
|
|
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setDeleteConfirmOpen(false);
|
|
|
|
|
setDeptToDelete(null);
|
|
|
|
|
}}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onClick={handleDeleteDepartmentConfirm}
|
|
|
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
삭제
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2025-11-03 17:42:46 +09:00
|
|
|
|
|
|
|
|
{/* 부서 삭제 에러 모달 */}
|
|
|
|
|
<Dialog open={!!deleteErrorMessage} onOpenChange={() => setDeleteErrorMessage(null)}>
|
|
|
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="text-base sm:text-lg">삭제 불가</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="py-4">
|
|
|
|
|
<p className="text-sm">{deleteErrorMessage}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button onClick={() => setDeleteErrorMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
|
|
|
확인
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
2025-11-03 16:31:03 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|