Files
invyone/frontend/components/admin/department/DepartmentStructure.tsx
T
johngreen 0e895a90fa feat(부서관리): V1 슬림 스코프 + 트리 컨텍스트 메뉴 UX 리디자인
백엔드:
- V018 soft-delete (deleted_at 컬럼) + 휴지통/복구 흐름
- V019 미사용 컬럼 cleanup (V1 슬림 스코프)
- DepartmentService.updateDepartment 에 parent_dept_code 사이클 가드
  (자기 자신/자손을 부모로 지정 시도 차단)
- DepartmentController, mapper 갱신

프론트:
- 부서관리 페이지(deptMngList) UX 리디자인
  - 트리 노드 ⋮ 컨텍스트 메뉴 (하위 추가, 다른 부서 아래로 이동, 정렬 4단계, 삭제)
  - 헤더 breadcrumb 으로 부서 위치 상시 표시
  - 폼의 상위부서 row 제거 (트리 ⋮ 로 진입점 일원화)
  - 빈 상태 placeholder + X 닫기 동작
  - 토글 버튼 토스 스타일 (아이콘 + 툴팁, 일정한 위치)
  - 부서유형 row 좁은 화면 가로 오버플로 fix
- DepartmentPicker 신규 재사용 컴포넌트 (자손 자동 exclude, 사이클 차단)
- 회사관리/프로비저닝 폼 개선 (Step1Basic, fields, CompanyTable, AdminPageRenderer)
- companyList/[companyCode]/departments 구버전 페이지 삭제

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:34:23 +09:00

