feat: [부서관리] 기능 보완 - 필드 확장, 검색/필터, UI 개선

- Department 타입에 code, description, isActive, sortOrder 필드 추가
- DepartmentDialog: Zod + react-hook-form 폼 검증 (5개 필드)
- DepartmentToolbar: 상태 필터(전체/활성/비활성) + 검색 기능
- DepartmentTree: 트리 필터링 (검색어 + 상태)
- DepartmentTreeItem: 코드 Badge, 부서명 볼드, 설명 표시, 체크박스 크기 조정
- convertApiToLocal에서 누락 필드 매핑 복원
This commit is contained in:
2026-03-13 00:30:09 +09:00
parent ca5a9325c6
commit 13249384e2
26 changed files with 1284 additions and 915 deletions

View File

@@ -1,6 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
import { useEffect } from 'react';
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Dialog,
DialogContent,
@@ -11,7 +14,19 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import type { DepartmentDialogProps } from './types';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { DepartmentDialogProps, DepartmentFormData } from './types';
const departmentFormSchema = z.object({
code: z.string().min(1, '부서 코드를 입력하세요').max(50, '50자 이내로 입력하세요'),
name: z.string().min(1, '부서명을 입력하세요').max(100, '100자 이내로 입력하세요'),
description: z.string().max(500, '500자 이내로 입력하세요').default(''),
sortOrder: z.coerce.number().min(0, '0 이상 입력하세요').default(0),
isActive: z.boolean().default(true),
});
type FormData = z.infer<typeof departmentFormSchema>;
/**
* 부서 추가/수정 다이얼로그
@@ -22,27 +37,59 @@ export function DepartmentDialog({
mode,
parentDepartment,
department,
onSubmit
onSubmit,
}: DepartmentDialogProps) {
const [name, setName] = useState('');
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(departmentFormSchema),
defaultValues: {
code: '',
name: '',
description: '',
sortOrder: 0,
isActive: true,
},
});
const isActive = watch('isActive');
// 다이얼로그 열릴 때 초기값 설정
useEffect(() => {
if (isOpen) {
if (mode === 'edit' && department) {
setName(department.name);
reset({
code: department.code || '',
name: department.name,
description: department.description || '',
sortOrder: department.sortOrder,
isActive: department.isActive,
});
} else {
setName('');
reset({
code: '',
name: '',
description: '',
sortOrder: 0,
isActive: true,
});
}
}
}, [isOpen, mode, department]);
}, [isOpen, mode, department, reset]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onSubmit(name.trim());
setName('');
}
const onFormSubmit = (data: FormData) => {
onSubmit({
code: data.code,
name: data.name,
description: data.description || '',
sortOrder: data.sortOrder,
isActive: data.isActive,
} as DepartmentFormData);
};
const title = mode === 'add' ? '부서 추가' : '부서 수정';
@@ -50,12 +97,12 @@ export function DepartmentDialog({
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit(onFormSubmit)}>
<div className="space-y-4 py-4">
{/* 부모 부서 표시 (추가 모드일 때) */}
{mode === 'add' && parentDepartment && (
@@ -64,16 +111,78 @@ export function DepartmentDialog({
</div>
)}
{/* 부서명 입력 */}
{/* 부서 코드 */}
<div className="space-y-2">
<Label htmlFor="department-name"></Label>
<Label htmlFor="department-code">
<span className="text-destructive">*</span>
</Label>
<Input
id="department-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="부서명을 입력하세요"
id="department-code"
{...register('code')}
placeholder="예: DEV, SALES, HR"
autoFocus
/>
{errors.code && (
<p className="text-sm text-destructive">{errors.code.message}</p>
)}
</div>
{/* 부서명 */}
<div className="space-y-2">
<Label htmlFor="department-name">
<span className="text-destructive">*</span>
</Label>
<Input
id="department-name"
{...register('name')}
placeholder="부서명을 입력하세요"
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="department-description"></Label>
<Textarea
id="department-description"
{...register('description')}
placeholder="부서 설명을 입력하세요"
rows={3}
/>
{errors.description && (
<p className="text-sm text-destructive">{errors.description.message}</p>
)}
</div>
{/* 정렬순서 + 활성상태 (가로 배치) */}
<div className="flex items-start gap-6">
<div className="space-y-2 flex-1">
<Label htmlFor="department-sort-order"></Label>
<Input
id="department-sort-order"
type="number"
{...register('sortOrder')}
min={0}
/>
{errors.sortOrder && (
<p className="text-sm text-destructive">{errors.sortOrder.message}</p>
)}
</div>
<div className="space-y-2">
<Label> </Label>
<div className="flex items-center gap-2 h-9">
<Switch
checked={isActive}
onCheckedChange={(checked) => setValue('isActive', checked)}
/>
<span className="text-sm text-muted-foreground">
{isActive ? '활성' : '비활성'}
</span>
</div>
</div>
</div>
</div>
@@ -81,7 +190,7 @@ export function DepartmentDialog({
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="submit" disabled={!name.trim()}>
<Button type="submit">
{submitText}
</Button>
</DialogFooter>
@@ -89,4 +198,4 @@ export function DepartmentDialog({
</DialogContent>
</Dialog>
);
}
}

View File

@@ -2,31 +2,58 @@
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Search, Plus, Trash2 } from 'lucide-react';
import type { DepartmentToolbarProps } from './types';
/**
* 검색 + 추가/삭제 버튼 툴바
* 검색 + 필터 + 추가/삭제 버튼 툴바
*/
export function DepartmentToolbar({
totalCount,
selectedCount,
searchQuery,
onSearchChange,
statusFilter,
onStatusFilterChange,
onAdd,
onDelete
onDelete,
}: DepartmentToolbarProps) {
return (
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
{/* 검색 */}
<div className="relative w-full sm:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="부서명 검색"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
{/* 검색 + 필터 */}
<div className="flex items-center gap-2 w-full sm:w-auto">
{/* 검색창 */}
<div className="relative flex-1 sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="부서명, 코드 검색"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>
{/* 상태 필터 */}
<Select
value={statusFilter}
onValueChange={(value) => onStatusFilterChange(value as 'all' | 'active' | 'inactive')}
>
<SelectTrigger className="w-28 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="inactive"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 선택 카운트 + 버튼 */}
@@ -54,4 +81,4 @@ export function DepartmentToolbar({
</div>
</div>
);
}
}

View File

@@ -35,7 +35,7 @@ export function DepartmentTree({
onCheckedChange={onToggleSelectAll}
aria-label="전체 선택"
/>
<span className="font-medium text-sm"></span>
<span className="font-medium text-sm"> / </span>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import { memo } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ChevronRight, ChevronDown, Plus, SquarePen, Trash2 } from 'lucide-react';
import type { DepartmentTreeItemProps } from './types';
@@ -56,11 +57,35 @@ export const DepartmentTreeItem = memo(function DepartmentTreeItem({
checked={isSelected}
onCheckedChange={() => onToggleSelect(department.id)}
aria-label={`${department.name} 선택`}
className="shrink-0"
className="shrink-0 size-[18px]"
/>
{/* 부서 코드 */}
<span
className="w-20 shrink-0 cursor-pointer"
onClick={() => onToggleSelect(department.id)}
>
{department.code && (
<Badge variant="outline" className="text-xs font-mono rounded-sm w-16 justify-center">
{department.code}
</Badge>
)}
</span>
{/* 부서명 */}
<span className="break-words">{department.name}</span>
<span className="w-20 shrink-0 font-bold truncate">{department.name}</span>
{/* 설명 */}
<span className="text-xs text-muted-foreground truncate">
{department.description || ''}
</span>
{/* 상태 뱃지 */}
{!department.isActive && (
<Badge variant="secondary" className="text-xs shrink-0">
</Badge>
)}
</div>
{/* 작업 버튼 (선택 시 부서명 아래에 표시, 데스크톱: 호버 시에도 표시) */}

View File

@@ -9,8 +9,8 @@ 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 type { Department, DepartmentFormData, StatusFilter } from './types';
import { countAllDepartments, getAllDepartmentIds, findDepartmentById, filterDepartmentTree } from './types';
import {
getDepartmentTree,
createDepartment,
@@ -27,8 +27,12 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
function convertApiToLocal(record: DepartmentRecord): Department {
return {
id: record.id,
code: record.code,
name: record.name,
description: record.description,
parentId: record.parentId,
isActive: record.isActive,
sortOrder: record.sortOrder,
depth: record.depth,
children: record.children ? record.children.map(convertApiToLocal) : [],
};
@@ -51,6 +55,9 @@ export function DepartmentManagement() {
// 검색어
const [searchQuery, setSearchQuery] = useState('');
// 상태 필터
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
/**
* 부서 트리 조회 API
*/
@@ -92,11 +99,20 @@ export function DepartmentManagement() {
const [departmentToDelete, setDepartmentToDelete] = useState<Department | null>(null);
const [isBulkDelete, setIsBulkDelete] = useState(false);
// 전체 부서 수 계산
// 필터링된 부서 트리
const filteredDepartments = useMemo(
() => filterDepartmentTree(departments, searchQuery, statusFilter),
[departments, searchQuery, statusFilter],
);
// 전체 부서 수 (필터 전)
const totalCount = useMemo(() => countAllDepartments(departments), [departments]);
// 모든 부서 ID
const allIds = useMemo(() => getAllDepartmentIds(departments), [departments]);
// 필터된 부서
const filteredCount = useMemo(() => countAllDepartments(filteredDepartments), [filteredDepartments]);
// 필터된 부서 ID
const filteredAllIds = useMemo(() => getAllDepartmentIds(filteredDepartments), [filteredDepartments]);
// 펼침/접힘 토글
const handleToggleExpand = (id: number) => {
@@ -124,12 +140,12 @@ export function DepartmentManagement() {
});
};
// 전체 선택/해제
// 전체 선택/해제 (필터된 부서 기준)
const handleToggleSelectAll = () => {
if (selectedIds.size === allIds.length) {
if (selectedIds.size === filteredAllIds.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(allIds));
setSelectedIds(new Set(filteredAllIds));
}
};
@@ -225,18 +241,19 @@ export function DepartmentManagement() {
/**
* 부서 추가/수정 제출 핸들러 (API 연동)
* @note parentId는 현재 API에서 미지원 - 모든 부서가 최상위로 생성됨
*/
const handleDialogSubmit = useCallback(async (name: string) => {
const handleDialogSubmit = useCallback(async (formData: DepartmentFormData) => {
if (isProcessing) return;
setIsProcessing(true);
try {
if (dialogMode === 'add') {
// 새 부서 추가 API
// NOTE: parentId는 현재 API에서 지원하지 않아 최상위에만 생성됨
const result = await createDepartment({
name,
code: formData.code,
name: formData.name,
description: formData.description || undefined,
sortOrder: formData.sortOrder,
isActive: formData.isActive,
parentId: parentDepartment?.id,
});
if (result.success) {
@@ -245,8 +262,13 @@ export function DepartmentManagement() {
console.error('[DepartmentManagement] 부서 생성 실패:', result.error);
}
} else if (dialogMode === 'edit' && selectedDepartment) {
// 부서 수정 API
const result = await updateDepartment(selectedDepartment.id, { name });
const result = await updateDepartment(selectedDepartment.id, {
code: formData.code,
name: formData.name,
description: formData.description || undefined,
sortOrder: formData.sortOrder,
isActive: formData.isActive,
});
if (result.success) {
await fetchDepartments();
} else {
@@ -262,6 +284,9 @@ export function DepartmentManagement() {
}
}, [dialogMode, parentDepartment, selectedDepartment, isProcessing, fetchDepartments]);
// 필터 활성 여부
const isFiltered = searchQuery.trim() !== '' || statusFilter !== 'all';
return (
<PageLayout>
<PageHeader
@@ -274,19 +299,21 @@ export function DepartmentManagement() {
{/* 전체 부서 카운트 */}
<DepartmentStats totalCount={totalCount} />
{/* 검색 + 추가/삭제 버튼 */}
{/* 검색 + 필터 + 추가/삭제 버튼 */}
<DepartmentToolbar
totalCount={totalCount}
totalCount={isFiltered ? filteredCount : totalCount}
selectedCount={selectedIds.size}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
onAdd={handleBulkAdd}
onDelete={handleBulkDelete}
/>
{/* 트리 테이블 */}
<DepartmentTree
departments={departments}
departments={filteredDepartments}
expandedIds={expandedIds}
selectedIds={selectedIds}
onToggleExpand={handleToggleExpand}
@@ -328,4 +355,4 @@ export function DepartmentManagement() {
/>
</PageLayout>
);
}
}

View File

@@ -8,12 +8,27 @@
*/
export interface Department {
id: number;
code: string | null;
name: string;
description: string | null;
parentId: number | null;
isActive: boolean;
sortOrder: number;
depth: number; // 깊이 (0: 최상위, 1, 2, 3, ... 무제한)
children?: Department[]; // 하위 부서 (재귀 - 무제한 깊이)
}
/**
* 부서 폼 데이터
*/
export interface DepartmentFormData {
code: string;
name: string;
description: string;
sortOrder: number;
isActive: boolean;
}
/**
* 부서 추가/수정 다이얼로그 Props
*/
@@ -23,7 +38,7 @@ export interface DepartmentDialogProps {
mode: 'add' | 'edit';
parentDepartment?: Department; // 추가 시 부모 부서
department?: Department; // 수정 시 대상 부서
onSubmit: (name: string) => void;
onSubmit: (data: DepartmentFormData) => void;
}
/**
@@ -48,6 +63,11 @@ export interface DepartmentStatsProps {
totalCount: number;
}
/**
* 상태 필터 타입
*/
export type StatusFilter = 'all' | 'active' | 'inactive';
/**
* 툴바 Props
*/
@@ -56,6 +76,8 @@ export interface DepartmentToolbarProps {
selectedCount: number;
searchQuery: string;
onSearchChange: (query: string) => void;
statusFilter: StatusFilter;
onStatusFilterChange: (filter: StatusFilter) => void;
onAdd: () => void;
onDelete: () => void;
}
@@ -107,3 +129,45 @@ export const findDepartmentById = (departments: Department[], id: number): Depar
}
return null;
};
/**
* 트리 필터링 유틸리티 (재귀 — 검색/상태 필터)
* 자식이 매칭되면 부모도 유지
*/
export const filterDepartmentTree = (
departments: Department[],
searchQuery: string,
statusFilter: StatusFilter,
): Department[] => {
const query = searchQuery.trim().toLowerCase();
const filterNode = (dept: Department): Department | null => {
// 자식 먼저 필터링
const filteredChildren = dept.children
? dept.children.map(filterNode).filter(Boolean) as Department[]
: [];
// 현재 노드가 매칭되는지 확인
const matchesSearch = !query ||
dept.name.toLowerCase().includes(query) ||
(dept.code && dept.code.toLowerCase().includes(query));
const matchesStatus = statusFilter === 'all' ||
(statusFilter === 'active' && dept.isActive) ||
(statusFilter === 'inactive' && !dept.isActive);
// 자식이 매칭되면 부모도 유지
if (filteredChildren.length > 0) {
return { ...dept, children: filteredChildren };
}
// 현재 노드가 매칭되면 유지
if (matchesSearch && matchesStatus) {
return { ...dept, children: [] };
}
return null;
};
return departments.map(filterNode).filter(Boolean) as Department[];
};