Files
invyone/frontend/app/(main)/admin/userMng/deptMngList/page.tsx
T
johngreen 824a3100ce security(멀티테넌시): 관리 plane vs 테넌트 plane 격리 + 부서관리 후속
이번 PR 은 invyone 멀티테넌시 SaaS 의 "관리 plane vs 테넌트 plane" 격리를
4 영역(PR #A~D) 에서 강화하고, 별도로 진행 중이던 부서관리 후속 작업을 포함한다.

# 보안 (plane 격리)

PR #A — controller/CompanyManagementController 인증 누락 패치
  /api/company-management/* 가 JWT/role/host 체크 없이 외부에서 누구나 회사 삭제
  + 디스크 통계 호출 가능했던 critical 누수 막음. SuperAdminGuard.enforce() 적용.

PR #C — cross-tenant 컨트롤러 호스트 격리 + 감사 로그
  CrossTenantContext.requireManagementHost() 헬퍼 추가, 5 컨트롤러
  (CrossTenantContext/Controller/UserController/RoleController/DeptController) 모두
  테넌트 호스트에서 호출 시 403. CompanyAuditLogService 에 cross-tenant write 4종
  (USER_CREATE/DELETE, PW_RESET, ROLE_UPDATE) audit action 추가.
  SuperAdminGuard.isTenantHost 가시성 public static 으로 승격.

PR #B — 프론트 솔루션 전용 admin 페이지 가드
  admin/* 페이지 전수 분류 결과 솔루션 전용 3건 식별:
  subdomainList / companyList / audit-log. 각 페이지에 isManagementHost
  useEffect 가드 + redirect 추가. 사이드바도 같이 숨김.

PR #D — MENU_INFO.IS_SOLUTION_ONLY 컬럼 + DB-driven 메뉴 필터
  V023 마이그레이션으로 컬럼 추가 + 솔루션 메뉴 3개 마킹.
  admin.xml selectUserMenuList 에 호스트 기반 필터 추가, AdminController.getUserMenus
  가 Host 헤더로 is_management_host 결정. 프론트 MANAGEMENT_ONLY_MENU_URLS
  하드코딩 set 폐기 (DB 가 대신함). 페이지 자체 가드는 defense in depth 로 유지.
  StartupSchemaMigrator 에 V023 등록되어 모든 테넌트 DB 부팅 시 자동 적용.

# 부서관리 후속 (이전 PR #18/#19 follow-up)

DepartmentController/Service + frontend deptMngList/department.ts 의 추가 작업분.
이번 격리 작업과 무관하지만 같이 정리.

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

2277 lines
90 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import * as XLSX from "xlsx";
import {
ArrowDownToLine,
ArrowUpToLine,
Building2,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
ChevronsDownUp,
ChevronsUpDown,
Eye,
EyeOff,
FileDown,
Folder,
FolderOpen,
FolderTree,
Globe,
History,
Info,
MoreVertical,
Play,
Plus,
Search,
Star,
Trash2,
Undo2,
Upload,
Users,
X,
XCircle,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
import * as departmentAPI from "@/lib/api/department";
import { getCompanyList } from "@/lib/api/company";
import { DepartmentPicker } from "@/components/departments/DepartmentPicker";
import type { Department, DepartmentMember } from "@/types/department";
import type { Company } from "@/types/company";
/**
* dept_info 테이블 스키마에 1:1 매핑되는 Draft (V019 정리 후).
*
* 필드 매핑:
* dept_code : 부서코드 (PK)
* parent_dept_code : 상위부서코드
* dept_name : 부서명
* location : 위치코드 (UI hide, V2 매핑용 컬럼만 유지)
* status : 사용여부 (active/inactive)
* company_name : 회사명 (조회용)
* company_code : 회사코드
* short_name : 부서약칭
* dept_type : 부서유형 (dept|team|temp)
* org_system : 조직체계
* approval_manager : 결재 관리자
* dept_manager : 부서 관리자
* zipcode : 우편번호 (UI hide)
* address1 : 주소1 (UI hide)
* address2 : 주소2 (UI hide)
* start_date : 시작일 (UI hide)
* end_date : 종료일 (UI hide)
* sort_order : 정렬순서
*/
interface DeptDetailDraft {
dept_code: string;
parent_dept_code: string | null;
dept_name: string;
location: string;
status: "active" | "inactive";
company_code: string;
short_name: string;
dept_type: string;
org_system: string;
approval_manager: string;
dept_manager: string;
zipcode: string;
address1: string;
address2: string;
start_date: string;
end_date: string;
sort_order: number;
// 다중 관리자 (chip UI 용)
approval_managers: string[];
dept_managers: string[];
org_leaders: string[];
}
const emptyDraft = (companyCode = ""): DeptDetailDraft => ({
dept_code: "",
parent_dept_code: null,
dept_name: "",
location: "",
status: "active",
company_code: companyCode,
short_name: "",
dept_type: "dept",
org_system: "",
approval_manager: "",
dept_manager: "",
zipcode: "",
address1: "",
address2: "",
start_date: "",
end_date: "",
sort_order: 10,
approval_managers: [],
dept_managers: [],
org_leaders: [],
});
export default function DeptMngListPage() {
const { toast } = useToast();
const { user } = useAuth();
// ── 회사 선택 / 기준일 ────────────────────────────────
const [companies, setCompanies] = useState<Company[]>([]);
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("");
const [periodMode, setPeriodMode] = useState<"all" | "date">("date");
const [baseDate, setBaseDate] = useState<string>(new Date().toISOString().slice(0, 10));
const [searchKeyword, setSearchKeyword] = useState("");
// ── 부서 트리 ─────────────────────────────────────────
const [departments, setDepartments] = useState<Department[]>([]);
const [expandedSet, setExpandedSet] = useState<Set<string>>(new Set());
// 회사/사업장 루트 노드 펼침 토글 (1:1 가정 — 단일 노드)
const [siteOpen, setSiteOpen] = useState(true);
const [selectedCode, setSelectedCode] = useState<string | null>(null);
const [isTreeLoading, setIsTreeLoading] = useState(false);
// V1: 삭제된 부서 표시 토글 (휴지통)
const [showDeleted, setShowDeleted] = useState(false);
// ── 상세정보 ─────────────────────────────────────────
const [draft, setDraft] = useState<DeptDetailDraft>(() => emptyDraft());
const [originalDraft, setOriginalDraft] = useState<DeptDetailDraft | null>(null);
const [isNewMode, setIsNewMode] = useState(false);
const [activeTab, setActiveTab] = useState<"info" | "members">("info");
const [members, setMembers] = useState<DepartmentMember[]>([]);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
// ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
const [bulkRows, setBulkRows] = useState<Record<string, any>[]>([]);
const [bulkPreviewRows, setBulkPreviewRows] = useState<departmentAPI.BulkPreviewRow[]>([]);
const [bulkSelected, setBulkSelected] = useState<Set<number>>(new Set());
const [bulkBusy, setBulkBusy] = useState(false);
const [bulkFileName, setBulkFileName] = useState<string>("");
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
const [contextDeleteDept, setContextDeleteDept] = useState<Department | null>(null);
const selectedCompany = useMemo(
() => companies.find((c) => c.company_code === selectedCompanyCode) || null,
[companies, selectedCompanyCode],
);
// 헤더 breadcrumb 용 ancestor 체인 — root 부터 직속 부모까지 (자기 자신 제외)
// 사이클 데이터에서도 무한 루프 안 걸리게 visited 가드.
const ancestors = useMemo(() => {
const list: Department[] = [];
if (!draft.parent_dept_code) return list;
const visited = new Set<string>();
if (draft.dept_code) visited.add(draft.dept_code);
let cur: string | null = draft.parent_dept_code;
while (cur && !visited.has(cur)) {
visited.add(cur);
const p = departments.find((d) => d.dept_code === cur);
if (!p) break;
list.unshift(p);
cur = p.parent_dept_code ?? null;
}
return list;
}, [draft.parent_dept_code, draft.dept_code, departments]);
// ── 회사 목록 로드 (SUPER_ADMIN 은 전체, 그 외엔 본인 회사) ──
useEffect(() => {
(async () => {
try {
const list = await getCompanyList();
setCompanies(list);
const userCompany = (user as any)?.company_code;
if (userCompany && userCompany !== "*") {
setSelectedCompanyCode(userCompany);
} else if (list.length > 0) {
setSelectedCompanyCode(list[0].company_code);
}
} catch (err) {
console.error("회사 목록 로드 실패", err);
}
})();
}, [user]);
// ── 부서 목록 로드 ───────────────────────────────────
const loadDepartments = useCallback(async () => {
if (!selectedCompanyCode) return;
setIsTreeLoading(true);
try {
const res = await departmentAPI.getDepartments(selectedCompanyCode, {
includeDeleted: showDeleted,
baseDate: periodMode === "date" ? baseDate : undefined,
});
if (res.success && (res as any).data) {
setDepartments((res as any).data);
} else {
setDepartments([]);
}
} finally {
setIsTreeLoading(false);
}
}, [selectedCompanyCode, showDeleted, periodMode, baseDate]);
useEffect(() => {
loadDepartments();
}, [loadDepartments]);
// ── 부서원 로드 ──────────────────────────────────────
useEffect(() => {
if (activeTab !== "members" || !selectedCode || isNewMode) {
setMembers([]);
return;
}
let cancelled = false;
(async () => {
const res = await departmentAPI.getDepartmentMembers(selectedCode);
if (cancelled) return;
if (res.success && (res as any).data) setMembers((res as any).data);
})();
return () => {
cancelled = true;
};
}, [activeTab, selectedCode, isNewMode]);
// ── 트리 구성 ────────────────────────────────────────
const filteredDepts = useMemo(() => {
if (!searchKeyword.trim()) return departments;
const kw = searchKeyword.toLowerCase();
// 1차: 직접 매칭된 부서들
const directMatches = new Set(
departments
.filter((d) => d.dept_name?.toLowerCase().includes(kw) || d.dept_code?.toLowerCase().includes(kw))
.map((d) => d.dept_code),
);
// 2차: 매칭된 노드의 모든 조상도 포함 (트리 구조 유지)
const visible = new Set<string>(directMatches);
const byCode = new Map(departments.map((d) => [d.dept_code, d]));
for (const code of directMatches) {
let cur: string | null | undefined = byCode.get(code)?.parent_dept_code ?? null;
const visited = new Set<string>([code]);
while (cur && !visited.has(cur)) {
visited.add(cur);
visible.add(cur);
cur = byCode.get(cur)?.parent_dept_code ?? null;
}
}
return departments.filter((d) => visible.has(d.dept_code));
}, [departments, searchKeyword]);
const childrenOf = useCallback(
(parent: string | null) =>
filteredDepts
.filter((d) => (d.parent_dept_code ?? null) === parent)
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0) || (a.dept_name || "").localeCompare(b.dept_name || "")),
[filteredDepts],
);
const expandAll = () => {
setExpandedSet(new Set(departments.map((d) => d.dept_code)));
};
const collapseAll = () => setExpandedSet(new Set());
const toggleExpand = (code: string) => {
setExpandedSet((prev) => {
const next = new Set(prev);
next.has(code) ? next.delete(code) : next.add(code);
return next;
});
};
// ── 선택 시 상세정보 채우기 (dept_info 스키마 1:1) ────
const handleSelectDepartment = (dept: Department) => {
setSelectedCode(dept.dept_code);
setIsNewMode(false);
const d = dept as any;
const loaded: DeptDetailDraft = {
...emptyDraft(selectedCompanyCode),
dept_code: dept.dept_code,
parent_dept_code: dept.parent_dept_code ?? null,
dept_name: dept.dept_name,
location: d.location ?? "",
company_code: dept.company_code ?? selectedCompanyCode,
short_name: dept.short_name ?? "",
dept_type: dept.dept_type ?? "dept",
org_system: dept.org_system ?? "",
approval_manager: dept.approval_manager ?? "",
dept_manager: dept.dept_manager ?? "",
zipcode: dept.zipcode ?? "",
address1: dept.address1 ?? "",
address2: dept.address2 ?? "",
start_date: (dept.start_date ?? "").slice(0, 10),
end_date: (dept.end_date ?? "").slice(0, 10),
sort_order: dept.sort_order ?? 10,
status: (dept.status as "active" | "inactive") ?? "active",
approval_managers: (() => {
const arr = ((dept as any).approval_managers || []).map((m: any) => m.user_id).filter(Boolean);
// 신규 매핑이 비어있고 옛날 단일 컬럼에 값 있으면 seed (PR #19 이전 데이터 호환)
if (arr.length === 0 && dept.approval_manager) return [dept.approval_manager];
return arr;
})(),
dept_managers: (() => {
const arr = ((dept as any).dept_managers || []).map((m: any) => m.user_id).filter(Boolean);
if (arr.length === 0 && dept.dept_manager) return [dept.dept_manager];
return arr;
})(),
org_leaders: ((dept as any).org_leaders || []).map((m: any) => m.user_id).filter(Boolean),
};
setDraft(loaded);
setOriginalDraft(loaded);
};
const handleAddNew = (parentCode: string | null = null) => {
setSelectedCode(null);
setIsNewMode(true);
setActiveTab("info");
setDraft({ ...emptyDraft(selectedCompanyCode), parent_dept_code: parentCode });
setOriginalDraft(null);
};
const handleClearDetail = () => {
setSelectedCode(null);
setIsNewMode(false);
setDraft(emptyDraft(selectedCompanyCode));
setOriginalDraft(null);
};
// ── 트리 ⋮ 메뉴 helpers / 핸들러 ───────────────────────
/** dept 객체를 update payload (snake_case) 로 변환 — 일부 필드만 override 가능 */
const toUpdatePayload = useCallback((d: any, overrides: Record<string, any> = {}) => ({
dept_name: d.dept_name,
parent_dept_code: d.parent_dept_code ?? null,
short_name: d.short_name ?? "",
dept_type: d.dept_type ?? "dept",
org_system: d.org_system || null,
approval_manager: d.approval_manager ?? "",
dept_manager: d.dept_manager ?? "",
zipcode: d.zipcode ?? "",
address1: d.address1 ?? "",
address2: d.address2 ?? "",
start_date: d.start_date ? String(d.start_date).slice(0, 10) : null,
end_date: d.end_date ? String(d.end_date).slice(0, 10) : null,
sort_order: d.sort_order ?? 10,
status: d.status ?? "active",
location: d.location ?? "",
// 다중 관리자 보존 (서버 응답 형식 그대로 다시 전달)
approval_managers: (d.approval_managers || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
dept_managers: (d.dept_managers || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
org_leaders: (d.org_leaders || []).map((m: any) => ({ user_id: typeof m === 'string' ? m : m.user_id })),
...overrides,
}), []);
/** 자기 자신 + 모든 자손 dept_code 들 — picker 의 excludeCodes 용 */
const collectAllDescendants = useCallback((rootCode: string): string[] => {
const result = new Set<string>([rootCode]);
const dfs = (code: string) => {
for (const child of departments.filter((d) => d.parent_dept_code === code)) {
if (!result.has(child.dept_code)) {
result.add(child.dept_code);
dfs(child.dept_code);
}
}
};
dfs(rootCode);
return [...result];
}, [departments]);
/** 트리에서 ⋮ → "다른 부서 아래로 이동" 후 picker 에서 새 부모 확정 */
const handleConfirmMoveTo = async (newParentCode: string | null) => {
const src = moveTargetDept;
if (!src) return;
if ((src.parent_dept_code ?? null) === newParentCode) {
setMoveTargetDept(null);
return;
}
try {
const payload = toUpdatePayload(src, { parent_dept_code: newParentCode });
const res = await departmentAPI.updateDepartment(src.dept_code, payload);
if (res.success) {
toast({ title: `"${src.dept_name}" 부서를 이동했습니다` });
await loadDepartments();
if (selectedCode === src.dept_code) {
const refreshed = (res as any).data as Department | undefined;
if (refreshed) handleSelectDepartment(refreshed);
}
} else {
toast({ title: "이동 실패", description: (res as any).error, variant: "destructive" });
}
} catch (err: any) {
toast({ title: "이동 오류", description: err?.message, variant: "destructive" });
} finally {
setMoveTargetDept(null);
}
};
/**
* 형제 사이 정렬 변경 — 4가지 동작.
* "맨 위로 / 한 칸 위 / 한 칸 아래 / 맨 아래로"
*
* 동작: siblings 를 새 순서로 재배치 후 sort_order 를 (i+1)*10 으로 normalize.
* - 매 호출마다 sort_order 가 정상화되어 다음 동작도 정확하게 1칸 이동.
* - sibling 5~20개 가정: N번 update 호출 OK.
*/
const handleMove = async (dept: Department, action: "top" | "up" | "down" | "bottom") => {
const siblings = departments
.filter((d) => (d.parent_dept_code ?? null) === (dept.parent_dept_code ?? null) && !(d as any).deleted_at)
.sort(
(a, b) =>
(a.sort_order || 0) - (b.sort_order || 0) ||
(a.dept_name || "").localeCompare(b.dept_name || ""),
);
const idx = siblings.findIndex((s) => s.dept_code === dept.dept_code);
if (idx === -1 || siblings.length < 2) return;
let newIdx: number;
switch (action) {
case "top": newIdx = 0; break;
case "up": newIdx = Math.max(0, idx - 1); break;
case "down": newIdx = Math.min(siblings.length - 1, idx + 1); break;
case "bottom": newIdx = siblings.length - 1; break;
}
if (newIdx === idx) return;
const reordered = [...siblings];
const [moved] = reordered.splice(idx, 1);
reordered.splice(newIdx, 0, moved);
try {
const results = await Promise.all(
reordered.map((s, i) =>
departmentAPI.updateDepartment(
s.dept_code,
toUpdatePayload(s, { sort_order: (i + 1) * 10 }),
),
),
);
const failed = results.find((r) => !r.success);
if (failed) throw new Error((failed as any).error || "정렬 변경 실패");
await loadDepartments();
} catch (err: any) {
toast({ title: "정렬 변경 실패", description: err?.message, variant: "destructive" });
await loadDepartments(); // 부분 업데이트 가능성 있어 DB 상태로 동기화
}
};
/** 컨텍스트 삭제 확인 */
const handleConfirmDeleteContext = async () => {
const d = contextDeleteDept;
if (!d) return;
try {
const res = await departmentAPI.deleteDepartment(d.dept_code);
if (res.success) {
const softDeleted = (res as any).data?.soft_deleted;
toast({
title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다",
description: softDeleted
? `"${d.dept_name}" 부서가 휴지통으로 이동했습니다.`
: undefined,
});
await loadDepartments();
if (selectedCode === d.dept_code) handleClearDetail();
} else {
toast({ title: "삭제 실패", description: (res as any).error, variant: "destructive" });
}
} finally {
setContextDeleteDept(null);
}
};
// ── 저장 ─────────────────────────────────────────────
const handleSave = async () => {
if (!draft.dept_name.trim()) {
toast({ title: "부서명을 입력해주세요", variant: "destructive" });
return;
}
if (!selectedCompanyCode) {
toast({ title: "회사를 선택해주세요", variant: "destructive" });
return;
}
// 시작일/종료일 정합성 검증
if (draft.start_date && draft.end_date && draft.start_date > draft.end_date) {
toast({
title: "시작일은 종료일보다 빠르거나 같아야 합니다",
variant: "destructive",
});
return;
}
// 기본정보 탭 전체 필드를 payload 로 전달 — dept_info 스키마와 1:1 (V019 정리 후)
const payload = {
dept_name: draft.dept_name,
parent_dept_code: draft.parent_dept_code,
short_name: draft.short_name,
dept_type: draft.dept_type,
org_system: draft.org_system || null,
approval_manager: draft.approval_manager,
dept_manager: draft.dept_manager,
zipcode: draft.zipcode,
address1: draft.address1,
address2: draft.address2,
start_date: draft.start_date || null,
end_date: draft.end_date || null,
sort_order: draft.sort_order,
status: draft.status,
// dept_info 추가 필드 (location 코드만 유지)
location: draft.location,
// 다중 관리자 — backend 가 {user_id} 객체 배열 받음
approval_managers: draft.approval_managers.map((uid) => ({ user_id: uid })),
dept_managers: draft.dept_managers.map((uid) => ({ user_id: uid })),
org_leaders: draft.org_leaders.map((uid) => ({ user_id: uid })),
} as any;
try {
if (isNewMode) {
const res = await departmentAPI.createDepartment(selectedCompanyCode, payload);
if (res.success) {
toast({ title: "부서가 생성되었습니다" });
await loadDepartments();
const created = (res as any).data as Department | undefined;
if (created) handleSelectDepartment(created);
else handleClearDetail();
} else {
toast({ title: "생성 실패", description: (res as any).error, variant: "destructive" });
}
} else if (selectedCode) {
const res = await departmentAPI.updateDepartment(selectedCode, payload);
if (res.success) {
toast({ title: "부서가 수정되었습니다" });
await loadDepartments();
setOriginalDraft(draft);
} else {
toast({ title: "수정 실패", description: (res as any).error, variant: "destructive" });
}
}
} catch (err: any) {
toast({ title: "오류", description: err?.message, variant: "destructive" });
}
};
// ── 삭제 ─────────────────────────────────────────────
const handleDelete = async () => {
const target = pendingDeleteDept;
if (!target) return;
try {
const res = await departmentAPI.deleteDepartment(target.code);
if (res.success) {
const softDeleted = (res as any).data?.soft_deleted;
toast({
title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다",
description: softDeleted
? `"${target.name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
: undefined,
});
await loadDepartments();
handleClearDetail();
} else {
toast({ title: "삭제 실패", description: (res as any).error, variant: "destructive" });
}
} finally {
setDeleteConfirmOpen(false);
setPendingDeleteDept(null);
}
};
// ── 복구 (V1) ────────────────────────────────────────
const handleRestoreDepartment = async (deptCode: string, deptName: string) => {
try {
const response = await departmentAPI.restoreDepartment(deptCode);
if (response.success) {
await loadDepartments();
toast({
title: "부서 복구 완료",
description: `"${deptName}" 부서가 복구되었습니다.`,
variant: "default",
});
} else {
toast({
title: "복구 불가",
description: (response as any).error || "부서 복구에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("부서 복구 실패:", error);
toast({
title: "부서 복구 실패",
description: "복구 중 오류가 발생했습니다.",
variant: "destructive",
});
}
};
// ─────────────────────────────────────────────────────
// 일괄등록 / 일괄업데이트 helpers
// ─────────────────────────────────────────────────────
const BULK_HEADERS_CREATE: Record<string, string> = {
"부서명": "dept_name",
"상위부서코드": "parent_dept_code",
"부서유형": "dept_type",
"약칭": "short_name",
"조직체계": "org_system",
"정렬순서": "sort_order",
"사용여부": "status",
"시작일": "start_date",
"종료일": "end_date",
"결재관리자": "approval_managers",
"부서관리자": "dept_managers",
"조직장": "org_leaders",
};
const BULK_HEADERS_UPDATE_DEPT: Record<string, string> = {
"부서코드": "dept_code",
"부서명": "dept_name",
"상위부서코드": "parent_dept_code",
"부서유형": "dept_type",
"약칭": "short_name",
"조직체계": "org_system",
"정렬순서": "sort_order",
"사용여부": "status",
"시작일": "start_date",
"종료일": "end_date",
};
const BULK_HEADERS_UPDATE_MGR: Record<string, string> = {
"부서코드": "dept_code",
"결재관리자": "approval_managers",
"부서관리자": "dept_managers",
"조직장": "org_leaders",
};
const MANAGER_KEYS = new Set(["approval_managers", "dept_managers", "org_leaders"]);
const currentHeaderMap = () =>
bulkTab === "create"
? BULK_HEADERS_CREATE
: bulkUpdateMode === "department"
? BULK_HEADERS_UPDATE_DEPT
: BULK_HEADERS_UPDATE_MGR;
const currentBulkAction = (): departmentAPI.BulkAction =>
bulkTab === "create"
? "create"
: bulkUpdateMode === "department"
? "update_department"
: "update_manager";
const resetBulkData = useCallback(() => {
setBulkRows([]);
setBulkPreviewRows([]);
setBulkSelected(new Set());
setBulkFileName("");
}, []);
const openBulkModal = () => {
if (!selectedCompanyCode) {
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
return;
}
setBulkTab("create");
setBulkUpdateMode("department");
resetBulkData();
setBulkOpen(true);
};
/** 엑셀 템플릿 다운로드 — action 별 컬럼 다름. 예시 row 1개 포함 */
const downloadBulkTemplate = () => {
const action = currentBulkAction();
const headerMap =
action === "create"
? BULK_HEADERS_CREATE
: action === "update_department"
? BULK_HEADERS_UPDATE_DEPT
: BULK_HEADERS_UPDATE_MGR;
const columns = Object.keys(headerMap);
const example: Record<string, any> = {};
columns.forEach((c) => {
const snake = headerMap[c];
if (snake === "dept_name") example[c] = "경영지원본부";
else if (snake === "dept_code") example[c] = "DEPT_1";
else if (snake === "dept_type") example[c] = "dept";
else if (snake === "status") example[c] = "active";
else if (snake === "sort_order") example[c] = 10;
else if (MANAGER_KEYS.has(snake)) example[c] = action === "update_manager" ? "user001,user002" : "";
else example[c] = "";
});
const ws = XLSX.utils.json_to_sheet([example], { header: columns });
ws["!cols"] = columns.map(() => ({ wch: 16 }));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "부서");
const fileName =
action === "create"
? "부서_일괄등록_템플릿.xlsx"
: action === "update_department"
? "부서정보_일괄업데이트_템플릿.xlsx"
: "부서관리자_일괄업데이트_템플릿.xlsx";
XLSX.writeFile(wb, fileName);
};
/** 업로드된 xlsx → 한글 헤더를 snake_case 로 매핑 + 매니저 필드는 CSV 분해 */
const handleBulkFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBulkFileName(file.name);
try {
const buf = await file.arrayBuffer();
const wb = XLSX.read(buf, { type: "array" });
const ws = wb.Sheets[wb.SheetNames[0]];
const raw = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: "" });
const headerMap = currentHeaderMap();
const rows = raw
.map((row) => {
const out: Record<string, any> = {};
for (const [korean, snake] of Object.entries(headerMap)) {
const v = row[korean];
if (v === undefined || v === null || v === "") continue;
if (MANAGER_KEYS.has(snake)) {
const ids = String(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean);
if (ids.length > 0) out[snake] = ids;
} else if (snake === "sort_order") {
const n = Number(v);
if (!Number.isNaN(n)) out[snake] = n;
} else {
out[snake] = String(v).trim();
}
}
return out;
})
.filter((r) => Object.keys(r).length > 0);
setBulkRows(rows);
setBulkPreviewRows([]);
setBulkSelected(new Set());
toast({ title: `${rows.length}건 로드됨`, description: "[미리보기] 를 눌러 검증하세요." });
} catch (err: any) {
toast({ title: "파일 읽기 실패", description: err.message || String(err), variant: "destructive" });
} finally {
// 동일 파일 재선택 가능하도록
e.target.value = "";
}
};
const handleBulkPreview = async () => {
if (bulkRows.length === 0) return;
setBulkBusy(true);
try {
const res = await departmentAPI.bulkPreviewDepartments(selectedCompanyCode, currentBulkAction(), bulkRows);
if (res.success && (res as any).data) {
const rows: departmentAPI.BulkPreviewRow[] = (res as any).data.rows;
setBulkPreviewRows(rows);
// 기본: ok 인 row 만 선택
setBulkSelected(new Set(rows.filter((r) => r.result === "ok").map((r) => r.row_index)));
} else {
toast({
title: "미리보기 실패",
description: (res as any).error || (res as any).message || "오류",
variant: "destructive",
});
}
} finally {
setBulkBusy(false);
}
};
const handleBulkApply = async () => {
const okSelected = bulkPreviewRows.filter(
(r) => bulkSelected.has(r.row_index) && r.result === "ok",
);
if (okSelected.length === 0) {
toast({ title: "반영할 정상 행이 없습니다", variant: "destructive" });
return;
}
const payload = okSelected.map((r) => {
const { row_index, result, error_detail, ...rest } = r as any;
return rest as Record<string, any>;
});
setBulkBusy(true);
try {
const res =
bulkTab === "create"
? await departmentAPI.bulkCreateDepartments(selectedCompanyCode, payload)
: await departmentAPI.bulkUpdateDepartments(selectedCompanyCode, bulkUpdateMode, payload);
if (res.success) {
const count =
(res as any).data?.inserted ?? (res as any).data?.updated ?? payload.length;
toast({
title: bulkTab === "create" ? "일괄등록 완료" : "일괄업데이트 완료",
description: `${count}건 처리됨`,
});
setBulkOpen(false);
resetBulkData();
await loadDepartments();
} else {
toast({
title: bulkTab === "create" ? "일괄등록 실패" : "일괄업데이트 실패",
description: (res as any).error || (res as any).message || "오류",
variant: "destructive",
});
}
} finally {
setBulkBusy(false);
}
};
const previewColumns = useMemo(() => {
if (bulkTab === "create") {
return [
{ key: "dept_name", label: "부서명" },
{ key: "parent_dept_code", label: "상위부서코드" },
{ key: "dept_type", label: "유형" },
{ key: "sort_order", label: "순서" },
{ key: "approval_managers", label: "결재관리자", manager: true },
{ key: "dept_managers", label: "부서관리자", manager: true },
{ key: "org_leaders", label: "조직장", manager: true },
];
}
if (bulkUpdateMode === "department") {
return [
{ key: "dept_code", label: "부서코드" },
{ key: "dept_name", label: "부서명" },
{ key: "parent_dept_code", label: "상위부서코드" },
{ key: "dept_type", label: "유형" },
{ key: "sort_order", label: "순서" },
];
}
return [
{ key: "dept_code", label: "부서코드" },
{ key: "approval_managers", label: "결재관리자", manager: true },
{ key: "dept_managers", label: "부서관리자", manager: true },
{ key: "org_leaders", label: "조직장", manager: true },
];
}, [bulkTab, bulkUpdateMode]);
const bulkOkCount = bulkPreviewRows.filter((r) => r.result === "ok").length;
const bulkErrCount = bulkPreviewRows.length - bulkOkCount;
const allOkSelected =
bulkOkCount > 0 &&
bulkPreviewRows
.filter((r) => r.result === "ok")
.every((r) => bulkSelected.has(r.row_index));
const isDirty = originalDraft
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
// ─────────────────────────────────────────────────────
// 렌더
// ─────────────────────────────────────────────────────
return (
<div className="flex h-full min-h-0 w-full flex-col bg-background text-sm">
{/* 상단 타이틀 바 */}
<div className="flex items-center justify-between border-b px-5 py-3">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold tracking-tight"></h1>
<Button size="icon" variant="ghost" className="h-7 w-7 text-sky-600">
<Info className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" className="h-7 w-7 text-sky-600">
<Play className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
onClick={openBulkModal}
>
<Upload className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => handleAddNew(null)}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs opacity-60"
disabled
title="변경이력 기능은 준비 중입니다"
>
<History className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Star className="h-4 w-4" />
</Button>
</div>
</div>
{/* 안내 배너 */}
<div className="flex items-center gap-2 border-b bg-sky-50/60 px-5 py-2 text-xs text-sky-800 dark:bg-sky-950/30 dark:text-sky-200">
<Info className="h-3.5 w-3.5 shrink-0" />
<span> () , '부서/팀/임시' .</span>
<Button variant="ghost" size="icon" className="ml-auto h-5 w-5">
<ChevronDown className="h-3 w-3" />
</Button>
</div>
{/* 본문 */}
<div className="flex min-h-0 flex-1">
{/* 좌측 트리 패널 */}
<aside className="flex w-[340px] shrink-0 flex-col border-r">
{/* 기준일 / 회사 / 검색 */}
<div className="space-y-3 border-b p-3">
<div className="flex items-center gap-3">
<Label className="w-[60px] shrink-0 text-xs font-semibold"></Label>
<RadioGroup
value={periodMode}
onValueChange={(v) => setPeriodMode(v as "all" | "date")}
className="flex items-center gap-3"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="all" id="period-all" className="h-3.5 w-3.5" />
<Label htmlFor="period-all" className="text-xs"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="date" id="period-date" className="h-3.5 w-3.5" />
<Label htmlFor="period-date" className="text-xs"></Label>
</div>
</RadioGroup>
<Input
type="date"
value={baseDate}
onChange={(e) => setBaseDate(e.target.value)}
disabled={periodMode !== "date"}
className="h-7 flex-1 text-xs"
/>
</div>
<Select
value={selectedCompanyCode}
onValueChange={(v) => {
setSelectedCompanyCode(v);
setSelectedCode(null);
setIsNewMode(false);
setDraft(emptyDraft(v));
setOriginalDraft(null);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{companies.map((c) => (
<SelectItem key={c.company_code} value={c.company_code}>
{c.company_code}. {c.company_name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="코드/사업장/부서명을 입력하세요."
className="h-8 pl-7 text-xs"
/>
</div>
<div className="flex items-center justify-start gap-0.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={expandedSet.size > 0 ? collapseAll : expandAll}
title={expandedSet.size > 0 ? "전체 접기" : "전체 펼치기"}
>
{expandedSet.size > 0 ? (
<ChevronsDownUp className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7",
showDeleted ? "text-sky-600 hover:text-sky-700" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setShowDeleted((v) => !v)}
title={showDeleted ? "삭제된 부서 숨기기" : "삭제된 부서 보기"}
>
{showDeleted ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</Button>
</div>
</div>
{/* 트리 */}
<div className="flex-1 overflow-auto p-2">
{isTreeLoading ? (
<div className="py-6 text-center text-xs text-muted-foreground"> ...</div>
) : !selectedCompany ? (
<div className="py-6 text-center text-xs text-muted-foreground"> </div>
) : (
<>
{/* 회사/사업장 루트 (1:1 가정으로 단일 표시) — 토글 가능 */}
<button
type="button"
onClick={() => setSiteOpen((v) => !v)}
className="flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-xs font-bold text-sky-700 hover:bg-sky-50 dark:text-sky-300 dark:hover:bg-sky-900/20"
>
{siteOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<Building2 className="h-3.5 w-3.5" />
<span className="truncate">{selectedCompany.company_code}. {selectedCompany.company_name}</span>
</button>
{siteOpen && (
<div className="ml-3">
<DeptTree
items={childrenOf(null)}
allDepts={filteredDepts}
expanded={expandedSet}
selectedCode={selectedCode}
handlers={{
onToggle: toggleExpand,
onSelect: handleSelectDepartment,
onRestore: handleRestoreDepartment,
onAddSub: (parent) => handleAddNew(parent.dept_code),
onMoveTo: (d) => setMoveTargetDept(d),
onMoveTop: (d) => handleMove(d, "top"),
onMoveUp: (d) => handleMove(d, "up"),
onMoveDown: (d) => handleMove(d, "down"),
onMoveBottom: (d) => handleMove(d, "bottom"),
onContextDelete: (d) => setContextDeleteDept(d),
}}
/>
{childrenOf(null).length === 0 && (
<div className="px-2 py-3 text-xs text-muted-foreground"> .</div>
)}
</div>
)}
</>
)}
</div>
</aside>
{/* 우측 상세 패널 */}
<section className="flex min-w-0 flex-1 flex-col">
{/* 상세 헤더 */}
<div className="flex items-center justify-between border-b px-5 py-2.5">
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold">
<span className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-sky-500" />
<span className="shrink-0"></span>
{isNewMode ? (
<span className="ml-1 rounded bg-sky-100 px-1.5 py-0.5 text-xs font-medium text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
</span>
) : selectedCode ? (
<span className="ml-1 flex min-w-0 items-center gap-1 text-foreground">
{ancestors.map((a) => (
<span key={a.dept_code} className="flex shrink-0 items-center gap-1 text-xs font-normal text-muted-foreground">
<span className="max-w-[120px] truncate">{a.dept_name}</span>
<ChevronRight className="h-3 w-3 opacity-60" />
</span>
))}
<span className="truncate">{draft.dept_name || "(이름 없음)"}</span>
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] font-medium text-muted-foreground">
{selectedCode}
</span>
</span>
) : (
<span className="ml-1 text-xs font-normal text-muted-foreground">
</span>
)}
</div>
{(selectedCode || isNewMode) && (
<div className="flex items-center gap-1.5">
<Button
size="sm"
className="h-7 bg-sky-500 text-xs text-white hover:bg-sky-600"
onClick={handleSave}
>
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => {
if (selectedCode) {
setPendingDeleteDept({ code: selectedCode, name: draft.dept_name });
setDeleteConfirmOpen(true);
}
}}
disabled={isNewMode}
>
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => {
if (isDirty && !window.confirm("저장되지 않은 변경사항이 있습니다. 폐기하시겠습니까?")) return;
handleClearDetail();
}}
title="상세 닫기"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
{(selectedCode || isNewMode) ? (
<>
{/* 탭 */}
<div className="flex items-center gap-5 border-b px-5">
<button
className={cn(
"relative py-2.5 text-sm font-medium transition-colors",
activeTab === "info" ? "text-sky-600 dark:text-sky-400" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveTab("info")}
>
{activeTab === "info" && <span className="absolute inset-x-0 -bottom-px h-0.5 bg-sky-500" />}
</button>
<button
className={cn(
"relative py-2.5 text-sm font-medium transition-colors",
activeTab === "members" ? "text-sky-600 dark:text-sky-400" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveTab("members")}
disabled={isNewMode}
>
{activeTab === "members" && <span className="absolute inset-x-0 -bottom-px h-0.5 bg-sky-500" />}
</button>
</div>
{/* 탭 바디 */}
<div
key={isNewMode ? "__new__" : selectedCode ?? "__empty__"}
className="min-h-0 flex-1 animate-in fade-in slide-in-from-bottom-1 overflow-auto p-6 duration-200"
>
{activeTab === "info" ? (
<BasicInfoForm
draft={draft}
setDraft={setDraft}
companyLabel={
selectedCompany ? `${selectedCompany.company_code}. ${selectedCompany.company_name}` : ""
}
/>
) : (
<MembersPanel
members={members}
deptCode={selectedCode!}
companyCode={selectedCompany?.company_code ?? ""}
onChanged={async () => {
if (selectedCode) {
const res = await departmentAPI.getDepartmentMembers(selectedCode);
if (res.success && (res as any).data) setMembers((res as any).data);
}
await loadDepartments();
}}
/>
)}
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center p-8">
<div className="flex flex-col items-center gap-3 text-center">
<Building2 className="h-12 w-12 text-muted-foreground/30" />
<div className="text-sm text-muted-foreground">
<br />
<span className="font-semibold text-foreground">+ </span>
</div>
</div>
</div>
)}
</section>
</div>
{/* 삭제 확인 */}
<Dialog
open={deleteConfirmOpen}
onOpenChange={(o) => {
setDeleteConfirmOpen(o);
if (!o) setPendingDeleteDept(null);
}}
>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<p className="text-sm">
<span className="font-semibold">{pendingDeleteDept?.name ?? draft.dept_name}</span> ?
</p>
<p className="text-xs text-muted-foreground"> . ( '삭제 보기' ) .</p>
<DialogFooter>
<Button variant="outline" onClick={() => { setDeleteConfirmOpen(false); setPendingDeleteDept(null); }}></Button>
<Button variant="destructive" onClick={handleDelete}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 트리 ⋮ 메뉴 — 컨텍스트 삭제 확인 */}
<Dialog open={!!contextDeleteDept} onOpenChange={(o) => !o && setContextDeleteDept(null)}>
<DialogContent className="max-w-[420px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<p className="text-sm">
<span className="font-semibold">{contextDeleteDept?.dept_name}</span> ?
</p>
<p className="text-xs text-muted-foreground"> . ( '삭제 보기' ) .</p>
<DialogFooter>
<Button variant="outline" onClick={() => setContextDeleteDept(null)}></Button>
<Button variant="destructive" onClick={handleConfirmDeleteContext}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 트리 ⋮ 메뉴 — 다른 부서 아래로 이동 picker */}
<DepartmentPicker
companyCode={moveTargetDept?.company_code ?? selectedCompanyCode}
mode="single"
allowRoot
value={moveTargetDept?.parent_dept_code || ""}
open={!!moveTargetDept}
onSelect={(code) =>
handleConfirmMoveTo(typeof code === "string" && code ? code : null)
}
onClose={() => setMoveTargetDept(null)}
excludeCodes={moveTargetDept ? collectAllDescendants(moveTargetDept.dept_code) : []}
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
/>
{/* 일괄등록 / 일괄업데이트 */}
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
<DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
<DialogHeader>
<DialogTitle> / </DialogTitle>
</DialogHeader>
<Tabs
value={bulkTab}
onValueChange={(v) => {
setBulkTab(v as "create" | "update");
resetBulkData();
}}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="mb-2">
<TabsTrigger value="create"></TabsTrigger>
<TabsTrigger value="update"></TabsTrigger>
</TabsList>
<TabsContent value="create" className="m-0 space-y-2">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1 font-semibold"> </p>
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
<li>[ 릿] .</li>
<li> [] [].</li>
<li> (DEPT_n).</li>
<li> user_id (,) . 10/role.</li>
<li> 1000 .</li>
</ul>
</div>
</TabsContent>
<TabsContent value="update" className="m-0 space-y-2">
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
<p className="mb-1 font-semibold"> </p>
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
<li> <code className="rounded bg-background px-1 font-mono">(dept_code)</code> .</li>
<li><b> </b>: /// . .</li>
<li><b> </b>: // . role .</li>
</ul>
</div>
<div className="flex items-center gap-3 px-1">
<Label className="text-xs font-semibold"> </Label>
<RadioGroup
value={bulkUpdateMode}
onValueChange={(v) => {
setBulkUpdateMode(v as "department" | "manager");
resetBulkData();
}}
className="flex items-center gap-4"
>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="department" id="bulk-mode-dept" className="h-3.5 w-3.5" />
<Label htmlFor="bulk-mode-dept" className="cursor-pointer text-xs"> </Label>
</div>
<div className="flex items-center gap-1.5">
<RadioGroupItem value="manager" id="bulk-mode-mgr" className="h-3.5 w-3.5" />
<Label htmlFor="bulk-mode-mgr" className="cursor-pointer text-xs"> </Label>
</div>
</RadioGroup>
</div>
</TabsContent>
{/* 회사 + 파일 선택 (탭 공통) */}
<div className="mt-2 space-y-2 rounded-md border p-3">
<div className="grid grid-cols-[100px_1fr_auto] items-center gap-3">
<Label className="text-xs font-semibold"> </Label>
<div className="text-xs">
<span className="font-mono">{selectedCompanyCode}</span>
{selectedCompany?.company_name && (
<span className="ml-2 text-muted-foreground">{selectedCompany.company_name}</span>
)}
</div>
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={downloadBulkTemplate}>
<FileDown className="h-3.5 w-3.5" />
릿
</Button>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-3">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-2">
<Input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleBulkFile}
className="h-8 cursor-pointer text-xs file:mr-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs"
/>
{bulkFileName && (
<span className="shrink-0 text-xs text-muted-foreground">
{bulkRows.length}
</span>
)}
</div>
</div>
</div>
{/* 미리보기 테이블 */}
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
<div className="flex items-center justify-between bg-muted/40 px-3 py-1.5 text-xs">
<span className="font-semibold">
({bulkSelected.size}/{bulkPreviewRows.length})
</span>
{bulkPreviewRows.length > 0 && (
<span className="text-muted-foreground">
<span className="text-emerald-600 dark:text-emerald-400"> {bulkOkCount}</span>
{" / "}
<span className="text-destructive"> {bulkErrCount}</span>
</span>
)}
</div>
<div className="min-h-[200px] flex-1 overflow-auto">
{bulkPreviewRows.length === 0 ? (
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-xs text-muted-foreground">
<FileDown className="h-6 w-6 opacity-30" />
<p>{bulkRows.length === 0 ? "엑셀 파일을 업로드하세요" : "[미리보기] 버튼을 눌러 검증하세요"}</p>
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 z-10 bg-muted/60">
<tr>
<th className="w-9 px-2 py-1.5">
<Checkbox
checked={allOkSelected}
onCheckedChange={(c) => {
if (c) {
setBulkSelected(
new Set(
bulkPreviewRows.filter((r) => r.result === "ok").map((r) => r.row_index),
),
);
} else {
setBulkSelected(new Set());
}
}}
/>
</th>
{previewColumns.map((c) => (
<th key={c.key} className="px-2 py-1.5 text-left font-semibold">{c.label}</th>
))}
<th className="w-16 px-2 py-1.5 text-left font-semibold"></th>
<th className="px-2 py-1.5 text-left font-semibold"></th>
</tr>
</thead>
<tbody className="divide-y">
{bulkPreviewRows.map((r) => {
const isErr = r.result === "error";
return (
<tr
key={r.row_index}
className={cn("hover:bg-muted/30", isErr && "bg-destructive/5")}
>
<td className="px-2 py-1.5">
<Checkbox
disabled={isErr}
checked={bulkSelected.has(r.row_index)}
onCheckedChange={(c) => {
setBulkSelected((prev) => {
const next = new Set(prev);
if (c) next.add(r.row_index);
else next.delete(r.row_index);
return next;
});
}}
/>
</td>
{previewColumns.map((c) => {
const v = (r as any)[c.key];
const display = (c as any).manager
? Array.isArray(v) && v.length > 0 ? v.join(", ") : "-"
: v != null && v !== "" ? String(v) : "-";
return (
<td key={c.key} className="max-w-[180px] truncate px-2 py-1.5" title={display}>
{display}
</td>
);
})}
<td className="px-2 py-1.5">
{isErr ? (
<Badge variant="destructive" className="gap-1 text-[10px]">
<XCircle className="h-3 w-3" />
</Badge>
) : (
<Badge className="gap-1 border-emerald-500/30 bg-emerald-500/15 text-[10px] text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-300">
<CheckCircle2 className="h-3 w-3" />
</Badge>
)}
</td>
<td
className="max-w-[300px] truncate px-2 py-1.5 text-destructive"
title={r.error_detail || ""}
>
{r.error_detail || ""}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
</Tabs>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setBulkOpen(false)} disabled={bulkBusy}>
</Button>
<Button
variant="outline"
onClick={handleBulkPreview}
disabled={bulkBusy || bulkRows.length === 0}
>
{bulkBusy ? "검증 중..." : "미리보기"}
</Button>
<Button
onClick={handleBulkApply}
disabled={bulkBusy || bulkSelected.size === 0}
>
{bulkBusy ? "처리 중..." : `반영 (${bulkSelected.size}건)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ───────────────────────────────────────────────────────
// 트리 (재귀)
// ───────────────────────────────────────────────────────
type DeptTreeHandlers = {
onToggle: (code: string) => void;
onSelect: (d: Department) => void;
onRestore: (code: string, name: string) => void;
onAddSub: (parent: Department) => void;
onMoveTo: (d: Department) => void;
onMoveTop: (d: Department) => void;
onMoveUp: (d: Department) => void;
onMoveDown: (d: Department) => void;
onMoveBottom: (d: Department) => void;
onContextDelete: (d: Department) => void;
};
function DeptTree({
items,
allDepts,
expanded,
selectedCode,
handlers,
}: {
items: Department[];
allDepts: Department[];
expanded: Set<string>;
selectedCode: string | null;
handlers: DeptTreeHandlers;
}) {
return (
<div>
{items.map((dept) => {
const sub = allDepts.filter((d) => d.parent_dept_code === dept.dept_code);
const hasSub = sub.length > 0;
const isOpen = expanded.has(dept.dept_code);
const isActive = selectedCode === dept.dept_code;
const isDeleted = !!(dept as any).deleted_at;
return (
<div key={dept.dept_code} className="group/node">
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-1.5 py-1 text-xs",
isActive
? "bg-sky-100 font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300"
: "hover:bg-muted",
isDeleted && "text-muted-foreground line-through opacity-60",
)}
onClick={() => !isDeleted && handlers.onSelect(dept)}
>
{hasSub ? (
<button
onClick={(e) => {
e.stopPropagation();
handlers.onToggle(dept.dept_code);
}}
className="flex h-3.5 w-3.5 items-center justify-center"
>
{isOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
</button>
) : (
<span className="inline-block h-3.5 w-3.5" />
)}
{hasSub && isOpen ? (
<FolderOpen className="h-3.5 w-3.5 text-amber-500" />
) : (
<Folder className="h-3.5 w-3.5 text-amber-500" />
)}
<span className="truncate">
{dept.dept_code}. {dept.dept_name}
</span>
{isDeleted ? (
<>
<span className="ml-1 text-[10px] uppercase tracking-wider text-muted-foreground">
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 ml-auto text-primary"
title="복구"
onClick={(e) => {
e.stopPropagation();
handlers.onRestore(dept.dept_code, dept.dept_name);
}}
>
<Undo2 className="h-3 w-3" />
</Button>
</>
) : (
<div className="ml-auto flex shrink-0 items-center gap-1">
{typeof dept.member_count === "number" && dept.member_count > 0 && (
<span className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
<Users className="h-2.5 w-2.5" /> {dept.member_count}
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground/60 transition-colors hover:bg-foreground/10 hover:text-foreground data-[state=open]:bg-foreground/10 data-[state=open]:text-foreground"
title="부서 메뉴"
>
<MoreVertical className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlers.onAddSub(dept);
}}
>
<Plus className="mr-2 h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlers.onMoveTo(dept);
}}
>
<FolderTree className="mr-2 h-3.5 w-3.5" />
...
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlers.onMoveTop(dept);
}}
>
<ArrowUpToLine className="mr-2 h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlers.onMoveUp(dept);
}}
>
<ChevronUp className="mr-2 h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlers.onMoveDown(dept);
}}
>
<ChevronDown className="mr-2 h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handlers.onMoveBottom(dept);
}}
>
<ArrowDownToLine className="mr-2 h-3.5 w-3.5" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
handlers.onContextDelete(dept);
}}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{hasSub && isOpen && (
<div className="ml-4">
<DeptTree
items={sub}
allDepts={allDepts}
expanded={expanded}
selectedCode={selectedCode}
handlers={handlers}
/>
</div>
)}
</div>
);
})}
</div>
);
}
// ───────────────────────────────────────────────────────
// 기본정보 폼
// ───────────────────────────────────────────────────────
function BasicInfoForm({
draft,
setDraft,
companyLabel,
}: {
draft: DeptDetailDraft;
setDraft: React.Dispatch<React.SetStateAction<DeptDetailDraft>>;
companyLabel: string;
}) {
const update = <K extends keyof DeptDetailDraft>(key: K, value: DeptDetailDraft[K]) =>
setDraft((prev) => ({ ...prev, [key]: value }));
return (
<div className="mx-auto max-w-4xl">
<div className="grid grid-cols-[120px_minmax(0,1fr)] gap-x-4 gap-y-0 rounded-md border bg-card">
<Row label="회사">
<div className="py-1 text-sm">{companyLabel || "-"}</div>
</Row>
<Row label="사업장">
<div className="py-1 text-sm">{companyLabel || "-"}</div>
</Row>
<Row label="부서코드">
{draft.dept_code ? (
<span className="font-mono text-sm">{draft.dept_code}</span>
) : (
<span className="text-muted-foreground text-sm"> (DEPT_n)</span>
)}
</Row>
<Row label="부서유형">
<div className="flex flex-wrap items-center gap-2">
<Select value={draft.dept_type} onValueChange={(v) => update("dept_type", v)}>
<SelectTrigger className="h-8 w-[100px] shrink-0 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dept"></SelectItem>
<SelectItem value="team"></SelectItem>
<SelectItem value="temp"></SelectItem>
</SelectContent>
</Select>
<Select value={draft.org_system || "none"} onValueChange={(v) => update("org_system", v === "none" ? "" : v)}>
<SelectTrigger className="h-8 min-w-[140px] flex-1 text-sm">
<SelectValue placeholder="조직체계 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="hr"></SelectItem>
<SelectItem value="sales"></SelectItem>
</SelectContent>
</Select>
</div>
</Row>
<Row label="부서명" required>
<div className="flex items-center gap-1">
<Input
value={draft.dept_name}
onChange={(e) => update("dept_name", e.target.value)}
className="h-8 flex-1 bg-rose-50/40 text-sm dark:bg-rose-950/20"
placeholder="부서명을 입력하세요"
/>
<Button variant="outline" size="icon" className="h-8 w-8" type="button" title="다국어">
<Globe className="h-3.5 w-3.5" />
</Button>
</div>
</Row>
<Row label="부서약칭">
<Input
value={draft.short_name}
onChange={(e) => update("short_name", e.target.value)}
className="h-8 text-sm"
/>
</Row>
<Row label="결재 관리자" hint>
<ManagerChipsField
userIds={draft.approval_managers}
onChange={(ids) => update("approval_managers", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
<Row label="부서 관리자">
<ManagerChipsField
userIds={draft.dept_managers}
onChange={(ids) => update("dept_managers", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
<Row label="조직장" hint>
<ManagerChipsField
userIds={draft.org_leaders}
onChange={(ids) => update("org_leaders", ids)}
companyCode={draft.company_code}
max={10}
/>
</Row>
{/* TODO V2: 부서주소 — 다지점 운영 회사 V2 에서 살릴 수도. 컬럼은 DEPT_INFO.ZIPCODE/ADDRESS1/ADDRESS2 유지 */}
{false && (
<Row label="부서주소">
<div className="space-y-1">
<div className="flex gap-1">
<Input
value={draft.zipcode}
onChange={(e) => update("zipcode", e.target.value)}
className="h-8 w-[100px] text-sm"
/>
<Button variant="outline" size="sm" className="h-8 text-xs" type="button"></Button>
</div>
<Input
value={draft.address1}
onChange={(e) => update("address1", e.target.value)}
className="h-8 text-sm"
/>
<Input
value={draft.address2}
onChange={(e) => update("address2", e.target.value)}
className="h-8 text-sm"
/>
</div>
</Row>
)}
<Row label="사용여부">
<RadioGroup
value={draft.status}
onValueChange={(v) => update("status", v as "active" | "inactive")}
className="flex items-center gap-4"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="active" id="status-active" className="h-3.5 w-3.5" />
<Label htmlFor="status-active" className="text-sm"></Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="inactive" id="status-inactive" className="h-3.5 w-3.5" />
<Label htmlFor="status-inactive" className="text-sm"></Label>
</div>
</RadioGroup>
</Row>
<Row label="시작일">
<div className="grid grid-cols-2 gap-3">
<Input
type="date"
value={draft.start_date}
onChange={(e) => update("start_date", e.target.value)}
className="h-8 text-sm"
/>
<div className="flex items-center gap-2">
<Label className="w-[50px] text-xs"></Label>
<Input
type="date"
value={draft.end_date}
onChange={(e) => update("end_date", e.target.value)}
className="h-8 flex-1 text-sm"
/>
</div>
</div>
</Row>
<Row label="정렬">
<Input
type="number"
value={draft.sort_order}
onChange={(e) => update("sort_order", Number(e.target.value) || 0)}
className="h-8 text-sm"
/>
</Row>
{/* TODO V2: 위치 — 향후 매핑 가능성. 컬럼은 DEPT_INFO.LOCATION 유지 */}
{false && (
<Row label="위치코드">
<Input
value={draft.location}
onChange={(e) => update("location", e.target.value)}
className="h-8 text-sm"
placeholder="위치코드"
/>
</Row>
)}
</div>
</div>
);
}
function Row({
label,
children,
required,
hint,
}: {
label: string;
children: React.ReactNode;
required?: boolean;
hint?: boolean;
}) {
return (
<>
<div className="flex items-center gap-1 border-b bg-muted/30 px-3 py-2.5 text-xs font-medium last:border-b-0">
{hint && <Info className="h-3 w-3 text-sky-500" />}
<span>{label}</span>
{required && <span className="text-destructive">*</span>}
</div>
<div className="flex items-center border-b px-3 py-1.5 last:border-b-0">{children}</div>
</>
);
}
function PickerField({
value,
onChange,
placeholder,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div className="flex items-center gap-1">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-8 flex-1 text-sm"
placeholder={placeholder}
/>
<Button variant="outline" size="icon" className="h-8 w-8" type="button">
<Users className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function ManagerChipsField({
userIds,
onChange,
companyCode,
max,
}: {
userIds: string[];
onChange: (ids: string[]) => void;
companyCode: string;
max: number;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const [resolvedNames, setResolvedNames] = useState<Record<string, string>>({});
useEffect(() => {
const unknown = userIds.filter((id) => !resolvedNames[id]);
if (unknown.length === 0 || !companyCode) return;
let cancelled = false;
(async () => {
const updates: Record<string, string> = {};
for (const id of unknown) {
try {
const res = await departmentAPI.searchUsers(companyCode, id);
if (res.success && Array.isArray((res as any).data)) {
const found = (res as any).data.find((u: any) => u.user_id === id);
if (found) updates[id] = found.user_name || id;
}
} catch { /* ignore */ }
}
if (!cancelled && Object.keys(updates).length > 0) {
setResolvedNames((prev) => ({ ...prev, ...updates }));
}
})();
return () => { cancelled = true; };
}, [userIds, companyCode]);
const handleRemove = (id: string) => onChange(userIds.filter((x) => x !== id));
const handleAdd = (id: string) => {
if (userIds.includes(id)) return;
if (userIds.length >= max) return;
onChange([...userIds, id]);
};
return (
<>
<div className="flex flex-wrap items-center gap-1.5">
{userIds.map((id) => (
<div
key={id}
className="flex items-center gap-1 rounded-md border bg-muted/50 px-2 py-0.5 text-xs"
>
<span className="font-medium">{resolvedNames[id] || id}</span>
<span className="text-muted-foreground text-[10px]">({id})</span>
<button
type="button"
onClick={() => handleRemove(id)}
className="text-muted-foreground hover:text-destructive ml-0.5"
title="제거"
>
<X className="h-3 w-3" />
</button>
</div>
))}
{userIds.length < max && (
<Button
type="button"
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => setPickerOpen(true)}
disabled={!companyCode}
>
<Plus className="h-3 w-3" />
</Button>
)}
{userIds.length >= max && (
<span className="text-muted-foreground text-[10px]"> {max}</span>
)}
</div>
<UserSearchModal
open={pickerOpen}
companyCode={companyCode}
existingMemberIds={new Set(userIds)}
onAdd={async (userId) => {
handleAdd(userId);
}}
onClose={() => setPickerOpen(false)}
/>
</>
);
}
// ───────────────────────────────────────────────────────
// 사용자 검색 모달
// ───────────────────────────────────────────────────────
function UserSearchModal({
open,
companyCode,
existingMemberIds,
onAdd,
onClose,
}: {
open: boolean;
companyCode: string;
existingMemberIds: Set<string>;
onAdd: (userId: string) => Promise<void>;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<Record<string, any>[]>([]);
const [searching, setSearching] = useState(false);
const [addingId, setAddingId] = useState<string | null>(null);
useEffect(() => {
if (!open) {
setQuery("");
setResults([]);
}
}, [open]);
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
setSearching(true);
try {
const res = await departmentAPI.searchUsers(companyCode, query.trim());
if (res.success && (res as any).data) setResults((res as any).data);
else setResults([]);
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query, companyCode]);
const handleAdd = async (userId: string) => {
setAddingId(userId);
try {
await onAdd(userId);
onClose();
} finally {
setAddingId(null);
}
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-[480px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
placeholder="이름 또는 아이디로 검색..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 text-sm"
autoFocus
/>
<div className="max-h-72 overflow-y-auto rounded-md border bg-card">
{searching ? (
<div className="py-8 text-center text-xs text-muted-foreground"> ...</div>
) : results.length === 0 && query.trim() ? (
<div className="py-8 text-center text-xs text-muted-foreground"> </div>
) : results.length === 0 ? (
<div className="py-8 text-center text-xs text-muted-foreground"> </div>
) : (
<div className="divide-y">
{results.map((u) => {
const alreadyMember = existingMemberIds.has(u.user_id);
return (
<div
key={u.user_id}
className={cn(
"flex items-center justify-between px-4 py-2.5",
alreadyMember ? "opacity-50" : "cursor-pointer hover:bg-muted/50",
)}
onClick={() => !alreadyMember && !addingId && handleAdd(u.user_id)}
>
<div>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{u.user_name}</span>
<span className="text-xs text-muted-foreground">({u.user_id})</span>
</div>
<div className="mt-0.5 flex gap-3 text-[11px] text-muted-foreground">
{u.position_name && <span>{u.position_name}</span>}
{u.email && <span>{u.email}</span>}
</div>
</div>
{alreadyMember ? (
<span className="text-[11px] text-muted-foreground"> </span>
) : (
<Button
size="sm"
className="h-6 px-2 text-[11px]"
disabled={addingId === u.user_id}
onClick={(e) => {
e.stopPropagation();
handleAdd(u.user_id);
}}
>
</Button>
)}
</div>
);
})}
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ───────────────────────────────────────────────────────
// 부서원 패널
// ───────────────────────────────────────────────────────
function MembersPanel({
members,
deptCode,
companyCode,
onChanged,
}: {
members: DepartmentMember[];
deptCode: string;
companyCode: string;
onChanged: () => Promise<void>;
}) {
const { toast } = useToast();
const [addModalOpen, setAddModalOpen] = useState(false);
const existingMemberIds = useMemo(() => new Set(members.map((m) => m.user_id)), [members]);
const handleAdd = async (userId: string) => {
const res = await departmentAPI.addDepartmentMember(deptCode, userId);
if (res.success) {
toast({ title: "부서원이 추가되었습니다" });
await onChanged();
} else {
toast({ title: "추가 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
}
};
const handleRemove = async (userId: string, userName: string) => {
if (!window.confirm(`${userName}을(를) 부서에서 제거하시겠습니까?`)) return;
const res = await departmentAPI.removeDepartmentMember(deptCode, userId);
if (res.success) {
toast({ title: "부서원이 제거되었습니다" });
await onChanged();
} else {
toast({ title: "제거 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
}
};
const handleSetPrimary = async (userId: string) => {
const res = await departmentAPI.setPrimaryDepartment(deptCode, userId);
if (res.success) {
toast({ title: "주부서로 설정되었습니다" });
await onChanged();
} else {
toast({ title: "주부서 설정 실패", description: (res as any).error ?? "오류가 발생했습니다", variant: "destructive" });
}
};
return (
<div className="mx-auto max-w-4xl">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {members.length}</div>
<Button size="sm" className="h-7 gap-1.5 text-xs" onClick={() => setAddModalOpen(true)}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="divide-y rounded-md border bg-card">
{members.length === 0 ? (
<div className="py-10 text-center text-xs text-muted-foreground"> .</div>
) : (
members.map((m) => (
<div key={m.user_id} className="flex items-center justify-between px-4 py-2.5">
<div>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{m.user_name}</span>
<span className="text-xs text-muted-foreground">({m.user_id})</span>
{m.is_primary && (
<Badge variant="default" className="h-4 gap-0.5 px-1.5 text-[10px]">
<Star className="h-2.5 w-2.5" />
</Badge>
)}
</div>
<div className="mt-0.5 flex gap-3 text-[11px] text-muted-foreground">
{m.position_name && <span>{m.position_name}</span>}
{m.email && <span>{m.email}</span>}
{m.phone && <span>{m.phone}</span>}
</div>
</div>
<div className="flex items-center gap-1">
{!m.is_primary && (
<Button
size="sm"
variant="outline"
className="h-6 gap-1 px-2 text-[11px]"
onClick={() => handleSetPrimary(m.user_id)}
title="주부서로 설정"
>
<Star className="h-3 w-3" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(m.user_id, m.user_name)}
title="부서원 제거"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))
)}
</div>
<UserSearchModal
open={addModalOpen}
companyCode={companyCode}
existingMemberIds={existingMemberIds}
onAdd={handleAdd}
onClose={() => setAddModalOpen(false)}
/>
</div>
);
}