"use client"; /** * DepartmentPicker — 부서 선택 재사용 컴포넌트 (V1 신규). * * 다른 화면에서 부서를 선택해야 할 때 사용. 단일 / 다중 선택 모드 지원. * * 사용 예: * setParentDeptCode(code as string)} * onClose={() => setIsOpen(false)} * excludeCodes={[currentDeptCode]} * /> * * 동작: * - shadcn Dialog 안에 검색박스 + 트리뷰 * - 부모 클릭 시 자식 cascade 펼침 * - 클라이언트측 검색 (debounce 200ms, 이름/코드 부분일치) * - single: 클릭 즉시 onSelect → close * - multi: 체크박스 + 부모 체크 시 자식 자동 cascade + 확인 버튼으로 onSelect * - excludeCodes 에 포함된 dept 는 disabled * - 사이클 데이터 (잘못된 PARENT_DEPT_CODE) 는 visited Set 으로 차단 * * V1 한계: 검색은 클라이언트측 필터. 1000+ 부서는 V2 에서 backend search 도입 예정. */ import { useEffect, useMemo, useState } from "react"; import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import type { Department } from "@/types/department"; import { getDepartments } from "@/lib/api/department"; export interface DepartmentPickerProps { companyCode: string; mode: "single" | "multi"; /** 현재 선택값. single 이면 string, multi 면 string[] */ value?: string | string[]; open: boolean; onSelect: (code: string | string[]) => void; onClose: () => void; /** 선택 불가로 disable 처리할 dept_code 들 (자기 자신 부모 등록 방지 등) */ excludeCodes?: string[]; /** soft-delete 된 부서도 보여줄지 (default false) */ includeDeleted?: boolean; /** 모달 헤더 타이틀 (default: "부서 선택") */ title?: string; /** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */ allowRoot?: boolean; } export function DepartmentPicker({ companyCode, mode, value, open, onSelect, onClose, excludeCodes, includeDeleted = false, title = "부서 선택", allowRoot = false, }: DepartmentPickerProps) { const [departments, setDepartments] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchInput, setSearchInput] = useState(""); const [searchTerm, setSearchTerm] = useState(""); // debounced const [expanded, setExpanded] = useState>(new Set()); const [selected, setSelected] = useState>(new Set()); // value -> selected 동기화 useEffect(() => { if (!open) return; if (mode === "single") { setSelected(new Set(typeof value === "string" && value ? [value] : [])); } else { setSelected(new Set(Array.isArray(value) ? value : [])); } }, [value, mode, open]); // 검색어 debounce 200ms useEffect(() => { const t = setTimeout(() => setSearchTerm(searchInput.trim().toLowerCase()), 200); return () => clearTimeout(t); }, [searchInput]); // 부서 목록 로드 useEffect(() => { if (!open) return; let cancelled = false; setIsLoading(true); getDepartments(companyCode, { includeDeleted }) .then((res: any) => { if (cancelled) return; if (res?.success && Array.isArray(res?.data)) { setDepartments(res.data); } else { setDepartments([]); } }) .catch(() => !cancelled && setDepartments([])) .finally(() => !cancelled && setIsLoading(false)); return () => { cancelled = true; }; }, [open, companyCode, includeDeleted]); // 코드 -> 부서 맵 (검색·자식 조회용) const byCode = useMemo(() => { const m = new Map(); for (const d of departments) m.set(d.dept_code, d); return m; }, [departments]); // 검색 매칭 (이름·코드 부분일치) const isMatch = (d: Department): boolean => { if (!searchTerm) return true; return ( d.dept_name.toLowerCase().includes(searchTerm) || d.dept_code.toLowerCase().includes(searchTerm) ); }; // 검색 매칭 부서 + 그 조상들도 포함해서 트리에서 visible const visibleCodes = useMemo(() => { if (!searchTerm) return null; // null = 전체 visible const visible = new Set(); for (const d of departments) { if (isMatch(d)) { visible.add(d.dept_code); // 조상 visible (부모-부모-...) — 사이클 차단 const visited = new Set([d.dept_code]); let parentCode = d.parent_dept_code; while (parentCode && !visited.has(parentCode)) { visited.add(parentCode); visible.add(parentCode); parentCode = byCode.get(parentCode)?.parent_dept_code ?? null; } } } return visible; }, [searchTerm, departments, byCode]); // 부모 코드 → 자식 정렬 리스트 const childrenOf = (parentCode: string | null): Department[] => { return departments .filter((d) => (d.parent_dept_code ?? null) === parentCode) .sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); }; // 부모 + 자식 (재귀) cascade 코드들 const collectDescendants = (rootCode: string): string[] => { const result: string[] = []; const visited = new Set(); const dfs = (code: string) => { if (visited.has(code)) return; visited.add(code); result.push(code); for (const child of childrenOf(code)) { dfs(child.dept_code); } }; dfs(rootCode); return result; }; const toggleExpand = (code: string) => { const next = new Set(expanded); if (next.has(code)) next.delete(code); else next.add(code); setExpanded(next); }; const isExcluded = (code: string) => Boolean(excludeCodes?.includes(code)); const isDeleted = (d: Department) => Boolean((d as any).deleted_at); const handleNodeClick = (d: Department) => { if (isExcluded(d.dept_code)) return; if (mode === "single") { onSelect(d.dept_code); onClose(); return; } // multi: 자기 + 자손 모두 토글 const next = new Set(selected); const codes = collectDescendants(d.dept_code).filter((c) => !isExcluded(c)); const allSelected = codes.every((c) => next.has(c)); if (allSelected) { for (const c of codes) next.delete(c); } else { for (const c of codes) next.add(c); } setSelected(next); }; const handleConfirmMulti = () => { onSelect(Array.from(selected)); onClose(); }; // 트리 렌더 (재귀, visited Set 사이클 차단) const renderTree = (parentCode: string | null, level: number, visited: Set): React.ReactNode => { const list = childrenOf(parentCode); return list.map((d) => { if (visited.has(d.dept_code)) return null; // 사이클 차단 const nextVisited = new Set(visited); nextVisited.add(d.dept_code); const hasChildren = childrenOf(d.dept_code).length > 0; const isOpen = expanded.has(d.dept_code) || (searchTerm.length > 0 && hasChildren); const isSel = selected.has(d.dept_code); const excluded = isExcluded(d.dept_code); const deleted = isDeleted(d); // 검색 시: visible 아닌 노드는 숨김 if (visibleCodes && !visibleCodes.has(d.dept_code)) return null; return (
handleNodeClick(d)} > {/* expand/collapse */} {hasChildren ? ( ) : (
)} {/* multi: 체크 표시 */} {mode === "multi" && (
{isSel && }
)} {/* 부서명 + 코드 */}
{d.dept_name} {d.dept_code}
{deleted && ( 삭제됨 )}
{hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)}
); }); }; // 루트 — parent_dept_code 가 null/빈문자열인 부서들 const rootList = useMemo(() => childrenOf(null), [departments]); const hasAny = rootList.length > 0 || departments.some((d) => !d.parent_dept_code); const noResults = searchTerm.length > 0 && (visibleCodes?.size ?? 0) === 0; return ( !o && onClose()}> {title}
{/* 검색 */}
setSearchInput(e.target.value)} placeholder="부서명 또는 코드로 검색" className="pl-8 pr-8" autoFocus /> {searchInput && ( )}
{/* 트리 */}
{mode === "single" && allowRoot && !isLoading && ( )} {isLoading ? (
로딩 중...
) : !hasAny ? (
부서가 없습니다.
) : noResults ? (
검색 결과 없음
) : ( renderTree(null, 0, new Set()) )}
{mode === "multi" && ( )}
); } export default DepartmentPicker;