456 lines
16 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2, Eye, EyeOff, Undo2 } 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 { useToast } from "@/hooks/use-toast";
import type { Department } from "@/types/department";
import * as departmentAPI from "@/lib/api/department";
interface DepartmentStructureProps {
companyCode: string;
selectedDepartment: Department | null;
onSelectDepartment: (department: Department | null) => void;
refreshTrigger?: number;
}
/**
* 부서 구조 컴포넌트 (트리 형태)
*/
export function DepartmentStructure({
companyCode,
selectedDepartment,
onSelectDepartment,
refreshTrigger,
}: DepartmentStructureProps) {
const { toast } = useToast();
const [departments, setDepartments] = useState<Department[]>([]);
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
// V1: soft-delete 된 부서 표시 토글
const [showDeleted, setShowDeleted] = useState(false);
// 부서 추가 모달
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
const [newDeptName, setNewDeptName] = useState("");
const [duplicateMessage, setDuplicateMessage] = useState<string | null>(null);
// 부서 삭제 확인 모달
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
// 부서 목록 로드 — showDeleted 도 의존성에 포함
useEffect(() => {
loadDepartments();
}, [companyCode, refreshTrigger, showDeleted]);
const loadDepartments = async () => {
setIsLoading(true);
try {
const response = await departmentAPI.getDepartments(companyCode, { includeDeleted: showDeleted });
if (response.success && (response as any).data) {
setDepartments((response as any).data);
} else {
console.error("부서 목록 로드 실패:", (response as any).error);
setDepartments([]);
}
} catch (error) {
console.error("부서 목록 로드 실패:", error);
setDepartments([]);
} finally {
setIsLoading(false);
}
};
// V1: 부서 복구 핸들러 (soft-delete 된 부서 되살리기)
const handleRestoreDepartment = async (deptCode: string, deptName: string) => {
try {
const response = await departmentAPI.restoreDepartment(deptCode);
if (response.success) {
loadDepartments();
toast({
title: "부서 복구 완료",
description: `"${deptName}" 부서가 복구되었습니다.`,
variant: "default",
});
} else {
toast({
title: "복구 불가",
description: (response as any).error || "부서 복구에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("부서 복구 실패:", error);
toast({
title: "부서 복구 실패",
description: "복구 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서 트리 구조 생성
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);
setNewDeptName("");
setParentDeptForAdd(null);
loadDepartments();
// 성공 Toast 표시
toast({
title: "부서 생성 완료",
description: `"${newDeptName}" 부서가 생성되었습니다.`,
variant: "default",
});
} else {
if ((response as any).is_duplicate) {
setDuplicateMessage((response as any).error || "이미 존재하는 부서명입니다.");
} else {
toast({
title: "부서 생성 실패",
description: (response as any).error || "부서 추가에 실패했습니다.",
variant: "destructive",
});
}
}
} catch (error) {
console.error("부서 추가 실패:", error);
toast({
title: "부서 생성 실패",
description: "부서 추가 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// 부서 삭제 확인 요청
const handleDeleteDepartmentRequest = (deptCode: string, deptName: string) => {
setDeptToDelete({ code: deptCode, name: deptName });
setDeleteConfirmOpen(true);
};
// 부서 삭제 실행
const handleDeleteDepartmentConfirm = async () => {
if (!deptToDelete) return;
try {
const response = await departmentAPI.deleteDepartment(deptToDelete.code);
if (response.success) {
// 삭제된 부서가 선택되어 있었다면 선택 해제
if (selectedDepartment?.dept_code === deptToDelete.code) {
onSelectDepartment(null);
}
setDeleteConfirmOpen(false);
setDeptToDelete(null);
loadDepartments();
// V1 soft-delete: 복구 가능 안내 추가
const isSoft = (response as any)?.data?.soft_deleted === true;
toast({
title: isSoft ? "부서 삭제됨 (복구 가능)" : "부서 삭제 완료",
description: isSoft
? `"${deptToDelete.name}" 부서를 휴지통으로 보냈습니다. '삭제 부서 보기' 토글로 복구할 수 있습니다.`
: (response as any).message || "부서가 삭제되었습니다.",
variant: "default",
});
} else {
// 삭제 확인 모달을 닫고 에러 모달을 표시
setDeleteConfirmOpen(false);
setDeptToDelete(null);
setDeleteErrorMessage((response as any).error || "부서 삭제에 실패했습니다.");
}
} catch (error) {
console.error("부서 삭제 실패:", error);
setDeleteConfirmOpen(false);
setDeptToDelete(null);
setDeleteErrorMessage("부서 삭제 중 오류가 발생했습니다.");
}
};
// 확장/축소 토글
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;
const isDeleted = !!(dept as any).deleted_at;
return (
<div key={dept.dept_code}>
{/* 부서 항목 — soft-delete 시 회색+취소선 */}
<div
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
isSelected ? "bg-primary/10 text-primary" : ""
} ${isDeleted ? "bg-muted/40 text-muted-foreground line-through opacity-60" : ""}`}
style={{ marginLeft: `${level * 16}px` }}
>
<div className="flex flex-1 items-center gap-2" onClick={() => !isDeleted && 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="text-muted-foreground flex items-center gap-1 text-xs">
<Users className="h-3 w-3" />
<span>{dept.member_count || 0}</span>
</div>
{/* deleted 배지 */}
{isDeleted && <span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>}
</div>
{/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
<div className="flex gap-1">
{isDeleted ? (
<Button
variant="ghost"
size="icon"
className="text-primary h-6 w-6"
title="복구"
onClick={(e) => {
e.stopPropagation();
handleRestoreDepartment(dept.dept_code, dept.dept_name);
}}
>
<Undo2 className="h-3 w-3" />
</Button>
) : (
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title="하위 부서 추가"
onClick={(e) => {
e.stopPropagation();
handleAddDepartment(dept.dept_code);
}}
>
<Plus className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive h-6 w-6"
title="삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
}}
>
<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>
<div className="flex items-center gap-2">
{/* V1: soft-delete 부서 표시 토글 */}
<Button
variant={showDeleted ? "secondary" : "ghost"}
size="sm"
className="h-9 gap-2 text-sm"
onClick={() => setShowDeleted((v) => !v)}
title={showDeleted ? "삭제된 부서 숨기기" : "삭제된 부서 보기"}
>
{showDeleted ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
{showDeleted ? "삭제 부서 숨기기" : "삭제 부서 보기"}
</Button>
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* 부서 트리 */}
<div className="bg-card space-y-1 rounded-lg border p-4 shadow-sm">
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : departments.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">
. .
</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>
{/* 중복 알림 모달 */}
<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>
<Button onClick={() => setDuplicateMessage(null)} className="h-8 text-xs sm:h-10 sm:text-sm">
</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>
<p className="text-muted-foreground mt-2 text-xs">
. ( '삭제 부서 보기' ) .
</p>
</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>
{/* 부서 삭제 에러 모달 */}
<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>
</div>
);
}