276 lines
8.6 KiB
TypeScript
276 lines
8.6 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import type { Department, DepartmentFormData } from "@/types/department";
|
|
import * as departmentAPI from "@/lib/api/department";
|
|
|
|
interface DepartmentStructureProps {
|
|
companyCode: string;
|
|
selectedDepartment: Department | null;
|
|
onSelectDepartment: (department: Department | null) => void;
|
|
}
|
|
|
|
/**
|
|
* 부서 구조 컴포넌트 (트리 형태)
|
|
*/
|
|
export function DepartmentStructure({
|
|
companyCode,
|
|
selectedDepartment,
|
|
onSelectDepartment,
|
|
}: DepartmentStructureProps) {
|
|
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("");
|
|
|
|
// 부서 목록 로드
|
|
useEffect(() => {
|
|
loadDepartments();
|
|
}, [companyCode]);
|
|
|
|
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);
|
|
loadDepartments();
|
|
} else {
|
|
alert(response.error || "부서 추가에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("부서 추가 실패:", error);
|
|
alert("부서 추가 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// 부서 삭제
|
|
const handleDeleteDepartment = async (deptCode: string) => {
|
|
if (!confirm("이 부서를 삭제하시겠습니까?")) return;
|
|
|
|
try {
|
|
const response = await departmentAPI.deleteDepartment(deptCode);
|
|
|
|
if (response.success) {
|
|
loadDepartments();
|
|
} else {
|
|
alert(response.error || "부서 삭제에 실패했습니다.");
|
|
}
|
|
} catch (error) {
|
|
console.error("부서 삭제 실패:", error);
|
|
alert("부서 삭제 중 오류가 발생했습니다.");
|
|
}
|
|
};
|
|
|
|
// 확장/축소 토글
|
|
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
|
|
className={`flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors hover:bg-muted ${
|
|
isSelected ? "bg-primary/10 text-primary" : ""
|
|
}`}
|
|
style={{ marginLeft: `${level * 16}px` }}
|
|
>
|
|
<div
|
|
className="flex flex-1 items-center gap-2"
|
|
onClick={() => onSelectDepartment(dept)}
|
|
>
|
|
{/* 확장/축소 아이콘 */}
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleExpand(dept.dept_code);
|
|
}}
|
|
className="h-4 w-4"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div className="h-4 w-4" />
|
|
)}
|
|
|
|
{/* 부서명 */}
|
|
<span className="font-medium">{dept.dept_name}</span>
|
|
|
|
{/* 인원수 */}
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<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"
|
|
className="h-6 w-6 text-destructive"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteDepartment(dept.dept_code);
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
{/* 부서 트리 */}
|
|
<div className="space-y-1 rounded-lg border bg-card p-4 shadow-sm">
|
|
{isLoading ? (
|
|
<div className="py-8 text-center text-sm text-muted-foreground">로딩 중...</div>
|
|
) : departments.length === 0 ? (
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
부서가 없습니다. 최상위 부서를 추가해주세요.
|
|
</div>
|
|
) : (
|
|
renderDepartmentTree(null)
|
|
)}
|
|
</div>
|
|
|
|
{/* 부서 추가 모달 */}
|
|
<Dialog open={isAddModalOpen} onOpenChange={setIsAddModalOpen}>
|
|
<DialogContent className="sm:max-w-[425px]">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{parentDeptForAdd ? "하위 부서 추가" : "최상위 부서 추가"}
|
|
</DialogTitle>
|
|
</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>
|
|
</div>
|
|
);
|
|
}
|
|
|