Files
sam-react-prod/src/components/hr/DepartmentManagement/index.tsx
유병철 269b901e64 refactor: UI 컴포넌트 추상화 및 입금/출금 등록 버튼 추가
- 입금관리, 출금관리 리스트에 등록 버튼 추가
- skeleton, confirm-dialog, empty-state, status-badge UI 컴포넌트 추가
- document-system 컴포넌트 추상화 (ApprovalLine, DocumentHeader 등)
- 여러 페이지 컴포넌트 리팩토링 및 코드 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:21:42 +09:00

331 lines
10 KiB
TypeScript

'use client';
import { useState, useMemo, useEffect, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Building2 } from 'lucide-react';
import { DepartmentStats } from './DepartmentStats';
import { DepartmentToolbar } from './DepartmentToolbar';
import { DepartmentTree } from './DepartmentTree';
import { DepartmentDialog } from './DepartmentDialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Department } from './types';
import { countAllDepartments, getAllDepartmentIds, findDepartmentById } from './types';
import {
getDepartmentTree,
createDepartment,
updateDepartment,
deleteDepartment,
deleteDepartmentsMany,
type DepartmentRecord,
} from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
/**
* API 응답을 로컬 Department 타입으로 변환
*/
function convertApiToLocal(record: DepartmentRecord): Department {
return {
id: record.id,
name: record.name,
parentId: record.parentId,
depth: record.depth,
children: record.children ? record.children.map(convertApiToLocal) : [],
};
}
export function DepartmentManagement() {
// 부서 데이터 상태
const [departments, setDepartments] = useState<Department[]>([]);
// 로딩/처리 상태
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// 선택 상태
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// 펼침 상태 (기본: 최상위만 펼침)
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
// 검색어
const [searchQuery, setSearchQuery] = useState('');
/**
* 부서 트리 조회 API
*/
const fetchDepartments = useCallback(async () => {
setIsLoading(true);
try {
const result = await getDepartmentTree();
if (result.success && result.data) {
const converted = result.data.map(convertApiToLocal);
setDepartments(converted);
// 최상위 부서 펼침
if (converted.length > 0) {
setExpandedIds(new Set([converted[0].id]));
}
} else {
console.error('[DepartmentManagement] fetchDepartments error:', result.error);
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DepartmentManagement] fetchDepartments error:', error);
} finally {
setIsLoading(false);
}
}, []);
// 초기 데이터 로드
useEffect(() => {
fetchDepartments();
}, [fetchDepartments]);
// 다이얼로그 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
const [selectedDepartment, setSelectedDepartment] = useState<Department | undefined>();
const [parentDepartment, setParentDepartment] = useState<Department | undefined>();
// 삭제 확인 다이얼로그
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [departmentToDelete, setDepartmentToDelete] = useState<Department | null>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
// 전체 부서 수 계산
const totalCount = useMemo(() => countAllDepartments(departments), [departments]);
// 모든 부서 ID
const allIds = useMemo(() => getAllDepartmentIds(departments), [departments]);
// 펼침/접힘 토글
const handleToggleExpand = (id: number) => {
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// 선택 토글
const handleToggleSelect = (id: number) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
// 전체 선택/해제
const handleToggleSelectAll = () => {
if (selectedIds.size === allIds.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(allIds));
}
};
// 부서 추가 (행 버튼)
const handleAdd = (parentId: number) => {
const parent = findDepartmentById(departments, parentId);
setParentDepartment(parent || undefined);
setSelectedDepartment(undefined);
setDialogMode('add');
setDialogOpen(true);
};
// 부서 추가 (상단 버튼 - 선택된 부서의 하위에 일괄 추가)
const handleBulkAdd = () => {
if (selectedIds.size === 0) {
// 선택된 부서가 없으면 최상위에 추가
setParentDepartment(undefined);
} else {
// 선택된 첫 번째 부서를 부모로 설정
const firstSelectedId = Array.from(selectedIds)[0];
const parent = findDepartmentById(departments, firstSelectedId);
setParentDepartment(parent || undefined);
}
setSelectedDepartment(undefined);
setDialogMode('add');
setDialogOpen(true);
};
// 부서 수정
const handleEdit = (department: Department) => {
setSelectedDepartment(department);
setParentDepartment(undefined);
setDialogMode('edit');
setDialogOpen(true);
};
// 부서 삭제 (단일)
const handleDelete = (department: Department) => {
setDepartmentToDelete(department);
setIsBulkDelete(false);
setDeleteDialogOpen(true);
};
// 부서 삭제 (일괄)
const handleBulkDelete = () => {
if (selectedIds.size === 0) return;
setDepartmentToDelete(null);
setIsBulkDelete(true);
setDeleteDialogOpen(true);
};
/**
* 삭제 확인 핸들러 (API 연동)
*/
const confirmDelete = useCallback(async () => {
if (isProcessing) return;
setIsProcessing(true);
try {
if (isBulkDelete) {
// 일괄 삭제 API
const ids = Array.from(selectedIds);
const result = await deleteDepartmentsMany(ids);
if (result.success) {
await fetchDepartments();
setSelectedIds(new Set());
} else {
console.error('[DepartmentManagement] 일괄 삭제 실패:', result.error);
}
} else if (departmentToDelete) {
// 단일 삭제 API
const result = await deleteDepartment(departmentToDelete.id);
if (result.success) {
await fetchDepartments();
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(departmentToDelete.id);
return next;
});
} else {
console.error('[DepartmentManagement] 삭제 실패:', result.error);
}
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DepartmentManagement] confirmDelete error:', error);
} finally {
setDeleteDialogOpen(false);
setDepartmentToDelete(null);
setIsProcessing(false);
}
}, [isBulkDelete, selectedIds, departmentToDelete, isProcessing, fetchDepartments]);
/**
* 부서 추가/수정 제출 핸들러 (API 연동)
* @note parentId는 현재 API에서 미지원 - 모든 부서가 최상위로 생성됨
*/
const handleDialogSubmit = useCallback(async (name: string) => {
if (isProcessing) return;
setIsProcessing(true);
try {
if (dialogMode === 'add') {
// 새 부서 추가 API
// NOTE: parentId는 현재 API에서 지원하지 않아 최상위에만 생성됨
const result = await createDepartment({
name,
parentId: parentDepartment?.id,
});
if (result.success) {
await fetchDepartments();
} else {
console.error('[DepartmentManagement] 부서 생성 실패:', result.error);
}
} else if (dialogMode === 'edit' && selectedDepartment) {
// 부서 수정 API
const result = await updateDepartment(selectedDepartment.id, { name });
if (result.success) {
await fetchDepartments();
} else {
console.error('[DepartmentManagement] 부서 수정 실패:', result.error);
}
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('[DepartmentManagement] handleDialogSubmit error:', error);
} finally {
setDialogOpen(false);
setIsProcessing(false);
}
}, [dialogMode, parentDepartment, selectedDepartment, isProcessing, fetchDepartments]);
return (
<PageLayout>
<PageHeader
title="부서관리"
description="부서 정보를 관리합니다"
icon={Building2}
/>
<div className="space-y-4">
{/* 전체 부서 카운트 */}
<DepartmentStats totalCount={totalCount} />
{/* 검색 + 추가/삭제 버튼 */}
<DepartmentToolbar
totalCount={totalCount}
selectedCount={selectedIds.size}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onAdd={handleBulkAdd}
onDelete={handleBulkDelete}
/>
{/* 트리 테이블 */}
<DepartmentTree
departments={departments}
expandedIds={expandedIds}
selectedIds={selectedIds}
onToggleExpand={handleToggleExpand}
onToggleSelect={handleToggleSelect}
onToggleSelectAll={handleToggleSelectAll}
onAdd={handleAdd}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
{/* 추가/수정 다이얼로그 */}
<DepartmentDialog
isOpen={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
parentDepartment={parentDepartment}
department={selectedDepartment}
onSubmit={handleDialogSubmit}
/>
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description={
<>
{isBulkDelete
? `선택한 부서 ${selectedIds.size}개를 삭제하시겠습니까?`
: `"${departmentToDelete?.name}" 부서를 삭제하시겠습니까?`
}
<br />
<span className="text-destructive">
() .
</span>
</>
}
onConfirm={confirmDelete}
/>
</PageLayout>
);
}