0e895a90fa
백엔드: - V018 soft-delete (deleted_at 컬럼) + 휴지통/복구 흐름 - V019 미사용 컬럼 cleanup (V1 슬림 스코프) - DepartmentService.updateDepartment 에 parent_dept_code 사이클 가드 (자기 자신/자손을 부모로 지정 시도 차단) - DepartmentController, mapper 갱신 프론트: - 부서관리 페이지(deptMngList) UX 리디자인 - 트리 노드 ⋮ 컨텍스트 메뉴 (하위 추가, 다른 부서 아래로 이동, 정렬 4단계, 삭제) - 헤더 breadcrumb 으로 부서 위치 상시 표시 - 폼의 상위부서 row 제거 (트리 ⋮ 로 진입점 일원화) - 빈 상태 placeholder + X 닫기 동작 - 토글 버튼 토스 스타일 (아이콘 + 툴팁, 일정한 위치) - 부서유형 row 좁은 화면 가로 오버플로 fix - DepartmentPicker 신규 재사용 컴포넌트 (자손 자동 exclude, 사이클 차단) - 회사관리/프로비저닝 폼 개선 (Step1Basic, fields, CompanyTable, AdminPageRenderer) - companyList/[companyCode]/departments 구버전 페이지 삭제 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1504 lines
59 KiB
TypeScript
1504 lines
59 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
ArrowDownToLine,
|
|
ArrowUpToLine,
|
|
Building2,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
ChevronUp,
|
|
ChevronsDownUp,
|
|
ChevronsUpDown,
|
|
Eye,
|
|
EyeOff,
|
|
Folder,
|
|
FolderOpen,
|
|
FolderTree,
|
|
Globe,
|
|
History,
|
|
Info,
|
|
MoreVertical,
|
|
Play,
|
|
Plus,
|
|
Search,
|
|
Star,
|
|
Trash2,
|
|
Undo2,
|
|
Upload,
|
|
Users,
|
|
X,
|
|
} 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 { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
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;
|
|
}
|
|
|
|
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: new Date().toISOString().slice(0, 10),
|
|
end_date: "",
|
|
sort_order: 10,
|
|
});
|
|
|
|
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 [bulkOpen, setBulkOpen] = useState(false);
|
|
const [bulkText, setBulkText] = useState("");
|
|
const [bulkUploading, setBulkUploading] = useState(false);
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
|
|
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
|
|
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 });
|
|
if (res.success && (res as any).data) {
|
|
setDepartments((res as any).data);
|
|
} else {
|
|
setDepartments([]);
|
|
}
|
|
} finally {
|
|
setIsTreeLoading(false);
|
|
}
|
|
}, [selectedCompanyCode, showDeleted]);
|
|
|
|
useEffect(() => {
|
|
loadDepartments();
|
|
}, [loadDepartments]);
|
|
|
|
// ── 부서원 로드 ──────────────────────────────────────
|
|
useEffect(() => {
|
|
if (activeTab !== "members" || !selectedCode || isNewMode) {
|
|
setMembers([]);
|
|
return;
|
|
}
|
|
(async () => {
|
|
const res = await departmentAPI.getDepartmentMembers(selectedCode);
|
|
if (res.success && (res as any).data) setMembers((res as any).data);
|
|
})();
|
|
}, [activeTab, selectedCode, isNewMode]);
|
|
|
|
// ── 트리 구성 ────────────────────────────────────────
|
|
const filteredDepts = useMemo(() => {
|
|
if (!searchKeyword.trim()) return departments;
|
|
const kw = searchKeyword.toLowerCase();
|
|
return departments.filter(
|
|
(d) =>
|
|
d.dept_name?.toLowerCase().includes(kw) ||
|
|
d.dept_code?.toLowerCase().includes(kw),
|
|
);
|
|
}, [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(filteredDepts.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",
|
|
};
|
|
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 ?? "",
|
|
...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" });
|
|
}
|
|
};
|
|
|
|
/** 컨텍스트 삭제 확인 */
|
|
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;
|
|
}
|
|
|
|
// 기본정보 탭 전체 필드를 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,
|
|
} 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 () => {
|
|
if (!selectedCode) return;
|
|
try {
|
|
const res = await departmentAPI.deleteDepartment(selectedCode);
|
|
if (res.success) {
|
|
const softDeleted = (res as any).data?.soft_deleted;
|
|
toast({
|
|
title: softDeleted ? "부서 삭제됨 (복구 가능)" : "부서가 삭제되었습니다",
|
|
description: softDeleted
|
|
? `"${draft.dept_name}" 부서가 휴지통으로 이동했습니다. 상단 '삭제 보기' 토글에서 복구할 수 있습니다.`
|
|
: undefined,
|
|
});
|
|
await loadDepartments();
|
|
handleClearDetail();
|
|
} else {
|
|
toast({ title: "삭제 실패", description: (res as any).error, variant: "destructive" });
|
|
}
|
|
} finally {
|
|
setDeleteConfirmOpen(false);
|
|
}
|
|
};
|
|
|
|
// ── 복구 (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",
|
|
});
|
|
}
|
|
};
|
|
|
|
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={() => {
|
|
if (!selectedCompanyCode) {
|
|
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
|
|
return;
|
|
}
|
|
setBulkText("");
|
|
setBulkOpen(true);
|
|
}}
|
|
>
|
|
<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"
|
|
onClick={() => {
|
|
if (!selectedCode) {
|
|
toast({ title: "부서를 먼저 선택하세요", variant: "destructive" });
|
|
return;
|
|
}
|
|
setHistoryOpen(true);
|
|
}}
|
|
>
|
|
<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">
|
|
{/* TODO V2: 사용기간 필터 — backend 미구현, V1 hidden */}
|
|
{false && (
|
|
<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={setSelectedCompanyCode}>
|
|
<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={() => setDeleteConfirmOpen(true)}
|
|
disabled={isNewMode}
|
|
>
|
|
삭제
|
|
</Button>
|
|
<Button
|
|
size="icon"
|
|
variant="ghost"
|
|
className="h-7 w-7"
|
|
onClick={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} />
|
|
)}
|
|
</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={setDeleteConfirmOpen}>
|
|
<DialogContent className="max-w-[420px]">
|
|
<DialogHeader>
|
|
<DialogTitle>부서 삭제</DialogTitle>
|
|
</DialogHeader>
|
|
<p className="text-sm">
|
|
<span className="font-semibold">{draft.dept_name}</span> 부서를 삭제하시겠습니까?
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">부서원은 보존됩니다. 휴지통(상단 '삭제 보기' 토글)에서 복구할 수 있습니다.</p>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}>취소</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"
|
|
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="max-w-[640px]">
|
|
<DialogHeader>
|
|
<DialogTitle>부서 일괄등록</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
|
<p className="mb-1.5 font-semibold">CSV 형식으로 한 줄에 하나씩 입력하세요</p>
|
|
<p className="text-muted-foreground">
|
|
형식: <code className="rounded bg-background px-1 py-0.5 font-mono">부서코드,부서명,상위부서코드,부서유형(dept|team|temp)</code>
|
|
</p>
|
|
<p className="mt-1 text-muted-foreground">예시: <code className="rounded bg-background px-1 py-0.5 font-mono">D001,경영지원본부,,dept</code></p>
|
|
</div>
|
|
<textarea
|
|
value={bulkText}
|
|
onChange={(e) => setBulkText(e.target.value)}
|
|
placeholder={"D001,경영지원본부,,dept\nD002,인사팀,D001,team"}
|
|
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setBulkOpen(false)}>취소</Button>
|
|
<Button
|
|
disabled={bulkUploading || !bulkText.trim()}
|
|
onClick={async () => {
|
|
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
if (lines.length === 0) return;
|
|
setBulkUploading(true);
|
|
let success = 0;
|
|
let failed = 0;
|
|
for (const line of lines) {
|
|
const cols = line.split(",").map((c) => c.trim());
|
|
const [dept_code, dept_name, parent, dept_type] = cols;
|
|
if (!dept_code || !dept_name) {
|
|
failed++;
|
|
continue;
|
|
}
|
|
try {
|
|
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
|
|
...emptyDraft(selectedCompanyCode),
|
|
dept_code,
|
|
dept_name,
|
|
parent_dept_code: parent || null,
|
|
dept_type: (dept_type || "dept") as any,
|
|
} as any);
|
|
if (res.success) success++;
|
|
else failed++;
|
|
} catch {
|
|
failed++;
|
|
}
|
|
}
|
|
setBulkUploading(false);
|
|
toast({
|
|
title: `일괄등록 완료`,
|
|
description: `성공 ${success}건 / 실패 ${failed}건`,
|
|
variant: failed > 0 ? "destructive" : "default",
|
|
});
|
|
setBulkOpen(false);
|
|
await loadDepartments();
|
|
}}
|
|
>
|
|
{bulkUploading ? "등록 중..." : "등록"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 변경이력 */}
|
|
<Dialog open={historyOpen} onOpenChange={setHistoryOpen}>
|
|
<DialogContent className="max-w-[720px]">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
변경이력 {selectedCode && <span className="text-muted-foreground text-sm font-normal">- {draft.dept_name} ({selectedCode})</span>}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-muted/50 sticky top-0">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-semibold">변경일시</th>
|
|
<th className="px-3 py-2 text-left font-semibold">작업자</th>
|
|
<th className="px-3 py-2 text-left font-semibold">변경 항목</th>
|
|
<th className="px-3 py-2 text-left font-semibold">이전 값 → 신규 값</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
<tr>
|
|
<td colSpan={4} className="px-3 py-12 text-center text-muted-foreground">
|
|
변경이력 데이터를 불러오는 중이거나, 등록된 이력이 없습니다.
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setHistoryOpen(false)}>닫기</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="부서코드">
|
|
<Input
|
|
value={draft.dept_code}
|
|
onChange={(e) => update("dept_code", e.target.value)}
|
|
placeholder="저장 시 자동 부여 (DEPT_n)"
|
|
className="h-8 text-sm"
|
|
readOnly={!!draft.dept_code}
|
|
/>
|
|
</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>
|
|
<PickerField
|
|
value={draft.approval_manager}
|
|
onChange={(v) => update("approval_manager", v)}
|
|
placeholder="사용자 이름을 입력해주세요."
|
|
/>
|
|
</Row>
|
|
<Row label="부서 관리자">
|
|
<PickerField
|
|
value={draft.dept_manager}
|
|
onChange={(v) => update("dept_manager", v)}
|
|
placeholder="사용자 이름을 입력해주세요."
|
|
/>
|
|
</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>
|
|
|
|
{/* TODO V2: 사용기간 (시작일/종료일) — 필터 도입 시 사용. 컬럼은 DEPT_INFO.START_DATE/END_DATE 유지 */}
|
|
{false && (
|
|
<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 MembersPanel({ members }: { members: DepartmentMember[] }) {
|
|
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>
|
|
</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>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|