Merge origin/main into gbpark-node
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m45s
Build & Deploy to K8s / build-and-deploy (push) Successful in 9m45s
부서관리 V1 슬림 스코프 + UX 리디자인, 25개 버그 일괄 수정, admin/부서관리 탭 라벨 fallback, Windows dev HMR 복원 흡수. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,9 @@ interface CompanyTableProps {
|
||||
export function CompanyTable({ companies, isLoading, onEdit, onDelete }: CompanyTableProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 부서 관리 페이지로 이동
|
||||
const handleManageDepartments = (company: Company) => {
|
||||
router.push(`/admin/userMng/companyList/${company.company_code}/departments`);
|
||||
// 부서 관리 페이지로 이동 (legacy deptMngList 가 캐노니컬 페이지)
|
||||
const handleManageDepartments = (_company: Company) => {
|
||||
router.push(`/admin/userMng/deptMngList`);
|
||||
};
|
||||
|
||||
// 디스크 사용량 포맷팅 함수
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
|
||||
import { Plus, ChevronDown, ChevronRight, Users, Trash2, Eye, EyeOff, Undo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,6 +31,9 @@ export function DepartmentStructure({
|
||||
const [expandedDepts, setExpandedDepts] = useState<Set<string>>(new Set());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// V1: soft-delete 된 부서 표시 토글
|
||||
const [showDeleted, setShowDeleted] = useState(false);
|
||||
|
||||
// 부서 추가 모달
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [parentDeptForAdd, setParentDeptForAdd] = useState<string | null>(null);
|
||||
@@ -42,15 +45,15 @@ export function DepartmentStructure({
|
||||
const [deptToDelete, setDeptToDelete] = useState<{ code: string; name: string } | null>(null);
|
||||
const [deleteErrorMessage, setDeleteErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// 부서 목록 로드
|
||||
// 부서 목록 로드 — showDeleted 도 의존성에 포함
|
||||
useEffect(() => {
|
||||
loadDepartments();
|
||||
}, [companyCode, refreshTrigger]);
|
||||
}, [companyCode, refreshTrigger, showDeleted]);
|
||||
|
||||
const loadDepartments = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await departmentAPI.getDepartments(companyCode);
|
||||
const response = await departmentAPI.getDepartments(companyCode, { includeDeleted: showDeleted });
|
||||
if (response.success && (response as any).data) {
|
||||
setDepartments((response as any).data);
|
||||
} else {
|
||||
@@ -65,6 +68,34 @@ export function DepartmentStructure({
|
||||
}
|
||||
};
|
||||
|
||||
// V1: 부서 복구 핸들러 (soft-delete 된 부서 되살리기)
|
||||
const handleRestoreDepartment = async (deptCode: string, deptName: string) => {
|
||||
try {
|
||||
const response = await departmentAPI.restoreDepartment(deptCode);
|
||||
if (response.success) {
|
||||
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 buildTree = (parentCode: string | null): Department[] => {
|
||||
return departments
|
||||
@@ -145,10 +176,13 @@ export function DepartmentStructure({
|
||||
setDeptToDelete(null);
|
||||
loadDepartments();
|
||||
|
||||
// 성공 메시지 Toast로 표시 (부서원 수 포함)
|
||||
// V1 soft-delete: 복구 가능 안내 추가
|
||||
const isSoft = (response as any)?.data?.soft_deleted === true;
|
||||
toast({
|
||||
title: "부서 삭제 완료",
|
||||
description: (response as any).message || "부서가 삭제되었습니다.",
|
||||
title: isSoft ? "부서 삭제됨 (복구 가능)" : "부서 삭제 완료",
|
||||
description: isSoft
|
||||
? `"${deptToDelete.name}" 부서를 휴지통으로 보냈습니다. '삭제 부서 보기' 토글로 복구할 수 있습니다.`
|
||||
: (response as any).message || "부서가 삭제되었습니다.",
|
||||
variant: "default",
|
||||
});
|
||||
} else {
|
||||
@@ -184,17 +218,18 @@ export function DepartmentStructure({
|
||||
const hasChildren = departments.some((d) => d.parent_dept_code === dept.dept_code);
|
||||
const isExpanded = expandedDepts.has(dept.dept_code);
|
||||
const isSelected = selectedDepartment?.dept_code === dept.dept_code;
|
||||
const isDeleted = !!(dept as any).deleted_at;
|
||||
|
||||
return (
|
||||
<div key={dept.dept_code}>
|
||||
{/* 부서 항목 */}
|
||||
{/* 부서 항목 — soft-delete 시 회색+취소선 */}
|
||||
<div
|
||||
className={`hover:bg-muted flex cursor-pointer items-center justify-between rounded-lg p-2 text-sm transition-colors ${
|
||||
isSelected ? "bg-primary/10 text-primary" : ""
|
||||
}`}
|
||||
} ${isDeleted ? "bg-muted/40 text-muted-foreground line-through opacity-60" : ""}`}
|
||||
style={{ marginLeft: `${level * 16}px` }}
|
||||
>
|
||||
<div className="flex flex-1 items-center gap-2" onClick={() => onSelectDepartment(dept)}>
|
||||
<div className="flex flex-1 items-center gap-2" onClick={() => !isDeleted && onSelectDepartment(dept)}>
|
||||
{/* 확장/축소 아이콘 */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
@@ -218,32 +253,54 @@ export function DepartmentStructure({
|
||||
<Users className="h-3 w-3" />
|
||||
<span>{dept.member_count || 0}</span>
|
||||
</div>
|
||||
|
||||
{/* deleted 배지 */}
|
||||
{isDeleted && <span className="text-muted-foreground text-[10px] uppercase tracking-wider">삭제됨</span>}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{/* 액션 버튼 — deleted 면 복구 버튼만, 아니면 추가/삭제 버튼 */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddDepartment(dept.dept_code);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-6 w-6"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
{isDeleted ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-primary h-6 w-6"
|
||||
title="복구"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRestoreDepartment(dept.dept_code, dept.dept_name);
|
||||
}}
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title="하위 부서 추가"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddDepartment(dept.dept_code);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive h-6 w-6"
|
||||
title="삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteDepartmentRequest(dept.dept_code, dept.dept_name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -259,10 +316,23 @@ export function DepartmentStructure({
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">부서 구조</h3>
|
||||
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
최상위 부서 추가
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* V1: soft-delete 부서 표시 토글 */}
|
||||
<Button
|
||||
variant={showDeleted ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="h-9 gap-2 text-sm"
|
||||
onClick={() => setShowDeleted((v) => !v)}
|
||||
title={showDeleted ? "삭제된 부서 숨기기" : "삭제된 부서 보기"}
|
||||
>
|
||||
{showDeleted ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
{showDeleted ? "삭제 부서 숨기기" : "삭제 부서 보기"}
|
||||
</Button>
|
||||
<Button size="sm" className="h-9 gap-2 text-sm" onClick={() => handleAddDepartment(null)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
최상위 부서 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부서 트리 */}
|
||||
@@ -338,7 +408,9 @@ export function DepartmentStructure({
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{deptToDelete?.name}</span> 부서를 삭제하시겠습니까?
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">이 작업은 되돌릴 수 없습니다.</p>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
부서원은 보존됩니다. 휴지통(상단 '삭제 부서 보기' 토글)에서 복구할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
|
||||
@@ -71,8 +71,19 @@ export default function Step1Basic({
|
||||
const r: any = await checkAvailability(payload);
|
||||
if (payload.subdomain === state.subdomain) {
|
||||
const sub = r?.subdomain;
|
||||
// 우선순위: reserved > valid_format > available
|
||||
// 백엔드 isValidSubdomain 이 reserved 도 false 로 잡아내므로 reserved 를 먼저 검사해야
|
||||
// "예약어" 케이스가 "형식 오류" 로 묻히지 않는다.
|
||||
setSubStatus(
|
||||
!sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken",
|
||||
!sub
|
||||
? "idle"
|
||||
: sub.reserved
|
||||
? "reserved"
|
||||
: !sub.valid_format
|
||||
? "invalid"
|
||||
: sub.available
|
||||
? "available"
|
||||
: "taken",
|
||||
);
|
||||
}
|
||||
if (payload.dbPrefix === state.db_prefix) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
|
||||
*/
|
||||
|
||||
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "invalid";
|
||||
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "reserved" | "invalid";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
@@ -228,6 +228,21 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
|
||||
<XCircle size={13} /> 이미 사용 중
|
||||
</span>
|
||||
);
|
||||
if (status === "reserved")
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
fontSize: "0.72rem",
|
||||
color: "var(--v5-red)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<XCircle size={13} /> 예약어 (사용 불가)
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
@@ -247,7 +262,7 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
|
||||
/** TextInput 의 status prop 과 매핑 */
|
||||
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
|
||||
if (a === "available") return "ok";
|
||||
if (a === "taken" || a === "invalid") return "err";
|
||||
if (a === "taken" || a === "reserved" || a === "invalid") return "err";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DepartmentPicker — 부서 선택 재사용 컴포넌트 (V1 신규).
|
||||
*
|
||||
* 다른 화면에서 부서를 선택해야 할 때 사용. 단일 / 다중 선택 모드 지원.
|
||||
*
|
||||
* 사용 예:
|
||||
* <DepartmentPicker
|
||||
* companyCode="INVYONE"
|
||||
* mode="single"
|
||||
* value={parentDeptCode}
|
||||
* open={isOpen}
|
||||
* onSelect={(code) => setParentDeptCode(code as string)}
|
||||
* onClose={() => setIsOpen(false)}
|
||||
* excludeCodes={[currentDeptCode]}
|
||||
* />
|
||||
*
|
||||
* 동작:
|
||||
* - shadcn Dialog 안에 검색박스 + 트리뷰
|
||||
* - 부모 클릭 시 자식 cascade 펼침
|
||||
* - 클라이언트측 검색 (debounce 200ms, 이름/코드 부분일치)
|
||||
* - single: 클릭 즉시 onSelect → close
|
||||
* - multi: 체크박스 + 부모 체크 시 자식 자동 cascade + 확인 버튼으로 onSelect
|
||||
* - excludeCodes 에 포함된 dept 는 disabled
|
||||
* - 사이클 데이터 (잘못된 PARENT_DEPT_CODE) 는 visited Set 으로 차단
|
||||
*
|
||||
* V1 한계: 검색은 클라이언트측 필터. 1000+ 부서는 V2 에서 backend search 도입 예정.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Department } from "@/types/department";
|
||||
import { getDepartments } from "@/lib/api/department";
|
||||
|
||||
export interface DepartmentPickerProps {
|
||||
companyCode: string;
|
||||
mode: "single" | "multi";
|
||||
/** 현재 선택값. single 이면 string, multi 면 string[] */
|
||||
value?: string | string[];
|
||||
open: boolean;
|
||||
onSelect: (code: string | string[]) => void;
|
||||
onClose: () => void;
|
||||
/** 선택 불가로 disable 처리할 dept_code 들 (자기 자신 부모 등록 방지 등) */
|
||||
excludeCodes?: string[];
|
||||
/** soft-delete 된 부서도 보여줄지 (default false) */
|
||||
includeDeleted?: boolean;
|
||||
/** 모달 헤더 타이틀 (default: "부서 선택") */
|
||||
title?: string;
|
||||
/** single 모드에서 "최상위로" (부모 없음) 옵션 표시 (default false) */
|
||||
allowRoot?: boolean;
|
||||
}
|
||||
|
||||
export function DepartmentPicker({
|
||||
companyCode,
|
||||
mode,
|
||||
value,
|
||||
open,
|
||||
onSelect,
|
||||
onClose,
|
||||
excludeCodes,
|
||||
includeDeleted = false,
|
||||
title = "부서 선택",
|
||||
allowRoot = false,
|
||||
}: DepartmentPickerProps) {
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [searchTerm, setSearchTerm] = useState(""); // debounced
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
// value -> selected 동기화
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === "single") {
|
||||
setSelected(new Set(typeof value === "string" && value ? [value] : []));
|
||||
} else {
|
||||
setSelected(new Set(Array.isArray(value) ? value : []));
|
||||
}
|
||||
}, [value, mode, open]);
|
||||
|
||||
// 검색어 debounce 200ms
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearchTerm(searchInput.trim().toLowerCase()), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchInput]);
|
||||
|
||||
// 부서 목록 로드
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
getDepartments(companyCode, { includeDeleted })
|
||||
.then((res: any) => {
|
||||
if (cancelled) return;
|
||||
if (res?.success && Array.isArray(res?.data)) {
|
||||
setDepartments(res.data);
|
||||
} else {
|
||||
setDepartments([]);
|
||||
}
|
||||
})
|
||||
.catch(() => !cancelled && setDepartments([]))
|
||||
.finally(() => !cancelled && setIsLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, companyCode, includeDeleted]);
|
||||
|
||||
// 코드 -> 부서 맵 (검색·자식 조회용)
|
||||
const byCode = useMemo(() => {
|
||||
const m = new Map<string, Department>();
|
||||
for (const d of departments) m.set(d.dept_code, d);
|
||||
return m;
|
||||
}, [departments]);
|
||||
|
||||
// 검색 매칭 (이름·코드 부분일치)
|
||||
const isMatch = (d: Department): boolean => {
|
||||
if (!searchTerm) return true;
|
||||
return (
|
||||
d.dept_name.toLowerCase().includes(searchTerm) ||
|
||||
d.dept_code.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
};
|
||||
|
||||
// 검색 매칭 부서 + 그 조상들도 포함해서 트리에서 visible
|
||||
const visibleCodes = useMemo(() => {
|
||||
if (!searchTerm) return null; // null = 전체 visible
|
||||
const visible = new Set<string>();
|
||||
for (const d of departments) {
|
||||
if (isMatch(d)) {
|
||||
visible.add(d.dept_code);
|
||||
// 조상 visible (부모-부모-...) — 사이클 차단
|
||||
const visited = new Set<string>([d.dept_code]);
|
||||
let parentCode = d.parent_dept_code;
|
||||
while (parentCode && !visited.has(parentCode)) {
|
||||
visited.add(parentCode);
|
||||
visible.add(parentCode);
|
||||
parentCode = byCode.get(parentCode)?.parent_dept_code ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}, [searchTerm, departments, byCode]);
|
||||
|
||||
// 부모 코드 → 자식 정렬 리스트
|
||||
const childrenOf = (parentCode: string | null): Department[] => {
|
||||
return departments
|
||||
.filter((d) => (d.parent_dept_code ?? null) === parentCode)
|
||||
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0));
|
||||
};
|
||||
|
||||
// 부모 + 자식 (재귀) cascade 코드들
|
||||
const collectDescendants = (rootCode: string): string[] => {
|
||||
const result: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const dfs = (code: string) => {
|
||||
if (visited.has(code)) return;
|
||||
visited.add(code);
|
||||
result.push(code);
|
||||
for (const child of childrenOf(code)) {
|
||||
dfs(child.dept_code);
|
||||
}
|
||||
};
|
||||
dfs(rootCode);
|
||||
return result;
|
||||
};
|
||||
|
||||
const toggleExpand = (code: string) => {
|
||||
const next = new Set(expanded);
|
||||
if (next.has(code)) next.delete(code);
|
||||
else next.add(code);
|
||||
setExpanded(next);
|
||||
};
|
||||
|
||||
const isExcluded = (code: string) => Boolean(excludeCodes?.includes(code));
|
||||
const isDeleted = (d: Department) => Boolean((d as any).deleted_at);
|
||||
|
||||
const handleNodeClick = (d: Department) => {
|
||||
if (isExcluded(d.dept_code)) return;
|
||||
if (mode === "single") {
|
||||
onSelect(d.dept_code);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// multi: 자기 + 자손 모두 토글
|
||||
const next = new Set(selected);
|
||||
const codes = collectDescendants(d.dept_code).filter((c) => !isExcluded(c));
|
||||
const allSelected = codes.every((c) => next.has(c));
|
||||
if (allSelected) {
|
||||
for (const c of codes) next.delete(c);
|
||||
} else {
|
||||
for (const c of codes) next.add(c);
|
||||
}
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const handleConfirmMulti = () => {
|
||||
onSelect(Array.from(selected));
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 트리 렌더 (재귀, visited Set 사이클 차단)
|
||||
const renderTree = (parentCode: string | null, level: number, visited: Set<string>): React.ReactNode => {
|
||||
const list = childrenOf(parentCode);
|
||||
return list.map((d) => {
|
||||
if (visited.has(d.dept_code)) return null; // 사이클 차단
|
||||
const nextVisited = new Set(visited);
|
||||
nextVisited.add(d.dept_code);
|
||||
|
||||
const hasChildren = childrenOf(d.dept_code).length > 0;
|
||||
const isOpen = expanded.has(d.dept_code) || (searchTerm.length > 0 && hasChildren);
|
||||
const isSel = selected.has(d.dept_code);
|
||||
const excluded = isExcluded(d.dept_code);
|
||||
const deleted = isDeleted(d);
|
||||
|
||||
// 검색 시: visible 아닌 노드는 숨김
|
||||
if (visibleCodes && !visibleCodes.has(d.dept_code)) return null;
|
||||
|
||||
return (
|
||||
<div key={d.dept_code}>
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded p-1.5 text-sm transition-colors ${
|
||||
excluded
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: "hover:bg-muted cursor-pointer"
|
||||
} ${isSel ? "bg-primary/10 text-primary" : ""} ${deleted ? "text-muted-foreground line-through" : ""}`}
|
||||
style={{ paddingLeft: `${level * 16 + 6}px` }}
|
||||
onClick={() => handleNodeClick(d)}
|
||||
>
|
||||
{/* expand/collapse */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleExpand(d.dept_code);
|
||||
}}
|
||||
className="flex h-4 w-4 items-center justify-center"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-4 w-4" />
|
||||
)}
|
||||
|
||||
{/* multi: 체크 표시 */}
|
||||
{mode === "multi" && (
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded border ${
|
||||
isSel ? "bg-primary border-primary text-primary-foreground" : "border-input"
|
||||
}`}
|
||||
>
|
||||
{isSel && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 부서명 + 코드 */}
|
||||
<div className="flex flex-1 flex-col leading-tight">
|
||||
<span className="font-medium">{d.dept_name}</span>
|
||||
<span className="text-muted-foreground text-[10px] uppercase">{d.dept_code}</span>
|
||||
</div>
|
||||
|
||||
{deleted && (
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">삭제됨</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && isOpen && renderTree(d.dept_code, level + 1, nextVisited)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 루트 — parent_dept_code 가 null/빈문자열인 부서들
|
||||
const rootList = useMemo(() => childrenOf(null), [departments]);
|
||||
const hasAny = rootList.length > 0 || departments.some((d) => !d.parent_dept_code);
|
||||
const noResults = searchTerm.length > 0 && (visibleCodes?.size ?? 0) === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="부서명 또는 코드로 검색"
|
||||
className="pl-8 pr-8"
|
||||
autoFocus
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
onClick={() => setSearchInput("")}
|
||||
className="text-muted-foreground absolute right-2 top-1/2 -translate-y-1/2"
|
||||
title="검색어 지우기"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 트리 */}
|
||||
<div className="bg-card max-h-[50vh] overflow-y-auto rounded border p-2">
|
||||
{mode === "single" && allowRoot && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect("");
|
||||
onClose();
|
||||
}}
|
||||
className="bg-muted/30 hover:bg-muted mb-1 w-full rounded p-1.5 text-left text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
📂 (최상위 — 부모 없음)
|
||||
</button>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">로딩 중...</div>
|
||||
) : !hasAny ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">부서가 없습니다.</div>
|
||||
) : noResults ? (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">검색 결과 없음</div>
|
||||
) : (
|
||||
renderTree(null, 0, new Set())
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
{mode === "multi" && (
|
||||
<Button onClick={handleConfirmMulti} disabled={selected.size === 0}>
|
||||
선택 ({selected.size})
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default DepartmentPicker;
|
||||
@@ -196,11 +196,6 @@ const DYNAMIC_ADMIN_PATTERNS: Array<{
|
||||
getImport: () => import("@/app/(main)/admin/systemMng/dataflow/edit/[diagramId]/page"),
|
||||
extractParams: (m) => ({ diagramId: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/userMng\/companyList\/([^/]+)\/departments$/,
|
||||
getImport: () => import("@/app/(main)/admin/userMng/companyList/[companyCode]/departments/page"),
|
||||
extractParams: (m) => ({ companyCode: m[1] }),
|
||||
},
|
||||
{
|
||||
pattern: /^\/admin\/standards\/([^/]+)\/edit$/,
|
||||
getImport: () => import("@/app/(main)/admin/standards/[webType]/edit/page"),
|
||||
|
||||
@@ -409,7 +409,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
|
||||
if (pathname.startsWith("/admin") && pathname !== "/admin") {
|
||||
store.setMode("admin");
|
||||
store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", admin_url: pathname });
|
||||
// menu API 가 실패하는 환경 (SUPER_ADMIN cross-tenant 등) 에서도 한글 라벨 유지
|
||||
const ADMIN_PATH_LABELS: Record<string, string> = {
|
||||
"/admin/userMng/deptMngList": "부서관리",
|
||||
};
|
||||
const fallbackTitle = ADMIN_PATH_LABELS[pathname] || pathname.split("/").pop() || "관리자";
|
||||
store.openTab({ type: "admin", title: fallbackTitle, admin_url: pathname });
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -902,6 +907,25 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
}
|
||||
}, [activeTab, uiMenus, isMenuActive]);
|
||||
|
||||
// URL 직접 진입 / sessionStorage 복원 시 admin 탭의 영어 path-segment title 을
|
||||
// menu_name_kor (uiMenus 의 tabTitle/label/name) 로 갱신.
|
||||
// menu API 가 실패한 환경 (SUPER_ADMIN cross-tenant) 에서도 동작하도록 hardcoded map 도 같이 검사.
|
||||
useEffect(() => {
|
||||
const ADMIN_PATH_LABELS: Record<string, string> = {
|
||||
"/admin/userMng/deptMngList": "부서관리",
|
||||
};
|
||||
const store = useTabStore.getState();
|
||||
for (const tab of store.admin.tabs) {
|
||||
if (tab.type !== "admin" || !tab.admin_url) continue;
|
||||
const matched = uiMenus.find((m: any) => m.url === tab.admin_url);
|
||||
const koreanTitle: string | undefined =
|
||||
matched?.tabTitle || matched?.label || matched?.name || ADMIN_PATH_LABELS[tab.admin_url];
|
||||
if (koreanTitle && tab.title !== koreanTitle) {
|
||||
store.updateTabTitle(tab.id, koreanTitle);
|
||||
}
|
||||
}
|
||||
}, [uiMenus]);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
|
||||
Reference in New Issue
Block a user