68c1cb5b14
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>
353 lines
12 KiB
TypeScript
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;
|