Files
invyone/frontend/components/departments/DepartmentPicker.tsx
T
johngreen 68c1cb5b14 fix(부서관리): 25개 버그 일괄 수정 + 데이터 무결성 강화
CRITICAL:
- searchUsers 회사/role 격리 가드 추가 (멀티테넌시 침해 차단)
- setPrimaryDept 멤버십 검증 추가 (주부서 데이터 손상 방지)
- parent_dept_code cross-tenant 검증 (validateParent 헬퍼)

HIGH:
- updateDepartment SQL WHERE 에 DELETED_AT IS NULL 추가 (silent corruption 방지)
- update/restore 부서명 중복 검증 추가
- 글로벌 부서 (*) write 작업 SUPER_ADMIN 전용 가드
- 부서코드 자동 생성으로 강제 (사용자 입력 받지 않음)
- 회사 변경 시 상세 패널 초기화
- handleMove 부분 실패 시 화면 동기화
- 검색 시 부모 체인 자동 포함 (broken tree 수정)
- start_date 기본값 today 강제 제거

MEDIUM:
- 멤버 fetch cancellation flag
- 삭제 다이얼로그 dept_code 클로저 캡처
- isDirty 시 X 버튼 폼 초기화 경고
- 변경이력 버튼 disabled (백엔드 API 미구현)
- 일괄등록 실패 상세 모달 (라인 + 사유)
- LIKE 와일드카드 ESCAPE 적용
- nullIfBlank 에 trim 통합

LOW + 새 기능:
- 부서원 추가/제거 UI 신규 구현 (UserSearchModal)
- selectDeptMembers LEFT JOIN 으로 변경
- DepartmentPicker allowRoot 옵션 (최상위로 이동)
- expandAll 전체 departments 사용
- dead code 정리

DB:
- RUN_085 마이그레이션: DEPT_INFO partial UNIQUE + USER_DEPT UNIQUE
- 모든 active 테넌트 DB (siflex/test01/test02_invyone) 적용 완료

Breaking changes:
- 일괄등록 CSV 4컬럼 → 3컬럼 (부서명,상위부서,유형)
- 부서코드 입력란 제거 (자동 부여 DEPT_n)

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

353 lines
12 KiB
TypeScript

"use client";
/**
* DepartmentPicker — 부서 선택 재사용 컴포넌트 (V1 신규).
*
* 다른 화면에서 부서를 선택해야 할 때 사용. 단일 / 다중 선택 모드 지원.
*
* 사용 예:
* <DepartmentPicker
* companyCode="INVYONE"
* mode="single"
* value={parentDeptCode}
* open={isOpen}
* onSelect={(code) => 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<Department[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [searchInput, setSearchInput] = useState("");
const [searchTerm, setSearchTerm] = useState(""); // debounced
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(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<string, Department>();
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<string>();
for (const d of departments) {
if (isMatch(d)) {
visible.add(d.dept_code);
// 조상 visible (부모-부모-...) — 사이클 차단
const visited = new Set<string>([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<string>();
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<string>): 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 (
<div key={d.dept_code}>
<div
className={`flex items-center gap-2 rounded p-1.5 text-sm transition-colors ${
excluded
? "cursor-not-allowed opacity-40"
: "hover:bg-muted cursor-pointer"
} ${isSel ? "bg-primary/10 text-primary" : ""} ${deleted ? "text-muted-foreground line-through" : ""}`}
style={{ paddingLeft: `${level * 16 + 6}px` }}
onClick={() => handleNodeClick(d)}
>
{/* expand/collapse */}
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand(d.dept_code);
}}
className="flex h-4 w-4 items-center justify-center"
>
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
) : (
<div className="h-4 w-4" />
)}
{/* multi: 체크 표시 */}
{mode === "multi" && (
<div
className={`flex h-4 w-4 items-center justify-center rounded border ${
isSel ? "bg-primary border-primary text-primary-foreground" : "border-input"
}`}
>
{isSel && <Check className="h-3 w-3" />}
</div>
)}
{/* 부서명 + 코드 */}
<div className="flex flex-1 flex-col leading-tight">
<span className="font-medium">{d.dept_name}</span>
<span className="text-muted-foreground text-[10px] uppercase">{d.dept_code}</span>
</div>
{deleted && (
<span className="text-muted-foreground text-[10px] uppercase tracking-wider"></span>
)}
</div>
{hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)}
</div>
);
});
};
// 루트 — 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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{/* 검색 */}
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="부서명 또는 코드로 검색"
className="pl-8 pr-8"
autoFocus
/>
{searchInput && (
<button
onClick={() => setSearchInput("")}
className="text-muted-foreground absolute right-2 top-1/2 -translate-y-1/2"
title="검색어 지우기"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* 트리 */}
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
{mode === "single" && allowRoot && !isLoading && (
<button
type="button"
onClick={() => {
onSelect("");
onClose();
}}
className="bg-muted/30 hover:bg-muted mb-1 w-full rounded p-1.5 text-left text-sm font-medium text-muted-foreground"
>
📂 ( )
</button>
)}
{isLoading ? (
<div className="text-muted-foreground py-8 text-center text-sm"> ...</div>
) : !hasAny ? (
<div className="text-muted-foreground py-8 text-center text-sm"> .</div>
) : noResults ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div>
) : (
renderTree(null, 0, new Set())
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
</Button>
{mode === "multi" && (
<Button onClick={handleConfirmMulti} disabled={selected.size === 0}>
({selected.size})
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default DepartmentPicker;