feat: Implement tab state persistence and enhance layout structure
- Added TabProvider to MainLayout for managing tab states. - Introduced tab state caching in ScreenViewPage to restore data on refresh. - Enhanced AppLayout to include TabBar and TabContent for better tab management. - Updated various components to utilize usePersistedState for maintaining UI states across sessions. - Improved user experience by ensuring selected tab and scroll positions are preserved during navigation and refresh.
This commit is contained in:
parent
6d40c3ea1c
commit
72d9e55159
|
|
@ -1,12 +1,15 @@
|
|||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { MenuProvider } from "@/contexts/MenuContext";
|
||||
import { TabProvider } from "@/contexts/TabContext";
|
||||
import { AppLayout } from "@/components/layout/AppLayout";
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<MenuProvider>
|
||||
<AppLayout>{children}</AppLayout>
|
||||
<TabProvider>
|
||||
<AppLayout>{children}</AppLayout>
|
||||
</TabProvider>
|
||||
</MenuProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,16 +29,64 @@ import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; //
|
|||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
||||
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
|
||||
|
||||
function ScreenViewPage() {
|
||||
// 탭 상태 캐시 (F5 새로고침 시 비활성 탭 데이터 복원용)
|
||||
interface TabStateCache {
|
||||
formData: Record<string, unknown>;
|
||||
selectedRowsData: any[];
|
||||
tableSortBy?: string;
|
||||
tableSortOrder: "asc" | "desc";
|
||||
tableColumnOrder?: string[];
|
||||
tableDisplayData: any[];
|
||||
scrollTop?: number;
|
||||
scrollLeft?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const TAB_CACHE_PREFIX = "tab-cache-";
|
||||
const TAB_CACHE_MAX_AGE = 30 * 60 * 1000; // 30분
|
||||
|
||||
function getTabCacheKey(screenId: number, menuObjid?: number): string {
|
||||
return `${TAB_CACHE_PREFIX}${screenId}-${menuObjid || "default"}`;
|
||||
}
|
||||
|
||||
function saveTabCache(key: string, data: TabStateCache) {
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify(data));
|
||||
} catch { /* 용량 초과 무시 */ }
|
||||
}
|
||||
|
||||
function loadTabCache(key: string): TabStateCache | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const cache = JSON.parse(raw) as TabStateCache;
|
||||
if (Date.now() - cache.timestamp > TAB_CACHE_MAX_AGE) {
|
||||
sessionStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
return cache;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function clearTabCache(key: string) {
|
||||
sessionStorage.removeItem(key);
|
||||
}
|
||||
|
||||
interface ScreenViewPageProps {
|
||||
screenIdProp?: number;
|
||||
menuObjidProp?: number;
|
||||
}
|
||||
|
||||
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
||||
// 스케줄 자동 생성 서비스 활성화
|
||||
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const screenId = parseInt(params.screenId as string);
|
||||
const screenId = screenIdProp || parseInt(params.screenId as string);
|
||||
|
||||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||
// props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
|
||||
const menuObjid = menuObjidProp || (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined);
|
||||
|
||||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
||||
const previewCompanyCode = searchParams.get("company_code");
|
||||
|
|
@ -63,16 +111,39 @@ function ScreenViewPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
// F5 캐시 복원 플래그 (복원된 경우 loadMainTableData/initAutoFill 스킵)
|
||||
const cacheRestoredRef = React.useRef(false);
|
||||
const initialCache = React.useMemo(() => {
|
||||
if (!screenId) return null;
|
||||
return loadTabCache(getTabCacheKey(screenId, menuObjid));
|
||||
}, []);
|
||||
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(() => {
|
||||
if (initialCache?.formData && Object.keys(initialCache.formData).length > 0) {
|
||||
cacheRestoredRef.current = true;
|
||||
return initialCache.formData as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 테이블에서 선택된 행 데이터 (버튼 액션에 전달)
|
||||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>([]);
|
||||
const [selectedRowsData, setSelectedRowsData] = useState<any[]>(
|
||||
() => initialCache?.selectedRowsData ?? []
|
||||
);
|
||||
|
||||
// 테이블 정렬 정보 (엑셀 다운로드용)
|
||||
const [tableSortBy, setTableSortBy] = useState<string | undefined>();
|
||||
const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc");
|
||||
const [tableColumnOrder, setTableColumnOrder] = useState<string[] | undefined>();
|
||||
const [tableDisplayData, setTableDisplayData] = useState<any[]>([]); // 화면에 표시된 데이터 (컬럼 순서 포함)
|
||||
const [tableSortBy, setTableSortBy] = useState<string | undefined>(
|
||||
() => initialCache?.tableSortBy
|
||||
);
|
||||
const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">(
|
||||
() => initialCache?.tableSortOrder ?? "asc"
|
||||
);
|
||||
const [tableColumnOrder, setTableColumnOrder] = useState<string[] | undefined>(
|
||||
() => initialCache?.tableColumnOrder
|
||||
);
|
||||
const [tableDisplayData, setTableDisplayData] = useState<any[]>(
|
||||
() => initialCache?.tableDisplayData ?? []
|
||||
);
|
||||
|
||||
// 플로우에서 선택된 데이터 (버튼 액션에 전달)
|
||||
const [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
|
||||
|
|
@ -122,6 +193,139 @@ function ScreenViewPage() {
|
|||
initComponents();
|
||||
}, []);
|
||||
|
||||
// 페이지 스크롤 위치 추적 (비활성 탭 F5 복원용 - 세로+가로)
|
||||
const pageScrollTopRef = React.useRef(0);
|
||||
const pageScrollLeftRef = React.useRef(0);
|
||||
const scrollTargetRef = React.useRef<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el || !screenId) return;
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
let saveTimer: ReturnType<typeof setTimeout>;
|
||||
const scrollKey = `page-scroll-${screenId}-${menuObjid || "default"}`;
|
||||
|
||||
const makeHandler = (target: HTMLElement) => () => {
|
||||
if (target.scrollTop > 0 || target.scrollLeft > 0) {
|
||||
pageScrollTopRef.current = target.scrollTop;
|
||||
pageScrollLeftRef.current = target.scrollLeft;
|
||||
scrollTargetRef.current = target;
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
try { sessionStorage.setItem(scrollKey, JSON.stringify({ top: target.scrollTop, left: target.scrollLeft })); } catch {}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
const selfHandler = makeHandler(el);
|
||||
el.addEventListener("scroll", selfHandler, { passive: true });
|
||||
cleanups.push(() => el.removeEventListener("scroll", selfHandler));
|
||||
|
||||
let parent = el.parentElement;
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent);
|
||||
const ov = style.overflow + style.overflowX + style.overflowY;
|
||||
if (ov.includes("auto") || ov.includes("scroll")) {
|
||||
const h = makeHandler(parent);
|
||||
parent.addEventListener("scroll", h, { passive: true });
|
||||
const pp = parent;
|
||||
cleanups.push(() => pp.removeEventListener("scroll", h));
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return () => { cleanups.forEach(fn => fn()); clearTimeout(saveTimer); };
|
||||
}, [screenId, menuObjid, loading]);
|
||||
|
||||
// 탭 상태를 sessionStorage에 debounce 저장 (F5 새로고침 시 복원용)
|
||||
useEffect(() => {
|
||||
if (!screenId || loading) return;
|
||||
const cacheKey = getTabCacheKey(screenId, menuObjid);
|
||||
const timer = setTimeout(() => {
|
||||
saveTabCache(cacheKey, {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
tableSortBy,
|
||||
tableSortOrder,
|
||||
tableColumnOrder,
|
||||
tableDisplayData,
|
||||
scrollTop: pageScrollTopRef.current,
|
||||
scrollLeft: pageScrollLeftRef.current,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [formData, selectedRowsData, tableSortBy, tableSortOrder, tableColumnOrder, tableDisplayData, screenId, menuObjid, loading]);
|
||||
|
||||
// 페이지 스크롤 위치 복원 (비활성 탭 F5 복원용 - 세로+가로 재시도)
|
||||
const scrollRestoredRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (scrollRestoredRef.current || loading || !layoutReady || !containerRef.current || !screenId) return;
|
||||
const scrollKey = `page-scroll-${screenId}-${menuObjid || "default"}`;
|
||||
const raw = sessionStorage.getItem(scrollKey);
|
||||
|
||||
let scrollTop = 0;
|
||||
let scrollLeft = 0;
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
scrollTop = parsed.top ?? 0;
|
||||
scrollLeft = parsed.left ?? 0;
|
||||
} catch {
|
||||
scrollTop = parseInt(raw, 10) || 0;
|
||||
}
|
||||
}
|
||||
if (!scrollTop && !scrollLeft) {
|
||||
scrollTop = initialCache?.scrollTop ?? 0;
|
||||
}
|
||||
if (scrollTop <= 0 && scrollLeft <= 0) { scrollRestoredRef.current = true; return; }
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 8;
|
||||
const tryRestore = () => {
|
||||
attempt++;
|
||||
if (!containerRef.current) {
|
||||
if (attempt < maxAttempts) setTimeout(tryRestore, 250 * attempt);
|
||||
return;
|
||||
}
|
||||
|
||||
const candidates: HTMLElement[] = [];
|
||||
if (scrollTargetRef.current) candidates.push(scrollTargetRef.current);
|
||||
candidates.push(containerRef.current);
|
||||
let parent = containerRef.current.parentElement;
|
||||
while (parent && candidates.length < 6) {
|
||||
const style = getComputedStyle(parent);
|
||||
const ov = style.overflow + style.overflowX + style.overflowY;
|
||||
if (ov.includes("auto") || ov.includes("scroll")) {
|
||||
candidates.push(parent);
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
let restored = false;
|
||||
for (const el of candidates) {
|
||||
const canScrollV = el.scrollHeight > el.clientHeight + 1;
|
||||
const canScrollH = el.scrollWidth > el.clientWidth + 1;
|
||||
if (canScrollV || canScrollH) {
|
||||
if (scrollTop > 0 && canScrollV) el.scrollTop = scrollTop;
|
||||
if (scrollLeft > 0 && canScrollH) el.scrollLeft = scrollLeft;
|
||||
const topOk = scrollTop <= 0 || Math.abs(el.scrollTop - scrollTop) < 5;
|
||||
const leftOk = scrollLeft <= 0 || Math.abs(el.scrollLeft - scrollLeft) < 5;
|
||||
if (topOk && leftOk) { restored = true; break; }
|
||||
}
|
||||
}
|
||||
if (restored) {
|
||||
scrollRestoredRef.current = true;
|
||||
} else if (attempt < maxAttempts) {
|
||||
setTimeout(tryRestore, 250 * attempt);
|
||||
} else {
|
||||
scrollRestoredRef.current = true;
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(tryRestore, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [loading, layoutReady, screenId, menuObjid, initialCache]);
|
||||
|
||||
// 편집 모달 이벤트 리스너 등록
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
|
|
@ -384,6 +588,8 @@ function ScreenViewPage() {
|
|||
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
||||
useEffect(() => {
|
||||
if (cacheRestoredRef.current) return;
|
||||
|
||||
const loadMainTableData = async () => {
|
||||
if (!screen || !layout || !layout.components || !companyCode) {
|
||||
return;
|
||||
|
|
@ -466,6 +672,8 @@ function ScreenViewPage() {
|
|||
|
||||
// 🆕 개별 autoFill 처리 (메인 테이블과 다른 테이블에서 조회하는 경우)
|
||||
useEffect(() => {
|
||||
if (cacheRestoredRef.current) return;
|
||||
|
||||
const initAutoFill = async () => {
|
||||
if (!layout || !layout.components || !user) {
|
||||
return;
|
||||
|
|
@ -567,13 +775,13 @@ function ScreenViewPage() {
|
|||
}
|
||||
}, [conditionalFieldValues, layout?.components]);
|
||||
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
|
||||
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일)
|
||||
// display:none 상태(비활성 탭)에서는 offsetWidth가 0이므로 건너뛰고,
|
||||
// ResizeObserver로 탭이 보이게 될 때 자동 재계산
|
||||
useEffect(() => {
|
||||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
||||
if (isMobile) {
|
||||
setScale(1);
|
||||
setLayoutReady(true); // 모바일에서도 레이아웃 준비 완료 표시
|
||||
setLayoutReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -582,57 +790,52 @@ function ScreenViewPage() {
|
|||
const designWidth = layout?.screenResolution?.width || 1200;
|
||||
const designHeight = layout?.screenResolution?.height || 800;
|
||||
|
||||
// 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용)
|
||||
let containerWidth: number;
|
||||
let containerHeight: number;
|
||||
let cw: number;
|
||||
let ch: number;
|
||||
|
||||
if (isPreviewMode) {
|
||||
// iframe에서는 window 크기를 직접 사용
|
||||
containerWidth = window.innerWidth;
|
||||
containerHeight = window.innerHeight;
|
||||
cw = window.innerWidth;
|
||||
ch = window.innerHeight;
|
||||
} else {
|
||||
containerWidth = containerRef.current.offsetWidth;
|
||||
containerHeight = containerRef.current.offsetHeight;
|
||||
cw = containerRef.current.offsetWidth;
|
||||
ch = containerRef.current.offsetHeight;
|
||||
}
|
||||
|
||||
// 비활성 탭(display:none)이면 offsetWidth=0 → 스케일 계산 건너뛰기
|
||||
if (cw === 0) return;
|
||||
|
||||
let newScale: number;
|
||||
|
||||
if (isPreviewMode) {
|
||||
// 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이)
|
||||
const scaleX = containerWidth / designWidth;
|
||||
const scaleY = containerHeight / designHeight;
|
||||
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
|
||||
const scaleX = cw / designWidth;
|
||||
const scaleY = ch / designHeight;
|
||||
newScale = Math.min(scaleX, scaleY, 1);
|
||||
} else {
|
||||
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
|
||||
const MARGIN_X = 32;
|
||||
const availableWidth = containerWidth - MARGIN_X;
|
||||
const availableWidth = cw - MARGIN_X;
|
||||
newScale = availableWidth / designWidth;
|
||||
}
|
||||
|
||||
// console.log("📐 스케일 계산:", {
|
||||
// containerWidth,
|
||||
// containerHeight,
|
||||
// designWidth,
|
||||
// designHeight,
|
||||
// finalScale: newScale,
|
||||
// isPreviewMode,
|
||||
// });
|
||||
|
||||
setScale(newScale);
|
||||
// 컨테이너 너비 업데이트
|
||||
setContainerWidth(containerWidth);
|
||||
|
||||
// 스케일 계산 완료 후 레이아웃 준비 완료 표시
|
||||
setContainerWidth(cw);
|
||||
setLayoutReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 측정 (한 번만 실행)
|
||||
const timer = setTimeout(updateScale, 100);
|
||||
|
||||
// resize 이벤트는 감지하지 않음 - 브라우저 배율 조정 시 메뉴와 화면이 함께 변경되도록
|
||||
// ResizeObserver: 탭이 display:none → block으로 전환되면 자동 재계산
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
if (containerRef.current) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updateScale();
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, [layout, isMobile, isPreviewMode]);
|
||||
|
||||
|
|
@ -1282,7 +1485,20 @@ function ScreenViewPage() {
|
|||
);
|
||||
}
|
||||
|
||||
// 실제 컴포넌트를 Provider로 감싸기
|
||||
// 탭 시스템에서 사용할 수 있는 임베드 가능한 컴포넌트
|
||||
export function ScreenViewPageEmbeddable({ screenId, menuObjid }: { screenId: number; menuObjid?: number }) {
|
||||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenContextProvider>
|
||||
<SplitPanelProvider>
|
||||
<ScreenViewPage screenIdProp={screenId} menuObjidProp={menuObjid} />
|
||||
</SplitPanelProvider>
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// URL 라우트에서 사용하는 기본 래퍼
|
||||
function ScreenViewPageWrapper() {
|
||||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import { MenuItem } from "@/lib/api/menu";
|
|||
import { menuScreenApi } from "@/lib/api/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
import { useTab } from "@/contexts/TabContext";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { TabContent } from "./TabContent";
|
||||
import { ProfileModal } from "./ProfileModal";
|
||||
import { Logo } from "./Logo";
|
||||
import { SideMenu } from "./SideMenu";
|
||||
|
|
@ -71,7 +74,7 @@ interface ExtendedUserInfo {
|
|||
}
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// 메뉴 아이콘 매핑 함수
|
||||
|
|
@ -212,12 +215,27 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
|||
};
|
||||
};
|
||||
|
||||
// 메뉴 트리에서 특정 메뉴의 부모 이름을 찾는 헬퍼 함수
|
||||
function findParentMenuName(uiMenus: any[], targetMenuId: string): string | undefined {
|
||||
for (const menu of uiMenus) {
|
||||
if (menu.children) {
|
||||
const found = menu.children.find((child: any) => String(child.id) === String(targetMenuId));
|
||||
if (found) return menu.name;
|
||||
|
||||
const deepResult = findParentMenuName(menu.children, targetMenuId);
|
||||
if (deepResult) return deepResult;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, logout, refreshUserData, switchCompany } = useAuth();
|
||||
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
||||
const { openTab, tabs, activeTabId } = useTab();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
|
@ -249,6 +267,23 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
fetchCurrentCompanyName();
|
||||
}, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]);
|
||||
|
||||
// 탭 전환 시 URL 동기화
|
||||
useEffect(() => {
|
||||
if (!activeTabId || tabs.length === 0) return;
|
||||
const activeTab = tabs.find(t => t.id === activeTabId);
|
||||
if (!activeTab) return;
|
||||
|
||||
const currentScreenMatch = pathname.match(/^\/screens\/(\d+)/);
|
||||
const currentScreenId = currentScreenMatch ? parseInt(currentScreenMatch[1]) : null;
|
||||
|
||||
if (currentScreenId !== activeTab.screenId) {
|
||||
const urlParams = new URLSearchParams();
|
||||
if (activeTab.isAdminMode) urlParams.set("mode", "admin");
|
||||
if (activeTab.menuObjid) urlParams.set("menuObjid", activeTab.menuObjid.toString());
|
||||
router.replace(`/screens/${activeTab.screenId}?${urlParams.toString()}`);
|
||||
}
|
||||
}, [activeTabId]);
|
||||
|
||||
// 화면 크기 감지 및 사이드바 초기 상태 설정
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
|
|
@ -320,8 +355,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
setExpandedMenus(newExpanded);
|
||||
};
|
||||
|
||||
// 메뉴 클릭 핸들러
|
||||
const handleMenuClick = async (menu: any) => {
|
||||
// 메뉴 클릭 핸들러 (탭 시스템 통합)
|
||||
// parentMenuName: 사이드바에서 자식 메뉴 클릭 시 부모 이름 직접 전달
|
||||
const handleMenuClick = async (menu: any, parentMenuName?: string) => {
|
||||
if (menu.hasChildren) {
|
||||
toggleMenu(menu.id);
|
||||
} else {
|
||||
|
|
@ -337,19 +373,33 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||
|
||||
if (assignedScreens.length > 0) {
|
||||
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
||||
const firstScreen = assignedScreens[0];
|
||||
|
||||
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
|
||||
const params = new URLSearchParams();
|
||||
if (isAdminMode) {
|
||||
params.set("mode", "admin");
|
||||
}
|
||||
params.set("menuObjid", menuObjid.toString());
|
||||
|
||||
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
|
||||
// 상위 카테고리 이름: 직접 전달받은 값 우선, 없으면 트리에서 탐색
|
||||
const resolvedParentName = parentMenuName || findParentMenuName(
|
||||
convertMenuToUI(currentMenus, user as ExtendedUserInfo),
|
||||
String(menu.id),
|
||||
);
|
||||
|
||||
// 탭으로 열기
|
||||
openTab({
|
||||
screenId: firstScreen.screenId,
|
||||
menuObjid,
|
||||
screenName: menuName,
|
||||
menuName: menuName,
|
||||
parentMenuName: resolvedParentName,
|
||||
isAdminMode,
|
||||
});
|
||||
|
||||
// URL도 동기화
|
||||
const urlParams = new URLSearchParams();
|
||||
if (isAdminMode) {
|
||||
urlParams.set("mode", "admin");
|
||||
}
|
||||
urlParams.set("menuObjid", menuObjid.toString());
|
||||
const screenPath = `/screens/${firstScreen.screenId}?${urlParams.toString()}`;
|
||||
router.replace(screenPath);
|
||||
|
||||
router.push(screenPath);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
|
@ -366,7 +416,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
setSidebarOpen(false);
|
||||
}
|
||||
} else {
|
||||
// URL도 없고 할당된 화면도 없으면 경고 메시지
|
||||
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
||||
}
|
||||
}
|
||||
|
|
@ -428,12 +477,23 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
{menu.children?.map((child: any) => (
|
||||
<div
|
||||
key={child.id}
|
||||
draggable={!child.hasChildren}
|
||||
onDragStart={(e) => {
|
||||
if (child.hasChildren) return;
|
||||
e.dataTransfer.setData("application/tab-menu", JSON.stringify({
|
||||
menuId: child.id,
|
||||
menuObjid: child.objid || child.id,
|
||||
menuName: child.label || child.name,
|
||||
parentMenuName: menu.name,
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
|
||||
pathname === child.url
|
||||
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||
: "text-slate-600 hover:bg-slate-50 hover:text-slate-900"
|
||||
}`}
|
||||
onClick={() => handleMenuClick(child)}
|
||||
onClick={() => handleMenuClick(child, menu.name)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{child.icon}
|
||||
|
|
@ -695,9 +755,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
||||
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||
{children}
|
||||
{/* 가운데 컨텐츠 영역 */}
|
||||
<main className={`flex min-w-0 flex-1 flex-col bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||
{/* 탭 바 (데스크톱에서만 항상 표시) */}
|
||||
{!isMobile && <TabBar />}
|
||||
|
||||
{/* 콘텐츠 영역 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TabContent />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,586 @@
|
|||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { X, ChevronDown, RotateCw } from "lucide-react";
|
||||
import { useTab, TabItem } from "@/contexts/TabContext";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { menuScreenApi } from "@/lib/api/screen";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const TAB_MIN_WIDTH = 190;
|
||||
const TAB_MAX_WIDTH = 190;
|
||||
const TAB_GAP = 2;
|
||||
const OVERFLOW_BUTTON_WIDTH = 48;
|
||||
|
||||
function getTabDisplayName(tab: TabItem): string {
|
||||
if (tab.parentMenuName && tab.screenName) {
|
||||
return `${tab.parentMenuName} - ${tab.screenName}`;
|
||||
}
|
||||
return tab.screenName || `화면 ${tab.screenId}`;
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
const { tabs, activeTabId, switchTab, closeTab, closeOtherTabs, closeAllTabs, closeTabsToTheLeft, closeTabsToTheRight, updateTabOrder, openTab, refreshTab } = useTab();
|
||||
const router = useRouter();
|
||||
const tabsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(tabs.length);
|
||||
const [tabWidth, setTabWidth] = useState(TAB_MAX_WIDTH);
|
||||
const [contextMenu, setContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null);
|
||||
const dragStateRef = useRef<{
|
||||
tabId: string;
|
||||
startX: number;
|
||||
startIndex: number;
|
||||
currentIndex: number;
|
||||
order: string[]; // 드래그 중 논리적 순서 (tabId 배열)
|
||||
isDragging: boolean;
|
||||
} | null>(null);
|
||||
const menuDragInsertRef = useRef<number>(-1);
|
||||
const pendingDropRef = useRef<{ timerId: ReturnType<typeof setTimeout>; finalize: () => void } | null>(null);
|
||||
const tabsRef = useRef(tabs);
|
||||
tabsRef.current = tabs;
|
||||
const newDropTabIdRef = useRef<string | null>(null);
|
||||
|
||||
const calculateVisibleTabs = useCallback(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) {
|
||||
setVisibleCount(tabs.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = container.offsetWidth;
|
||||
const tabSlotWidth = TAB_MIN_WIDTH + TAB_GAP;
|
||||
|
||||
// 오버플로우 없이 전부 들어가는지 확인
|
||||
const totalIfAll = tabs.length * tabSlotWidth;
|
||||
if (totalIfAll <= containerWidth) {
|
||||
// 전부 표시, 남는 공간을 균등 분배
|
||||
setVisibleCount(tabs.length);
|
||||
const w = Math.floor(containerWidth / tabs.length) - TAB_GAP;
|
||||
setTabWidth(Math.min(TAB_MAX_WIDTH, Math.max(TAB_MIN_WIDTH, w)));
|
||||
return;
|
||||
}
|
||||
|
||||
// 오버플로우 필요: +N 버튼 공간 확보 후 몇 개 들어가는지 계산
|
||||
const availableForTabs = containerWidth - OVERFLOW_BUTTON_WIDTH;
|
||||
const count = Math.max(1, Math.floor(availableForTabs / tabSlotWidth));
|
||||
|
||||
setVisibleCount(count);
|
||||
const w = Math.floor(availableForTabs / count) - TAB_GAP;
|
||||
setTabWidth(Math.min(TAB_MAX_WIDTH, Math.max(TAB_MIN_WIDTH, w)));
|
||||
}, [tabs.length]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateVisibleTabs();
|
||||
const resizeObserver = new ResizeObserver(calculateVisibleTabs);
|
||||
if (tabsContainerRef.current) {
|
||||
resizeObserver.observe(tabsContainerRef.current);
|
||||
}
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [calculateVisibleTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = () => setContextMenu(null);
|
||||
if (contextMenu) {
|
||||
document.addEventListener("click", handleClick);
|
||||
return () => document.removeEventListener("click", handleClick);
|
||||
}
|
||||
}, [contextMenu]);
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return (
|
||||
<div className="relative flex h-9 min-w-0 items-end bg-slate-50 px-1 overflow-hidden shadow-[inset_0_-1px_0_0_rgb(226,232,240)]" />
|
||||
);
|
||||
}
|
||||
|
||||
const visibleTabs = tabs.slice(0, visibleCount);
|
||||
const overflowTabs = tabs.slice(visibleCount);
|
||||
const hasOverflow = overflowTabs.length > 0;
|
||||
|
||||
// 활성 탭이 오버플로우에 있는 경우, 보이는 탭의 마지막을 교체
|
||||
const activeInOverflow = overflowTabs.find(t => t.id === activeTabId);
|
||||
let displayVisibleTabs = [...visibleTabs];
|
||||
let displayOverflowTabs = [...overflowTabs];
|
||||
|
||||
if (activeInOverflow && visibleTabs.length > 0) {
|
||||
const lastVisible = displayVisibleTabs[displayVisibleTabs.length - 1];
|
||||
displayVisibleTabs[displayVisibleTabs.length - 1] = activeInOverflow;
|
||||
displayOverflowTabs = displayOverflowTabs.filter(t => t.id !== activeInOverflow.id);
|
||||
displayOverflowTabs.unshift(lastVisible);
|
||||
}
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ tabId, x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const applyDragTransforms = (order: string[], dragId: string, dragDx: number) => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
const slotWidth = tabWidth + TAB_GAP;
|
||||
const originalOrder = tabsRef.current.map(t => t.id);
|
||||
|
||||
order.forEach((id, newIdx) => {
|
||||
const el = container.querySelector(`[data-tab-id="${id}"]`) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
const origIdx = originalOrder.indexOf(id);
|
||||
if (origIdx === -1) return;
|
||||
|
||||
if (id === dragId) {
|
||||
el.style.transform = `translateX(${dragDx}px)`;
|
||||
el.style.zIndex = "50";
|
||||
el.style.opacity = "0.85";
|
||||
} else {
|
||||
const offset = (newIdx - origIdx) * slotWidth;
|
||||
el.style.transform = offset !== 0 ? `translateX(${offset}px)` : "";
|
||||
el.style.zIndex = "";
|
||||
el.style.opacity = "";
|
||||
}
|
||||
el.style.transition = "none";
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllTransforms = () => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
container.querySelectorAll("[data-tab-id]").forEach((el) => {
|
||||
const h = el as HTMLElement;
|
||||
h.style.transform = "";
|
||||
h.style.zIndex = "";
|
||||
h.style.opacity = "";
|
||||
h.style.transition = "";
|
||||
});
|
||||
};
|
||||
|
||||
const handleTabMouseDown = (e: React.MouseEvent, tabId: string) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
closeTab(tabId);
|
||||
return;
|
||||
}
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
|
||||
// 이전 드롭 애니메이션이 진행 중이면 즉시 완료
|
||||
if (pendingDropRef.current) {
|
||||
clearTimeout(pendingDropRef.current.timerId);
|
||||
pendingDropRef.current.finalize();
|
||||
pendingDropRef.current = null;
|
||||
}
|
||||
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// finalize() 후 flushSync로 상태가 갱신되었으므로 최신 tabs 사용
|
||||
const currentTabs = tabsRef.current;
|
||||
const startIndex = currentTabs.findIndex(t => t.id === tabId);
|
||||
const startX = e.clientX;
|
||||
const slotWidth = tabWidth + TAB_GAP;
|
||||
const order = currentTabs.map(t => t.id);
|
||||
|
||||
dragStateRef.current = {
|
||||
tabId,
|
||||
startX,
|
||||
startIndex,
|
||||
currentIndex: startIndex,
|
||||
order,
|
||||
isDragging: false,
|
||||
};
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const dx = moveEvent.clientX - state.startX;
|
||||
if (!state.isDragging && Math.abs(dx) > 4) {
|
||||
state.isDragging = true;
|
||||
}
|
||||
if (!state.isDragging) return;
|
||||
|
||||
// 마우스 위치로 목표 슬롯 계산
|
||||
const containerLeft = container.getBoundingClientRect().left + 4;
|
||||
const mouseRelX = moveEvent.clientX - containerLeft;
|
||||
const targetSlot = Math.max(0, Math.min(visibleCount - 1, Math.floor(mouseRelX / slotWidth)));
|
||||
|
||||
// 순서 배열에서 직접 이동 (React 상태 안 건드림)
|
||||
if (targetSlot !== state.currentIndex) {
|
||||
const [moved] = state.order.splice(state.currentIndex, 1);
|
||||
state.order.splice(targetSlot, 0, moved);
|
||||
state.currentIndex = targetSlot;
|
||||
}
|
||||
|
||||
// 드래그 탭의 시각적 위치: 원래 슬롯 기준 마우스 이동량
|
||||
const visualDx = dx;
|
||||
applyDragTransforms(state.order, state.tabId, visualDx);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const state = dragStateRef.current;
|
||||
dragStateRef.current = null;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
|
||||
if (state && state.isDragging && state.currentIndex !== state.startIndex) {
|
||||
const dragEl = container.querySelector(`[data-tab-id="${state.tabId}"]`) as HTMLElement | null;
|
||||
const targetOffset = (state.currentIndex - state.startIndex) * slotWidth;
|
||||
const newOrder = [...state.order];
|
||||
|
||||
if (dragEl) {
|
||||
dragEl.style.transition = "transform 150ms ease, opacity 150ms ease";
|
||||
dragEl.style.transform = `translateX(${targetOffset}px)`;
|
||||
dragEl.style.opacity = "1";
|
||||
dragEl.style.zIndex = "50";
|
||||
}
|
||||
|
||||
const finalize = () => {
|
||||
clearAllTransforms();
|
||||
flushSync(() => updateTabOrder(newOrder));
|
||||
pendingDropRef.current = null;
|
||||
};
|
||||
const timerId = setTimeout(finalize, 150);
|
||||
pendingDropRef.current = { timerId, finalize };
|
||||
} else if (state && state.isDragging) {
|
||||
const dragEl = container.querySelector(`[data-tab-id="${state.tabId}"]`) as HTMLElement | null;
|
||||
if (dragEl) {
|
||||
dragEl.style.transition = "transform 150ms ease, opacity 150ms ease";
|
||||
dragEl.style.transform = "translateX(0)";
|
||||
dragEl.style.opacity = "1";
|
||||
}
|
||||
|
||||
const finalize = () => {
|
||||
clearAllTransforms();
|
||||
pendingDropRef.current = null;
|
||||
};
|
||||
const timerId = setTimeout(finalize, 150);
|
||||
pendingDropRef.current = { timerId, finalize };
|
||||
} else {
|
||||
clearAllTransforms();
|
||||
if (state) switchTab(tabId);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
const applyMenuDragGap = (insertIdx: number) => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
const slotWidth = tabWidth + TAB_GAP;
|
||||
|
||||
displayVisibleTabs.forEach((tab, i) => {
|
||||
const el = container.querySelector(`[data-tab-id="${tab.id}"]`) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
if (i >= insertIdx) {
|
||||
el.style.transform = `translateX(${slotWidth}px)`;
|
||||
el.style.transition = "transform 150ms ease";
|
||||
} else {
|
||||
el.style.transform = "";
|
||||
el.style.transition = "transform 150ms ease";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const clearMenuDragGap = () => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
container.querySelectorAll("[data-tab-id]").forEach((el) => {
|
||||
const h = el as HTMLElement;
|
||||
h.style.transform = "";
|
||||
h.style.transition = "";
|
||||
});
|
||||
menuDragInsertRef.current = -1;
|
||||
};
|
||||
|
||||
const handleMenuDragOver = (e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes("application/tab-menu")) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
if (!dragOver) setDragOver(true);
|
||||
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
const containerLeft = container.getBoundingClientRect().left + 4;
|
||||
const mouseRelX = e.clientX - containerLeft;
|
||||
const slotWidth = tabWidth + TAB_GAP;
|
||||
const insertIdx = Math.max(0, Math.min(displayVisibleTabs.length, Math.round(mouseRelX / slotWidth)));
|
||||
|
||||
if (insertIdx !== menuDragInsertRef.current) {
|
||||
menuDragInsertRef.current = insertIdx;
|
||||
applyMenuDragGap(insertIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuDragLeave = (e: React.DragEvent) => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (container && !container.contains(e.relatedTarget as Node)) {
|
||||
setDragOver(false);
|
||||
clearMenuDragGap();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMenuDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
|
||||
const insertIndex = menuDragInsertRef.current >= 0
|
||||
? menuDragInsertRef.current
|
||||
: undefined;
|
||||
|
||||
clearMenuDragGap();
|
||||
|
||||
const raw = e.dataTransfer.getData("application/tab-menu");
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(raw);
|
||||
const { menuObjid, menuName, parentMenuName } = data;
|
||||
|
||||
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||
if (assignedScreens.length > 0) {
|
||||
const firstScreen = assignedScreens[0];
|
||||
|
||||
const expectedTabId = `tab-${firstScreen.screenId}-${menuObjid || "default"}`;
|
||||
const alreadyOpen = tabs.some(t => t.id === expectedTabId);
|
||||
|
||||
if (!alreadyOpen) {
|
||||
newDropTabIdRef.current = expectedTabId;
|
||||
}
|
||||
|
||||
openTab({
|
||||
screenId: firstScreen.screenId,
|
||||
menuObjid,
|
||||
screenName: menuName,
|
||||
menuName: menuName,
|
||||
parentMenuName: parentMenuName,
|
||||
}, insertIndex);
|
||||
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set("menuObjid", menuObjid.toString());
|
||||
router.replace(`/screens/${firstScreen.screenId}?${urlParams.toString()}`);
|
||||
|
||||
if (!alreadyOpen) {
|
||||
requestAnimationFrame(() => {
|
||||
const container = tabsContainerRef.current;
|
||||
if (!container) return;
|
||||
const newEl = container.querySelector(`[data-tab-id="${expectedTabId}"]`) as HTMLElement | null;
|
||||
if (newEl) {
|
||||
newEl.style.transition = "none";
|
||||
newEl.style.transform = "scale(0.85)";
|
||||
newEl.style.opacity = "0";
|
||||
requestAnimationFrame(() => {
|
||||
newEl.style.transition = "transform 150ms ease, opacity 150ms ease";
|
||||
newEl.style.transform = "scale(1)";
|
||||
newEl.style.opacity = "1";
|
||||
setTimeout(() => {
|
||||
newEl.style.transition = "";
|
||||
newEl.style.transform = "";
|
||||
newEl.style.opacity = "";
|
||||
newDropTabIdRef.current = null;
|
||||
}, 160);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("드래그 드롭 탭 생성 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex h-9 min-w-0 items-end bg-slate-50 px-1 overflow-hidden shadow-[inset_0_-1px_0_0_rgb(226,232,240)]",
|
||||
dragOver && "shadow-[inset_0_-2px_0_0_rgb(59,130,246)]",
|
||||
)}
|
||||
ref={tabsContainerRef}
|
||||
onDragOver={handleMenuDragOver}
|
||||
onDragLeave={handleMenuDragLeave}
|
||||
onDrop={handleMenuDrop}
|
||||
>
|
||||
{/* 탭 + 오버플로우 버튼이 나란히 배치 */}
|
||||
<div className="flex min-w-0 items-end">
|
||||
{displayVisibleTabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const displayName = getTabDisplayName(tab);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
style={{ width: `${tabWidth}px` }}
|
||||
className={cn(
|
||||
"group relative mr-0.5 flex shrink-0 cursor-pointer items-center gap-1 rounded-t-lg border px-3 text-xs font-medium select-none transition-colors",
|
||||
isActive
|
||||
? "z-10 h-[33px] border-slate-200 border-b-0 bg-white text-slate-900"
|
||||
: "h-8 border-transparent text-slate-500 hover:bg-slate-100 hover:text-slate-700",
|
||||
)}
|
||||
onMouseDown={(e) => handleTabMouseDown(e, tab.id)}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
title={displayName}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{displayName}</span>
|
||||
{isActive && (
|
||||
<button
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-slate-400 transition-colors hover:bg-slate-200 hover:text-slate-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
refreshTab(tab.id);
|
||||
}}
|
||||
title="새로고침"
|
||||
>
|
||||
<RotateCw className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition-colors",
|
||||
isActive
|
||||
? "text-slate-400 hover:bg-slate-200 hover:text-slate-700"
|
||||
: "text-transparent group-hover:text-slate-400 group-hover:hover:bg-slate-200 group-hover:hover:text-slate-700",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 오버플로우 드롭다운 - 마지막 탭 바로 옆 */}
|
||||
{hasOverflow && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="mb-0.5 flex h-7 shrink-0 items-center justify-center gap-0.5 rounded-md px-2 text-xs font-medium text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-700"
|
||||
title={`외 ${displayOverflowTabs.length}개 탭`}
|
||||
>
|
||||
<span className="text-[10px]">+{displayOverflowTabs.length}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-[400px] w-[280px] overflow-y-auto">
|
||||
{displayOverflowTabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center justify-between gap-2",
|
||||
isActive && "bg-slate-100 font-semibold",
|
||||
)}
|
||||
onClick={() => switchTab(tab.id)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{tab.parentMenuName && (
|
||||
<span className="text-[10px] text-slate-400">{tab.parentMenuName}</span>
|
||||
)}
|
||||
<span className="truncate text-xs">{tab.screenName || `화면 ${tab.screenId}`}</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm text-slate-400 hover:bg-slate-200 hover:text-slate-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-xs text-slate-500"
|
||||
onClick={closeAllTabs}
|
||||
>
|
||||
모든 탭 닫기
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우클릭 컨텍스트 메뉴 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-50 min-w-[160px] rounded-md border border-slate-200 bg-white py-1 shadow-lg"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
refreshTab(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
<div className="my-1 border-t border-slate-200" />
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
closeTab(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
탭 닫기
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
closeOtherTabs(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
다른 탭 모두 닫기
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
closeTabsToTheLeft(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
왼쪽 탭 모두 닫기
|
||||
</button>
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 text-xs text-slate-700 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
closeTabsToTheRight(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
오른쪽 탭 모두 닫기
|
||||
</button>
|
||||
<div className="my-1 border-t border-slate-200" />
|
||||
<button
|
||||
className="flex w-full items-center px-3 py-1.5 text-xs text-red-600 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
closeAllTabs();
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
모든 탭 닫기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import React, { Suspense, useRef } from "react";
|
||||
import { Loader2, Inbox } from "lucide-react";
|
||||
import { useTab, TabItem } from "@/contexts/TabContext";
|
||||
import { ScreenViewPageEmbeddable } from "@/app/(main)/screens/[screenId]/page";
|
||||
|
||||
function TabLoadingFallback() {
|
||||
return (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 text-center shadow-lg">
|
||||
<Loader2 className="text-primary mx-auto h-10 w-10 animate-spin" />
|
||||
<p className="mt-4 font-medium text-slate-900">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTabState() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center py-12 text-center">
|
||||
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100">
|
||||
<Inbox className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-700">열린 탭이 없습니다</h3>
|
||||
<p className="max-w-sm text-sm text-slate-500">
|
||||
왼쪽 메뉴에서 화면을 선택하면 탭으로 열립니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabContent() {
|
||||
const { tabs, activeTabId, refreshKeys } = useTab();
|
||||
// 탭 순서 변경(드래그 리오더링) 시 DOM 재배치를 방지하기 위해
|
||||
// 탭이 추가된 순서(id 기준)로 고정 렌더링
|
||||
const stableOrderRef = useRef<TabItem[]>([]);
|
||||
|
||||
// F5 이후 활성 탭만 마운트하고, 비활성 탭은 클릭 시 마운트 (Lazy Mount)
|
||||
const mountedTabIdsRef = useRef<Set<string>>(
|
||||
new Set(activeTabId ? [activeTabId] : [])
|
||||
);
|
||||
|
||||
// 활성 탭을 마운트 목록에 추가
|
||||
if (activeTabId && !mountedTabIdsRef.current.has(activeTabId)) {
|
||||
mountedTabIdsRef.current.add(activeTabId);
|
||||
}
|
||||
|
||||
// 새 탭 추가 시 stableOrder에 append, 닫힌 탭은 제거
|
||||
const currentIds = new Set(tabs.map(t => t.id));
|
||||
const stableIds = new Set(stableOrderRef.current.map(t => t.id));
|
||||
|
||||
// 닫힌 탭 제거
|
||||
stableOrderRef.current = stableOrderRef.current.filter(t => currentIds.has(t.id));
|
||||
// 닫힌 탭을 마운트 목록에서도 제거
|
||||
for (const id of mountedTabIdsRef.current) {
|
||||
if (!currentIds.has(id)) mountedTabIdsRef.current.delete(id);
|
||||
}
|
||||
// 새로 추가된 탭 append
|
||||
for (const tab of tabs) {
|
||||
if (!stableIds.has(tab.id)) {
|
||||
stableOrderRef.current.push(tab);
|
||||
}
|
||||
}
|
||||
|
||||
const stableTabs = stableOrderRef.current;
|
||||
|
||||
if (stableTabs.length === 0) {
|
||||
return <EmptyTabState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{stableTabs.map((tab) => (
|
||||
<div
|
||||
key={`${tab.id}-${refreshKeys[tab.id] || 0}`}
|
||||
className="h-full w-full"
|
||||
style={{ display: tab.id === activeTabId ? "block" : "none" }}
|
||||
>
|
||||
{mountedTabIdsRef.current.has(tab.id) ? (
|
||||
<Suspense fallback={<TabLoadingFallback />}>
|
||||
<ScreenViewPageEmbeddable
|
||||
screenId={tab.screenId}
|
||||
menuObjid={tab.menuObjid}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
|
@ -48,12 +49,12 @@ export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...p
|
|||
// menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값
|
||||
const effectiveMenuObjid = menuObjid || props.menuObjid;
|
||||
|
||||
const [selectedColumn, setSelectedColumn] = useState<{
|
||||
uniqueKey: string; // 테이블명.컬럼명 형식
|
||||
const [selectedColumn, setSelectedColumn] = usePersistedState<{
|
||||
uniqueKey: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
tableName: string;
|
||||
} | null>(null);
|
||||
} | null>('selectedColumn', null);
|
||||
|
||||
const [leftWidth, setLeftWidth] = useState(15); // 초기값 15%
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export function TabsWidget({
|
|||
} = component;
|
||||
|
||||
const storageKey = `tabs-${component.id}-selected`;
|
||||
const sessionStorageKey = `tabs-session-${menuObjid || "g"}-${component.id}`;
|
||||
|
||||
// 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용)
|
||||
// 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식
|
||||
|
|
@ -89,12 +90,21 @@ export function TabsWidget({
|
|||
[externalOnSelectedRowsChange],
|
||||
);
|
||||
|
||||
// 초기 선택 탭 결정
|
||||
// 초기 선택 탭 결정 (sessionStorage 우선 → localStorage → 기본값)
|
||||
const getInitialTab = () => {
|
||||
if (persistSelection && typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved && tabs.some((t) => t.id === saved)) {
|
||||
return saved;
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const sessionSaved = sessionStorage.getItem(sessionStorageKey);
|
||||
if (sessionSaved && tabs.some((t) => t.id === sessionSaved)) {
|
||||
return sessionSaved;
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
|
||||
if (persistSelection) {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved && tabs.some((t) => t.id === saved)) {
|
||||
return saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultTab || tabs[0]?.id || "";
|
||||
|
|
@ -135,22 +145,32 @@ export function TabsWidget({
|
|||
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||
// 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
|
||||
// 탭별 화면 정보 (screenId, tableName) - screenId 기반 탭과 인라인 컴포넌트 모두 포함
|
||||
const screenInfoMap = React.useMemo(() => {
|
||||
const map: Record<string, { id?: number; tableName?: string }> = {};
|
||||
for (const tab of tabs as ExtendedTabItem[]) {
|
||||
const inlineComponents = tab.components || [];
|
||||
|
||||
// screenId 기반 탭 (별도 화면 로드 방식)
|
||||
if (tab.screenId != null && inlineComponents.length === 0) {
|
||||
map[tab.id] = { id: tab.screenId };
|
||||
continue;
|
||||
}
|
||||
|
||||
// 인라인 컴포넌트 탭
|
||||
if (inlineComponents.length > 0) {
|
||||
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
|
||||
const tableComp = inlineComponents.find(
|
||||
(c) => c.componentType === "v2-table-list" || c.componentType === "table-list",
|
||||
(c) =>
|
||||
c.componentType === "v2-table-list" ||
|
||||
c.componentType === "table-list" ||
|
||||
c.componentType === "v2-split-panel-layout",
|
||||
);
|
||||
const selectedTable = tableComp?.componentConfig?.selectedTable;
|
||||
if (selectedTable || tab.screenId) {
|
||||
map[tab.id] = {
|
||||
id: tab.screenId,
|
||||
tableName: selectedTable,
|
||||
};
|
||||
if (selectedTable || tab.screenId != null) {
|
||||
const entry: { id?: number; tableName?: string } = {};
|
||||
if (tab.screenId != null) entry.id = tab.screenId;
|
||||
if (selectedTable) entry.tableName = selectedTable;
|
||||
map[tab.id] = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -193,10 +213,13 @@ export function TabsWidget({
|
|||
loadScreenLayouts();
|
||||
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
||||
|
||||
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
||||
// 선택된 탭 변경 시 storage에 저장 + ActiveTab Context 업데이트
|
||||
useEffect(() => {
|
||||
if (persistSelection && typeof window !== "undefined") {
|
||||
localStorage.setItem(storageKey, selectedTab);
|
||||
if (typeof window !== "undefined") {
|
||||
try { sessionStorage.setItem(sessionStorageKey, selectedTab); } catch { /* 무시 */ }
|
||||
if (persistSelection) {
|
||||
localStorage.setItem(storageKey, selectedTab);
|
||||
}
|
||||
}
|
||||
|
||||
const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab);
|
||||
|
|
@ -207,7 +230,7 @@ export function TabsWidget({
|
|||
label: currentTabInfo.label,
|
||||
});
|
||||
}
|
||||
}, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]);
|
||||
}, [selectedTab, persistSelection, storageKey, sessionStorageKey, component.id, visibleTabs, setActiveTab]);
|
||||
|
||||
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
|
||||
useEffect(() => {
|
||||
|
|
@ -412,11 +435,11 @@ export function TabsWidget({
|
|||
onSelectedRowsChange={handleSelectedRowsChange}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
// 탭에 screenId가 있으면 해당 화면의 tableName/screenId로 오버라이드
|
||||
// 탭에 screenId/tableName이 있으면 오버라이드 (undefined로 부모값을 덮어쓰지 않도록 조건부 적용)
|
||||
{...(screenInfoMap[tab.id]
|
||||
? {
|
||||
tableName: screenInfoMap[tab.id].tableName,
|
||||
screenId: screenInfoMap[tab.id].id,
|
||||
...(screenInfoMap[tab.id].tableName != null && { tableName: screenInfoMap[tab.id].tableName }),
|
||||
...(screenInfoMap[tab.id].id != null && { screenId: screenInfoMap[tab.id].id }),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -30,6 +31,7 @@ interface CategoryValueManagerProps {
|
|||
columnLabel: string;
|
||||
onValueCountChange?: (count: number) => void;
|
||||
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
||||
screenId?: number | string; // 비활성 탭 F5 복원용 캐시 키
|
||||
}
|
||||
|
||||
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||
|
|
@ -38,20 +40,20 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
|||
columnLabel,
|
||||
onValueCountChange,
|
||||
menuObjid,
|
||||
screenId,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
// TSP: 상태 자동 보존
|
||||
const [selectedValueIds, setSelectedValueIds] = usePersistedState<number[]>(`selectedValueIds-${columnName}`, []);
|
||||
const [searchQuery, setSearchQuery] = usePersistedState(`searchQuery-${columnName}`, '');
|
||||
const [showInactive, setShowInactive] = usePersistedState(`showInactive-${columnName}`, false);
|
||||
|
||||
const [values, setValues] = useState<TableCategoryValue[]>([]);
|
||||
const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedValueIds, setSelectedValueIds] = useState<number[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(
|
||||
null
|
||||
);
|
||||
const [showInactive, setShowInactive] = useState(false); // 비활성 항목 표시 옵션 (기본: 숨김)
|
||||
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(null);
|
||||
|
||||
// 카테고리 값 로드
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
* - 체크박스를 통한 다중 선택 및 일괄 삭제 지원
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
|
|
@ -58,6 +59,7 @@ interface CategoryValueManagerTreeProps {
|
|||
columnName: string;
|
||||
columnLabel: string;
|
||||
onValueCountChange?: (count: number) => void;
|
||||
screenId?: number | string; // 비활성 탭 F5 복원용 캐시 키
|
||||
}
|
||||
|
||||
// 트리 노드 컴포넌트
|
||||
|
|
@ -271,15 +273,24 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
columnName,
|
||||
columnLabel,
|
||||
onValueCountChange,
|
||||
screenId,
|
||||
}) => {
|
||||
// TSP: 상태 자동 보존
|
||||
const [checkedIds, setCheckedIds] = usePersistedState<Set<number>>(`checkedIds-${columnName}`, new Set());
|
||||
const [expandedNodes, setExpandedNodes] = usePersistedState<Set<number>>(`expandedNodes-${columnName}`, new Set());
|
||||
const [searchQuery, setSearchQuery] = usePersistedState(`searchQuery-${columnName}`, '');
|
||||
const [showInactive, setShowInactive] = usePersistedState(`showInactive-${columnName}`, false);
|
||||
const [focusedValueId, setFocusedValueId] = usePersistedState<number | null>(`focusedValueId-${columnName}`, null);
|
||||
const pendingFocusIdRef = useRef<number | null>(focusedValueId);
|
||||
|
||||
// 상태
|
||||
const [tree, setTree] = useState<CategoryValue[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
||||
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
|
||||
const [selectedValue, setSelectedValueRaw] = useState<CategoryValue | null>(null);
|
||||
const setSelectedValue = useCallback((val: CategoryValue | null) => {
|
||||
setSelectedValueRaw(val);
|
||||
setFocusedValueId(val?.valueId ?? null);
|
||||
}, [setFocusedValueId]);
|
||||
|
||||
// 모달 상태
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
|
|
@ -386,9 +397,26 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
|
||||
setTree(filteredTree);
|
||||
|
||||
// 캐시된 선택 항목 복원
|
||||
if (pendingFocusIdRef.current !== null) {
|
||||
const node = findNodeById(filteredTree, pendingFocusIdRef.current);
|
||||
if (node) {
|
||||
setSelectedValue(node);
|
||||
// 부모 노드들을 펼쳐서 선택 항목이 보이도록
|
||||
if (node.path) {
|
||||
const pathIds = node.path.split("/").filter(Boolean).map(Number);
|
||||
setExpandedNodes((prev) => new Set([...prev, ...pathIds]));
|
||||
}
|
||||
}
|
||||
pendingFocusIdRef.current = null;
|
||||
}
|
||||
|
||||
// 기존 펼침 상태 유지하지 않으면 모두 접기 (대분류만 표시)
|
||||
if (!keepExpanded) {
|
||||
setExpandedNodes(new Set());
|
||||
setExpandedNodes((prev) => {
|
||||
// 캐시 복원으로 펼쳐진 노드가 있으면 유지
|
||||
return prev.size > 0 ? prev : new Set();
|
||||
});
|
||||
}
|
||||
|
||||
// 전체 개수 업데이트
|
||||
|
|
@ -401,7 +429,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
|||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange],
|
||||
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange, findNodeById],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
|
||||
|
||||
export interface TabItem {
|
||||
id: string;
|
||||
screenId: number;
|
||||
menuObjid?: number;
|
||||
screenName: string;
|
||||
menuName?: string;
|
||||
parentMenuName?: string;
|
||||
isAdminMode?: boolean;
|
||||
}
|
||||
|
||||
interface TabContextType {
|
||||
tabs: TabItem[];
|
||||
activeTabId: string | null;
|
||||
refreshKeys: Record<string, number>;
|
||||
openTab: (tab: Omit<TabItem, "id">, insertIndex?: number) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
closeOtherTabs: (tabId: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
closeTabsToTheLeft: (tabId: string) => void;
|
||||
closeTabsToTheRight: (tabId: string) => void;
|
||||
switchTab: (tabId: string) => void;
|
||||
getActiveTab: () => TabItem | undefined;
|
||||
updateTabOrder: (newOrder: string[]) => void;
|
||||
refreshTab: (tabId: string) => void;
|
||||
}
|
||||
|
||||
const TAB_STORAGE_KEY = "erp_open_tabs";
|
||||
const ACTIVE_TAB_STORAGE_KEY = "erp_active_tab";
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(undefined);
|
||||
|
||||
function generateTabId(screenId: number, menuObjid?: number): string {
|
||||
return `tab-${screenId}-${menuObjid || "default"}`;
|
||||
}
|
||||
|
||||
function clearTabStateCache(tab: TabItem) {
|
||||
try {
|
||||
// 페이지 레벨 캐시 삭제
|
||||
sessionStorage.removeItem(`tab-cache-${tab.screenId}-${tab.menuObjid || "default"}`);
|
||||
|
||||
// 페이지 스크롤 위치 캐시 삭제
|
||||
sessionStorage.removeItem(`page-scroll-${tab.screenId}-${tab.menuObjid || "default"}`);
|
||||
|
||||
// 하위 컴포넌트 캐시 삭제 (category-mgr, category-widget)
|
||||
if (tab.menuObjid) {
|
||||
sessionStorage.removeItem(`category-mgr-${tab.menuObjid}`);
|
||||
sessionStorage.removeItem(`category-widget-${tab.menuObjid}`);
|
||||
}
|
||||
sessionStorage.removeItem(`category-mgr-${tab.screenId}`);
|
||||
sessionStorage.removeItem(`category-widget-${tab.screenId}`);
|
||||
|
||||
// TSP: 하위 컴포넌트 캐시 일괄 삭제 (레거시 + 새 usePersistedState 훅)
|
||||
const prefixes = [
|
||||
`tsp-${tab.screenId}-`,
|
||||
`tabs-session-${tab.menuObjid || "g"}-`,
|
||||
`table-state-${tab.screenId}-`,
|
||||
`split-sel-${tab.screenId}-`,
|
||||
`catval-sel-${tab.screenId}-`,
|
||||
`category-mgr-${tab.menuObjid || tab.screenId}-`,
|
||||
`bom-tree-${tab.screenId}-`,
|
||||
];
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && prefixes.some(p => key.startsWith(p))) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(k => sessionStorage.removeItem(k));
|
||||
} catch { /* 무시 */ }
|
||||
}
|
||||
|
||||
function loadTabsFromStorage(): { tabs: TabItem[]; activeTabId: string | null } {
|
||||
if (typeof window === "undefined") return { tabs: [], activeTabId: null };
|
||||
|
||||
try {
|
||||
const tabsJson = sessionStorage.getItem(TAB_STORAGE_KEY);
|
||||
const activeTabId = sessionStorage.getItem(ACTIVE_TAB_STORAGE_KEY);
|
||||
const tabs = tabsJson ? JSON.parse(tabsJson) : [];
|
||||
return { tabs, activeTabId };
|
||||
} catch {
|
||||
return { tabs: [], activeTabId: null };
|
||||
}
|
||||
}
|
||||
|
||||
function saveTabsToStorage(tabs: TabItem[], activeTabId: string | null) {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(tabs));
|
||||
if (activeTabId) {
|
||||
sessionStorage.setItem(ACTIVE_TAB_STORAGE_KEY, activeTabId);
|
||||
} else {
|
||||
sessionStorage.removeItem(ACTIVE_TAB_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage 용량 초과 등 무시
|
||||
}
|
||||
}
|
||||
|
||||
export function TabProvider({ children }: { children: ReactNode }) {
|
||||
const [tabs, setTabs] = useState<TabItem[]>([]);
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [refreshKeys, setRefreshKeys] = useState<Record<string, number>>({});
|
||||
|
||||
// sessionStorage에서 복원
|
||||
useEffect(() => {
|
||||
const { tabs: savedTabs, activeTabId: savedActiveTabId } = loadTabsFromStorage();
|
||||
if (savedTabs.length > 0) {
|
||||
setTabs(savedTabs);
|
||||
const activeId = savedActiveTabId && savedTabs.some(t => t.id === savedActiveTabId) ? savedActiveTabId : savedTabs[0].id;
|
||||
setActiveTabId(activeId);
|
||||
|
||||
// 활성 탭의 상태 캐시 삭제 (F5 시 활성 탭은 정상 새로고침)
|
||||
const activeTab = savedTabs.find(t => t.id === activeId);
|
||||
if (activeTab) {
|
||||
clearTabStateCache(activeTab);
|
||||
}
|
||||
}
|
||||
setInitialized(true);
|
||||
}, []);
|
||||
|
||||
// 상태 변경 시 sessionStorage에 저장
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
saveTabsToStorage(tabs, activeTabId);
|
||||
}, [tabs, activeTabId, initialized]);
|
||||
|
||||
const openTab = useCallback((tabData: Omit<TabItem, "id">, insertIndex?: number) => {
|
||||
const tabId = generateTabId(tabData.screenId, tabData.menuObjid);
|
||||
|
||||
setTabs((prevTabs) => {
|
||||
const existingTab = prevTabs.find(t => t.screenId === tabData.screenId);
|
||||
if (existingTab) {
|
||||
setActiveTabId(existingTab.id);
|
||||
return prevTabs;
|
||||
}
|
||||
|
||||
const newTab: TabItem = { ...tabData, id: tabId };
|
||||
setActiveTabId(tabId);
|
||||
|
||||
if (insertIndex !== undefined && insertIndex >= 0 && insertIndex <= prevTabs.length) {
|
||||
const newTabs = [...prevTabs];
|
||||
newTabs.splice(insertIndex, 0, newTab);
|
||||
return newTabs;
|
||||
}
|
||||
return [...prevTabs, newTab];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeTab = useCallback((tabId: string) => {
|
||||
setTabs((prevTabs) => {
|
||||
const tabIndex = prevTabs.findIndex(t => t.id === tabId);
|
||||
if (tabIndex === -1) return prevTabs;
|
||||
|
||||
clearTabStateCache(prevTabs[tabIndex]);
|
||||
|
||||
const newTabs = prevTabs.filter(t => t.id !== tabId);
|
||||
|
||||
setActiveTabId((prevActiveId) => {
|
||||
if (prevActiveId === tabId) {
|
||||
if (newTabs.length === 0) return null;
|
||||
const nextIndex = Math.min(tabIndex, newTabs.length - 1);
|
||||
return newTabs[nextIndex].id;
|
||||
}
|
||||
return prevActiveId;
|
||||
});
|
||||
|
||||
return newTabs;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeOtherTabs = useCallback((tabId: string) => {
|
||||
setTabs((prevTabs) => {
|
||||
const keepTab = prevTabs.find(t => t.id === tabId);
|
||||
if (!keepTab) return prevTabs;
|
||||
prevTabs.filter(t => t.id !== tabId).forEach(clearTabStateCache);
|
||||
setActiveTabId(tabId);
|
||||
return [keepTab];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeAllTabs = useCallback(() => {
|
||||
setTabs((prevTabs) => {
|
||||
prevTabs.forEach(clearTabStateCache);
|
||||
return [];
|
||||
});
|
||||
setActiveTabId(null);
|
||||
}, []);
|
||||
|
||||
const closeTabsToTheLeft = useCallback((tabId: string) => {
|
||||
setTabs((prevTabs) => {
|
||||
const tabIndex = prevTabs.findIndex(t => t.id === tabId);
|
||||
if (tabIndex <= 0) return prevTabs;
|
||||
|
||||
prevTabs.slice(0, tabIndex).forEach(clearTabStateCache);
|
||||
const newTabs = prevTabs.slice(tabIndex);
|
||||
|
||||
setActiveTabId((prevActiveId) => {
|
||||
if (prevActiveId && !newTabs.find(t => t.id === prevActiveId)) {
|
||||
return tabId;
|
||||
}
|
||||
return prevActiveId;
|
||||
});
|
||||
|
||||
return newTabs;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeTabsToTheRight = useCallback((tabId: string) => {
|
||||
setTabs((prevTabs) => {
|
||||
const tabIndex = prevTabs.findIndex(t => t.id === tabId);
|
||||
if (tabIndex === -1) return prevTabs;
|
||||
|
||||
prevTabs.slice(tabIndex + 1).forEach(clearTabStateCache);
|
||||
const newTabs = prevTabs.slice(0, tabIndex + 1);
|
||||
|
||||
setActiveTabId((prevActiveId) => {
|
||||
if (prevActiveId && !newTabs.find(t => t.id === prevActiveId)) {
|
||||
return tabId;
|
||||
}
|
||||
return prevActiveId;
|
||||
});
|
||||
|
||||
return newTabs;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const switchTab = useCallback((tabId: string) => {
|
||||
setActiveTabId(tabId);
|
||||
}, []);
|
||||
|
||||
const getActiveTab = useCallback(() => {
|
||||
return tabs.find(t => t.id === activeTabId);
|
||||
}, [tabs, activeTabId]);
|
||||
|
||||
const refreshTab = useCallback((tabId: string) => {
|
||||
const tab = tabs.find(t => t.id === tabId);
|
||||
if (tab) clearTabStateCache(tab);
|
||||
setRefreshKeys(prev => ({ ...prev, [tabId]: (prev[tabId] || 0) + 1 }));
|
||||
}, [tabs]);
|
||||
|
||||
const updateTabOrder = useCallback((newOrder: string[]) => {
|
||||
setTabs((prevTabs) => {
|
||||
const tabMap = new Map(prevTabs.map(t => [t.id, t]));
|
||||
const reordered = newOrder
|
||||
.map(id => tabMap.get(id))
|
||||
.filter((t): t is TabItem => t !== undefined);
|
||||
// 드래그에 포함되지 않은 탭(오버플로우 등)은 뒤에 유지
|
||||
const remaining = prevTabs.filter(t => !newOrder.includes(t.id));
|
||||
return [...reordered, ...remaining];
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TabContext.Provider
|
||||
value={{
|
||||
tabs,
|
||||
activeTabId,
|
||||
refreshKeys,
|
||||
openTab,
|
||||
closeTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
closeTabsToTheLeft,
|
||||
closeTabsToTheRight,
|
||||
switchTab,
|
||||
getActiveTab,
|
||||
updateTabOrder,
|
||||
refreshTab,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TabContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTab() {
|
||||
const context = useContext(TabContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTab must be used within a TabProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import type { Dispatch, SetStateAction, ReactNode } from "react";
|
||||
|
||||
// ─── TSP (Tab State Persistence) 중앙 관리 훅 ───
|
||||
// 모든 컴포넌트의 UI 상태를 sessionStorage에 자동 보존/복원
|
||||
// useState 대신 usePersistedState를 사용하면 탭 상태 보존이 자동 적용됨
|
||||
|
||||
const TSP_PREFIX = "tsp-";
|
||||
|
||||
// ─── Context ───
|
||||
|
||||
interface TSPContextValue {
|
||||
screenId?: number;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
const TSPContext = createContext<TSPContextValue>({});
|
||||
|
||||
export function TSPProvider({
|
||||
screenId,
|
||||
componentId,
|
||||
children,
|
||||
}: {
|
||||
screenId?: number;
|
||||
componentId?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const value = useMemo(
|
||||
() => ({ screenId, componentId }),
|
||||
[screenId, componentId],
|
||||
);
|
||||
return <TSPContext.Provider value={value}>{children}</TSPContext.Provider>;
|
||||
}
|
||||
|
||||
// ─── 직렬화 (Set, Map, Date 지원) ───
|
||||
|
||||
function serialize(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, val) => {
|
||||
if (val instanceof Set) return { __tsp: "Set", d: Array.from(val) };
|
||||
if (val instanceof Map) return { __tsp: "Map", d: Array.from(val.entries()) };
|
||||
if (val instanceof Date) return { __tsp: "Date", d: val.toISOString() };
|
||||
return val;
|
||||
});
|
||||
}
|
||||
|
||||
function deserialize(raw: string): unknown {
|
||||
return JSON.parse(raw, (_key, val) => {
|
||||
if (val && typeof val === "object" && val.__tsp === "Set") return new Set(val.d);
|
||||
if (val && typeof val === "object" && val.__tsp === "Map") return new Map(val.d);
|
||||
if (val && typeof val === "object" && val.__tsp === "Date") return new Date(val.d);
|
||||
return val;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 메인 훅 ───
|
||||
|
||||
interface PersistedStateOptions {
|
||||
/** sessionStorage 저장 지연 시간 (기본 300ms) */
|
||||
debounce?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* useState와 동일한 인터페이스로 탭 상태를 자동 보존하는 훅
|
||||
*
|
||||
* @param key - 상태 식별 키 (같은 컴포넌트 내에서 고유해야 함)
|
||||
* @param defaultValue - 캐시가 없을 때 사용할 기본값
|
||||
* @param options - debounce 등 옵션
|
||||
*
|
||||
* @example
|
||||
* const [selectedRow, setSelectedRow] = usePersistedState('selectedRow', null);
|
||||
* const [expanded, setExpanded] = usePersistedState('expanded', new Set<string>());
|
||||
* const [scroll, setScroll] = usePersistedState('scrollTop', 0, { debounce: 100 });
|
||||
*/
|
||||
export function usePersistedState<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
options?: PersistedStateOptions,
|
||||
): [T, Dispatch<SetStateAction<T>>] {
|
||||
const { screenId, componentId } = useContext(TSPContext);
|
||||
const debounceMs = options?.debounce ?? 300;
|
||||
|
||||
// tsp-{screenId}-{componentId}-{key}
|
||||
const cacheKey =
|
||||
screenId != null && componentId
|
||||
? `${TSP_PREFIX}${screenId}-${componentId}-${key}`
|
||||
: null;
|
||||
|
||||
// 안정적인 참조 (cacheKey가 렌더 중 변하지 않도록)
|
||||
const cacheKeyRef = useRef(cacheKey);
|
||||
cacheKeyRef.current = cacheKey;
|
||||
|
||||
const [state, setState] = useState<T>(() => {
|
||||
if (!cacheKey || typeof window === "undefined") return defaultValue;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(cacheKey);
|
||||
if (raw !== null) return deserialize(raw) as T;
|
||||
} catch {
|
||||
/* 파싱 실패 시 기본값 사용 */
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
// debounce 저장
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isFirstRender = useRef(true);
|
||||
const latestStateRef = useRef<T>(state);
|
||||
latestStateRef.current = state;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const ck = cacheKeyRef.current;
|
||||
if (!ck) return;
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
try {
|
||||
sessionStorage.setItem(ck, serialize(state));
|
||||
} catch {
|
||||
/* 용량 초과 무시 */
|
||||
}
|
||||
timerRef.current = null;
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [state, debounceMs]);
|
||||
|
||||
// unmount 시 미저장 상태 flush (탭 전환 중 데이터 유실 방지)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
const ck = cacheKeyRef.current;
|
||||
if (ck) {
|
||||
try { sessionStorage.setItem(ck, serialize(latestStateRef.current)); } catch {}
|
||||
}
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [state, setState];
|
||||
}
|
||||
|
||||
// ─── 캐시 정리 유틸리티 ───
|
||||
|
||||
/**
|
||||
* 특정 화면(또는 화면+컴포넌트)의 TSP 캐시를 일괄 삭제
|
||||
*
|
||||
* @param screenId - 화면 ID
|
||||
* @param componentId - (선택) 특정 컴포넌트만 삭제할 때
|
||||
*/
|
||||
export function clearTSPCache(screenId: number, componentId?: string) {
|
||||
const prefix = componentId
|
||||
? `${TSP_PREFIX}${screenId}-${componentId}-`
|
||||
: `${TSP_PREFIX}${screenId}-`;
|
||||
|
||||
const toRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const k = sessionStorage.key(i);
|
||||
if (k?.startsWith(prefix)) toRemove.push(k);
|
||||
}
|
||||
toRemove.forEach((k) => sessionStorage.removeItem(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 TSP 캐시 삭제 (로그아웃 등)
|
||||
*/
|
||||
export function clearAllTSPCache() {
|
||||
const toRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const k = sessionStorage.key(i);
|
||||
if (k?.startsWith(TSP_PREFIX)) toRemove.push(k);
|
||||
}
|
||||
toRemove.forEach((k) => sessionStorage.removeItem(k));
|
||||
}
|
||||
|
|
@ -8,6 +8,8 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
|||
|
||||
// 통합 폼 시스템 import
|
||||
import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
||||
// TSP (Tab State Persistence) - 컴포넌트별 상태 보존
|
||||
import { TSPProvider } from "@/hooks/usePersistedState";
|
||||
|
||||
// 컴포넌트 렌더러 인터페이스
|
||||
export interface ComponentRenderer {
|
||||
|
|
@ -669,14 +671,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
NewComponentRenderer.prototype.render;
|
||||
|
||||
if (isClass) {
|
||||
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
|
||||
const rendererInstance = new NewComponentRenderer(rendererProps);
|
||||
return rendererInstance.render();
|
||||
return (
|
||||
<TSPProvider screenId={screenId} componentId={component.id}>
|
||||
{rendererInstance.render()}
|
||||
</TSPProvider>
|
||||
);
|
||||
} else {
|
||||
// 함수형 컴포넌트
|
||||
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
|
||||
|
||||
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
||||
return (
|
||||
<TSPProvider screenId={screenId} componentId={component.id}>
|
||||
<NewComponentRenderer key={refreshKey} {...rendererProps} />
|
||||
</TSPProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -718,22 +724,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 레거시 시스템에서도 DOM 안전한 props만 전달
|
||||
const safeLegacyProps = filterDOMProps(props);
|
||||
|
||||
return renderer({
|
||||
const legacyRendered = renderer({
|
||||
component,
|
||||
isSelected,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
children,
|
||||
// React 전용 props들은 명시적으로 전달 (레거시 컴포넌트가 필요한 경우)
|
||||
isInteractive: props.isInteractive,
|
||||
formData: props.formData,
|
||||
onFormDataChange: props.onFormDataChange,
|
||||
screenId: props.screenId,
|
||||
tableName: props.tableName,
|
||||
userId: props.userId, // 🆕 사용자 ID
|
||||
userName: props.userName, // 🆕 사용자 이름
|
||||
companyCode: props.companyCode, // 🆕 회사 코드
|
||||
userId: props.userId,
|
||||
userName: props.userName,
|
||||
companyCode: props.companyCode,
|
||||
onRefresh: props.onRefresh,
|
||||
onClose: props.onClose,
|
||||
mode: props.mode,
|
||||
|
|
@ -743,18 +748,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
onZoneClick: props.onZoneClick,
|
||||
onZoneComponentDrop: props.onZoneComponentDrop,
|
||||
allComponents: props.allComponents,
|
||||
// 테이블 선택된 행 정보 전달
|
||||
selectedRows: props.selectedRows,
|
||||
selectedRowsData: props.selectedRowsData,
|
||||
onSelectedRowsChange: props.onSelectedRowsChange,
|
||||
// 플로우 선택된 데이터 정보 전달
|
||||
flowSelectedData: props.flowSelectedData,
|
||||
flowSelectedStepId: props.flowSelectedStepId,
|
||||
onFlowSelectedDataChange: props.onFlowSelectedDataChange,
|
||||
refreshKey: props.refreshKey,
|
||||
// DOM 안전한 props들
|
||||
...safeLegacyProps,
|
||||
});
|
||||
|
||||
return (
|
||||
<TSPProvider screenId={props.screenId} componentId={component.id}>
|
||||
{legacyRendered}
|
||||
</TSPProvider>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Star, Loader2, ExternalLink } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -38,7 +39,7 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
|
|||
style,
|
||||
}) => {
|
||||
const [buttons, setButtons] = useState<ButtonItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = usePersistedState<string | null>('selectedId', null);
|
||||
const [selectedItem, setSelectedItem] = useState<ButtonItem | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [masterData, setMasterData] = useState<Record<string, any> | null>(null);
|
||||
|
|
@ -160,10 +161,11 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
|
|||
|
||||
setButtons(items);
|
||||
|
||||
// 자동 선택: 기본 항목 또는 첫 번째 항목
|
||||
// 자동 선택: 캐시 복원 → 기본 항목 → 첫 번째 항목
|
||||
if (config.autoSelectFirst && items.length > 0) {
|
||||
const cachedItem = selectedId ? items.find(item => String(item.id) === String(selectedId)) : undefined;
|
||||
const defaultItem = items.find(item => item.isDefault);
|
||||
const targetItem = defaultItem || items[0];
|
||||
const targetItem = cachedItem || defaultItem || items[0];
|
||||
setSelectedId(targetItem.id);
|
||||
setSelectedItem(targetItem);
|
||||
emitSelection(targetItem);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -166,7 +167,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 데이터 상태
|
||||
const [leftData, setLeftData] = useState<any[]>([]);
|
||||
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
||||
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
||||
const [selectedLeftItem, setSelectedLeftItem] = usePersistedState<any>('selectedLeftItem', null, { debounce: 0 });
|
||||
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
|
||||
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||
|
|
@ -2286,6 +2287,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDesignMode, componentConfig.autoLoad]);
|
||||
|
||||
// TSP: F5 새로고침 후 캐시된 좌측 선택 항목 복원
|
||||
const selectionRestoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (selectionRestoredRef.current || isDesignMode) return;
|
||||
if (leftData.length === 0) return;
|
||||
if (selectedLeftItem) {
|
||||
selectionRestoredRef.current = true;
|
||||
loadRightData(selectedLeftItem);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftData, isDesignMode]);
|
||||
|
||||
// 🔄 필터 변경 시 데이터 다시 로드
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig, GroupingConfig, ColumnDisplayConfig, TabConfig } from "./types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { toast } from "sonner";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
|
||||
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
|
||||
// 추가 props
|
||||
|
|
@ -66,12 +67,19 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
// 상태 관리
|
||||
const [leftData, setLeftData] = useState<any[]>([]);
|
||||
const [rightData, setRightData] = useState<any[]>([]);
|
||||
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
||||
const [leftSearchTerm, setLeftSearchTerm] = useState("");
|
||||
const [rightSearchTerm, setRightSearchTerm] = useState("");
|
||||
|
||||
// TSP: 탭 상태 보존 대상 (usePersistedState)
|
||||
const [selectedLeftItem, setSelectedLeftItem] = usePersistedState<any>('selectedLeftItem', null, { debounce: 0 });
|
||||
const initialCachedLeftItemRef = useRef(selectedLeftItem);
|
||||
const [leftSearchTerm, setLeftSearchTerm] = usePersistedState('leftSearchTerm', '');
|
||||
const [rightSearchTerm, setRightSearchTerm] = usePersistedState('rightSearchTerm', '');
|
||||
const [expandedItems, setExpandedItems] = usePersistedState<Set<string>>('expandedItems', new Set());
|
||||
const [selectedRightItems, setSelectedRightItems] = usePersistedState<Set<string | number>>('selectedRightItems', new Set());
|
||||
const [leftActiveTab, setLeftActiveTab] = usePersistedState<string | null>('leftActiveTab', null);
|
||||
const [rightActiveTab, setRightActiveTab] = usePersistedState<string | null>('rightActiveTab', null);
|
||||
|
||||
const [leftLoading, setLeftLoading] = useState(false);
|
||||
const [rightLoading, setRightLoading] = useState(false);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
|
|
@ -79,19 +87,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
|
||||
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({});
|
||||
|
||||
// 우측 패널 선택 상태 (체크박스용)
|
||||
const [selectedRightItems, setSelectedRightItems] = useState<Set<string | number>>(new Set());
|
||||
|
||||
// 삭제 확인 다이얼로그 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [itemToDelete, setItemToDelete] = useState<any>(null);
|
||||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||
const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right");
|
||||
|
||||
// 탭 상태 (좌측/우측 각각)
|
||||
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
|
||||
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
|
||||
|
||||
// 프론트엔드 그룹핑 함수
|
||||
const groupData = useCallback(
|
||||
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
|
||||
|
|
@ -1241,6 +1242,20 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
config.rightPanel?.tableName,
|
||||
]);
|
||||
|
||||
// TSP: 캐시에서 복원된 좌측 선택 항목으로 우측 데이터 로드
|
||||
const selectionRestoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (selectionRestoredRef.current || isDesignMode) return;
|
||||
if (leftData.length === 0) return;
|
||||
|
||||
if (selectedLeftItem) {
|
||||
selectionRestoredRef.current = true;
|
||||
loadRightData(selectedLeftItem);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftData, isDesignMode]);
|
||||
|
||||
// 컴포넌트 언마운트 시 DataProvider 해제
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
|
||||
/**
|
||||
* BOM 트리 노드 데이터
|
||||
|
|
@ -66,11 +67,13 @@ export function BomTreeComponent({
|
|||
selectedRowsData,
|
||||
...props
|
||||
}: BomTreeComponentProps) {
|
||||
// TSP: usePersistedState 훅으로 자동 보존
|
||||
const [expandedNodes, setExpandedNodes] = usePersistedState<Set<string>>("expandedNodes", new Set());
|
||||
const [selectedNodeId, setSelectedNodeId] = usePersistedState<string | null>("selectedNodeId", null);
|
||||
|
||||
const [headerInfo, setHeaderInfo] = useState<BomHeaderInfo | null>(null);
|
||||
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
|
||||
const config = component?.componentConfig || {};
|
||||
|
||||
|
|
@ -93,6 +96,14 @@ export function BomTreeComponent({
|
|||
return null;
|
||||
}, [formData, selectedRowsData]);
|
||||
|
||||
// ref로 현재 상태 참조 (useCallback 의존성 순환 방지)
|
||||
const expandedNodesRef = useRef(expandedNodes);
|
||||
expandedNodesRef.current = expandedNodes;
|
||||
const selectedNodeIdRef = useRef(selectedNodeId);
|
||||
selectedNodeIdRef.current = selectedNodeId;
|
||||
|
||||
const cacheRestoredRef = useRef(false);
|
||||
|
||||
// BOM 디테일 데이터 로드
|
||||
const loadBomDetails = useCallback(async (bomId: string) => {
|
||||
if (!bomId) return;
|
||||
|
|
@ -110,8 +121,30 @@ export function BomTreeComponent({
|
|||
const rows = result.data || [];
|
||||
const tree = buildTree(rows);
|
||||
setTreeData(tree);
|
||||
const firstLevelIds = new Set<string>(tree.map((n: BomTreeNode) => n.id));
|
||||
setExpandedNodes(firstLevelIds);
|
||||
|
||||
const currentExpanded = expandedNodesRef.current;
|
||||
const currentSelectedId = selectedNodeIdRef.current;
|
||||
|
||||
if (!cacheRestoredRef.current && currentExpanded.size > 0) {
|
||||
cacheRestoredRef.current = true;
|
||||
if (currentSelectedId) {
|
||||
const parentMap = new Map<string, string>();
|
||||
rows.forEach((r: any) => {
|
||||
if (r.parent_detail_id) parentMap.set(String(r.id), String(r.parent_detail_id));
|
||||
});
|
||||
const merged = new Set(currentExpanded);
|
||||
let cur: string | undefined = currentSelectedId;
|
||||
while (cur && parentMap.has(cur)) {
|
||||
cur = parentMap.get(cur);
|
||||
if (cur) merged.add(cur);
|
||||
}
|
||||
setExpandedNodes(merged);
|
||||
}
|
||||
} else {
|
||||
cacheRestoredRef.current = true;
|
||||
const firstLevelIds = new Set<string>(tree.map((n: BomTreeNode) => n.id));
|
||||
setExpandedNodes(firstLevelIds);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BomTree] 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||
|
|
@ -46,6 +47,9 @@ export function V2CategoryManagerComponent({
|
|||
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
|
||||
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
|
||||
|
||||
// screenId (비활성 탭 F5 복원용 캐시 키로 사용)
|
||||
const effectiveScreenId = typeof props.screenId === "number" || typeof props.screenId === "string" ? props.screenId : undefined;
|
||||
|
||||
// 디버그 로그
|
||||
useEffect(() => {
|
||||
console.log("🔍 V2CategoryManagerComponent props:", {
|
||||
|
|
@ -58,16 +62,16 @@ export function V2CategoryManagerComponent({
|
|||
});
|
||||
}, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]);
|
||||
|
||||
// 선택된 컬럼 상태
|
||||
const [selectedColumn, setSelectedColumn] = useState<{
|
||||
// TSP: 선택된 컬럼 상태 (자동 보존)
|
||||
const [selectedColumn, setSelectedColumn] = usePersistedState<{
|
||||
uniqueKey: string;
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
tableName: string;
|
||||
} | null>(null);
|
||||
} | null>('selectedColumn', null);
|
||||
|
||||
// 뷰 모드 상태
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
|
||||
// TSP: 뷰 모드 상태 (자동 보존)
|
||||
const [viewMode, setViewMode] = usePersistedState<ViewMode>('viewMode', config.viewMode);
|
||||
|
||||
// 좌측 패널 너비 상태
|
||||
const [leftWidth, setLeftWidth] = useState(config.leftPanelWidth);
|
||||
|
|
@ -112,7 +116,7 @@ export function V2CategoryManagerComponent({
|
|||
const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => {
|
||||
const columnName = uniqueKey.split(".")[1];
|
||||
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
|
||||
}, []);
|
||||
}, [setSelectedColumn]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex h-full min-h-[10px] gap-0 overflow-hidden" style={{ height: config.height }}>
|
||||
|
|
@ -181,6 +185,7 @@ export function V2CategoryManagerComponent({
|
|||
tableName={selectedColumn.tableName}
|
||||
columnName={selectedColumn.columnName}
|
||||
columnLabel={selectedColumn.columnLabel}
|
||||
screenId={effectiveScreenId}
|
||||
/>
|
||||
) : (
|
||||
<CategoryValueManager
|
||||
|
|
@ -189,6 +194,7 @@ export function V2CategoryManagerComponent({
|
|||
columnName={selectedColumn.columnName}
|
||||
columnLabel={selectedColumn.columnLabel}
|
||||
menuObjid={effectiveMenuObjid}
|
||||
screenId={effectiveScreenId}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PivotGridProps,
|
||||
|
|
@ -327,13 +328,13 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
|||
// 🆕 초기 로드 시 자동 확장 (첫 레벨만)
|
||||
const [isInitialExpanded, setIsInitialExpanded] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태
|
||||
const [showFieldPanel, setShowFieldPanel] = usePersistedState('showFieldPanel', false);
|
||||
const [showFieldChooser, setShowFieldChooser] = useState(false);
|
||||
const [drillDownData, setDrillDownData] = useState<{
|
||||
open: boolean;
|
||||
cellData: PivotCellData | null;
|
||||
}>({ open: false, cellData: null });
|
||||
const [showChart, setShowChart] = useState(chartConfig?.enabled || false);
|
||||
const [showChart, setShowChart] = usePersistedState('showChart', chartConfig?.enabled ?? false);
|
||||
const [containerHeight, setContainerHeight] = useState(400);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -997,7 +998,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
|||
}, [stateStorageKey, initialFields]);
|
||||
|
||||
// 필드 숨기기/표시 상태
|
||||
const [hiddenFields, setHiddenFields] = useState<Set<string>>(new Set());
|
||||
const [hiddenFields, setHiddenFields] = usePersistedState<Set<string>>('hiddenFields', new Set());
|
||||
|
||||
const toggleFieldVisibility = useCallback((fieldName: string) => {
|
||||
setHiddenFields((prev) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import React, { useState, useCallback, useEffect, useMemo, useRef, useContext } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import { ComponentRendererProps } from "../../types";
|
||||
import { SplitPanelLayoutConfig } from "./types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
|
@ -68,19 +69,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}) => {
|
||||
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
|
||||
|
||||
// 🐛 디버깅: 로드 시 rightPanel.components 확인
|
||||
const rightComps = componentConfig.rightPanel?.components || [];
|
||||
const finishedTimeline = rightComps.find((c: any) => c.id === "finished_timeline");
|
||||
if (finishedTimeline) {
|
||||
const fm = finishedTimeline.componentConfig?.fieldMapping;
|
||||
console.log("🔍 [SplitPanelLayout] finished_timeline fieldMapping:", {
|
||||
componentId: finishedTimeline.id,
|
||||
fieldMapping: fm ? JSON.stringify(fm) : "undefined",
|
||||
fieldMappingKeys: fm ? Object.keys(fm) : [],
|
||||
fieldMappingId: fm?.id,
|
||||
fullComponentConfig: JSON.stringify(finishedTimeline.componentConfig || {}, null, 2),
|
||||
});
|
||||
}
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능)
|
||||
const companyCode = (props as any).companyCode as string | undefined;
|
||||
|
||||
|
|
@ -188,11 +176,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
||||
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
||||
|
||||
// TSP: 분할패널 상태 자동 보존
|
||||
const [selectedLeftItem, setSelectedLeftItemRaw] = usePersistedState<any>('selectedLeftItem', null, { debounce: 0 });
|
||||
const initialCachedLeftItemRef = useRef(selectedLeftItem);
|
||||
const setSelectedLeftItem = useCallback((val: any) => {
|
||||
setSelectedLeftItemRaw(val);
|
||||
}, [setSelectedLeftItemRaw]);
|
||||
const userInteractedLeftRef = useRef(false);
|
||||
const [expandedRightItems, setExpandedRightItems] = usePersistedState<Set<string | number>>('expandedRightItems', new Set());
|
||||
|
||||
// 데이터 상태
|
||||
const [leftData, setLeftData] = useState<any[]>([]);
|
||||
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
||||
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
||||
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
|
||||
const [rightData, setRightData] = useState<any[] | any>(null);
|
||||
const [customLeftSelectedData, setCustomLeftSelectedData] = useState<Record<string, any>>({}); // 커스텀 모드: 좌측 선택 데이터
|
||||
// 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용)
|
||||
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
|
||||
|
|
@ -205,15 +200,51 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
},
|
||||
[(props as any).onSelectedRowsChange],
|
||||
);
|
||||
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||
// (TSP: selectedLeftItem 저장은 usePersistedState가 자동 처리)
|
||||
|
||||
// 좌/우측 패널 스크롤 위치 저장/복원
|
||||
const leftPanelContentRef = useRef<HTMLDivElement>(null);
|
||||
const rightPanelContentRef = useRef<HTMLDivElement>(null);
|
||||
const splitScrollBase = (() => {
|
||||
const sid = (props as any).screenId;
|
||||
return sid != null && component.id ? `tsp-${sid}-${component.id}` : null;
|
||||
})();
|
||||
const leftScrollCacheKey = splitScrollBase ? `${splitScrollBase}-lscroll` : null;
|
||||
const rightScrollCacheKey = splitScrollBase ? `${splitScrollBase}-rscroll` : null;
|
||||
const scrollRestoredRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const attachScroll = (el: HTMLElement | null, cacheKey: string | null) => {
|
||||
if (!el || !cacheKey) return () => {};
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const handler = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const t = target === el ? el : target;
|
||||
if (t.scrollTop > 0 || t.scrollLeft > 0) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
try { sessionStorage.setItem(cacheKey, JSON.stringify({ top: t.scrollTop, left: t.scrollLeft })); } catch {}
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
el.addEventListener("scroll", handler, true);
|
||||
return () => { el.removeEventListener("scroll", handler, true); clearTimeout(timer); };
|
||||
};
|
||||
const cleanL = attachScroll(leftPanelContentRef.current, leftScrollCacheKey);
|
||||
const cleanR = attachScroll(rightPanelContentRef.current, rightScrollCacheKey);
|
||||
return () => { cleanL(); cleanR(); };
|
||||
}, [leftScrollCacheKey, rightScrollCacheKey]);
|
||||
|
||||
// TSP: UI 상태 자동 보존
|
||||
const [leftSearchQuery, setLeftSearchQuery] = usePersistedState('leftSearchQuery', '');
|
||||
const [rightSearchQuery, setRightSearchQuery] = usePersistedState('rightSearchQuery', '');
|
||||
const [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]);
|
||||
const [expandedItems, setExpandedItems] = usePersistedState<Set<any>>('expandedItems', new Set());
|
||||
|
||||
// 추가 탭 관련 상태
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0); // 0 = 기본 탭, 1+ = 추가 탭
|
||||
const [activeTabIndex, setActiveTabIndex] = usePersistedState('activeTabIndex', 0);
|
||||
const [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
|
||||
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
|
||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
||||
|
|
@ -225,6 +256,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
Record<string, Record<string, { label: string; color?: string }>>
|
||||
>({}); // 우측 카테고리 매핑
|
||||
|
||||
// (TSP: UI 상태 저장은 usePersistedState가 자동 처리)
|
||||
|
||||
// 🆕 커스텀 모드: 드래그/리사이즈 상태
|
||||
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
|
@ -1555,6 +1588,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시)
|
||||
const handleLeftItemSelect = useCallback(
|
||||
(item: any) => {
|
||||
userInteractedLeftRef.current = true;
|
||||
// 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀)
|
||||
const leftPk = componentConfig.rightPanel?.relation?.leftColumn ||
|
||||
componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn;
|
||||
|
|
@ -1568,6 +1602,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
setExpandedRightItems(new Set());
|
||||
setTabsData({});
|
||||
|
||||
// 부모에게 선택 해제 전파 (탭 상태 캐시용)
|
||||
(props as any).onSelectedRowsChange?.([], []);
|
||||
|
||||
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||
if (mainRelationType === "detail") {
|
||||
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
|
||||
|
|
@ -1593,6 +1630,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
setSelectedLeftItem(item);
|
||||
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
|
||||
|
||||
// 부모에게 선택 전파 (탭 상태 캐시용)
|
||||
(props as any).onSelectedRowsChange?.([item], [item]);
|
||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||
setTabsData({}); // 모든 탭 데이터 초기화
|
||||
|
||||
|
|
@ -2809,6 +2849,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isDesignMode, componentConfig.autoLoad]);
|
||||
|
||||
// F5 새로고침 후 캐시된 좌측 선택 항목 복원
|
||||
const selectionRestoredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (selectionRestoredRef.current || isDesignMode) return;
|
||||
if (leftData.length === 0) return;
|
||||
|
||||
// 1순위: usePersistedState에서 복원된 selectedLeftItem
|
||||
if (selectedLeftItem) {
|
||||
selectionRestoredRef.current = true;
|
||||
setCustomLeftSelectedData(selectedLeftItem);
|
||||
loadRightData(selectedLeftItem);
|
||||
if (activeTabIndex > 0) {
|
||||
loadTabData(activeTabIndex, selectedLeftItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2순위: page-level selectedRowsData에서 복원
|
||||
const parentSelectedRows = (props as any).selectedRowsData;
|
||||
if (parentSelectedRows && parentSelectedRows.length > 0) {
|
||||
const cachedItem = parentSelectedRows[0];
|
||||
selectionRestoredRef.current = true;
|
||||
setSelectedLeftItem(cachedItem);
|
||||
setCustomLeftSelectedData(cachedItem);
|
||||
loadRightData(cachedItem);
|
||||
if (activeTabIndex > 0) {
|
||||
loadTabData(activeTabIndex, cachedItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [leftData, isDesignMode]);
|
||||
|
||||
// 좌/우 패널 스크롤 위치 복원 (데이터 로드 완료 후)
|
||||
useEffect(() => {
|
||||
if (scrollRestoredRef.current || isDesignMode) return;
|
||||
if (isLoadingLeft || isLoadingRight) return;
|
||||
|
||||
const restoreScroll = (panelRef: React.RefObject<HTMLDivElement | null>, cacheKey: string | null) => {
|
||||
if (!panelRef.current || !cacheKey) return;
|
||||
const saved = sessionStorage.getItem(cacheKey);
|
||||
if (!saved) return;
|
||||
|
||||
let top = 0, left = 0;
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
top = parsed.top ?? 0;
|
||||
left = parsed.left ?? 0;
|
||||
} catch {
|
||||
top = parseInt(saved, 10) || 0;
|
||||
}
|
||||
if (top <= 0 && left <= 0) return;
|
||||
|
||||
const apply = (el: HTMLElement) => {
|
||||
if (top > 0) el.scrollTop = top;
|
||||
if (left > 0) el.scrollLeft = left;
|
||||
};
|
||||
|
||||
if (panelRef.current.scrollHeight > panelRef.current.clientHeight ||
|
||||
panelRef.current.scrollWidth > panelRef.current.clientWidth) {
|
||||
apply(panelRef.current);
|
||||
} else {
|
||||
const scrollable = panelRef.current.querySelector("[class*='overflow-auto']") as HTMLElement;
|
||||
if (scrollable) apply(scrollable);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
restoreScroll(leftPanelContentRef, leftScrollCacheKey);
|
||||
restoreScroll(rightPanelContentRef, rightScrollCacheKey);
|
||||
scrollRestoredRef.current = true;
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [leftData, rightData, isLoadingLeft, isLoadingRight, isDesignMode, leftScrollCacheKey, rightScrollCacheKey]);
|
||||
|
||||
// 🔄 필터 변경 시 데이터 다시 로드
|
||||
useEffect(() => {
|
||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||
|
|
@ -2948,9 +3063,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
<CardContent ref={leftPanelContentRef} className="flex-1 overflow-auto p-4">
|
||||
{/* 좌측 데이터 목록/테이블/커스텀 */}
|
||||
{console.log("🔍 [SplitPanel] 왼쪽 패널 displayMode:", componentConfig.leftPanel?.displayMode, "isDesignMode:", isDesignMode)}
|
||||
{componentConfig.leftPanel?.displayMode === "custom" ? (
|
||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||
<div
|
||||
|
|
@ -3087,7 +3201,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}}
|
||||
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
|
||||
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
|
||||
console.log("🔍 [SplitPanel-Left] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id });
|
||||
// 탭 내 컴포넌트 선택 상태 업데이트
|
||||
setNestedTabSelectedCompId(compId);
|
||||
// 부모 분할 패널 정보와 함께 전역 이벤트 발생
|
||||
|
|
@ -3171,9 +3284,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
onFormDataChange={(data: any) => {
|
||||
// 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처
|
||||
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
||||
userInteractedLeftRef.current = true;
|
||||
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
||||
setSelectedLeftItem(data.selectedRowsData[0]);
|
||||
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
|
||||
// 사용자가 아직 상호작용하지 않았고 캐시값이 있으면 초기화 시 리셋 방지
|
||||
if (!userInteractedLeftRef.current && initialCachedLeftItemRef.current) {
|
||||
return;
|
||||
}
|
||||
setCustomLeftSelectedData({});
|
||||
setSelectedLeftItem(null);
|
||||
}
|
||||
|
|
@ -3743,7 +3861,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex-1 overflow-hidden p-4">
|
||||
<CardContent ref={rightPanelContentRef} className="flex-1 overflow-hidden p-4">
|
||||
{/* 추가 탭 컨텐츠 */}
|
||||
{activeTabIndex > 0 ? (
|
||||
(() => {
|
||||
|
|
@ -4147,7 +4265,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}}
|
||||
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
|
||||
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
|
||||
console.log("🔍 [SplitPanel-Right] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id });
|
||||
// 탭 내 컴포넌트 선택 상태 업데이트
|
||||
setNestedTabSelectedCompId(compId);
|
||||
// 부모 분할 패널 정보와 함께 전역 이벤트 발생
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import {
|
||||
TableGroupedConfig,
|
||||
GroupState,
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
UseGroupedDataResult,
|
||||
} from "../types";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
|
||||
/**
|
||||
* 그룹 요약 데이터 계산
|
||||
|
|
@ -105,22 +106,17 @@ function formatGroupLabel(
|
|||
export function useGroupedData(
|
||||
config: TableGroupedConfig,
|
||||
externalData?: any[],
|
||||
searchFilters?: Record<string, any>
|
||||
searchFilters?: Record<string, any>,
|
||||
): UseGroupedDataResult {
|
||||
// 원본 데이터
|
||||
const [rawData, setRawData] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 그룹 펼침 상태 관리
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
// 사용자가 수동으로 펼침/접기를 조작했는지 여부
|
||||
const [isManuallyControlled, setIsManuallyControlled] = useState(false);
|
||||
|
||||
// 선택 상태 관리
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
// TSP: 그룹 펼침 / 선택 상태 (자동 보존)
|
||||
const [expandedGroups, setExpandedGroups] = usePersistedState<Set<string>>('expandedGroups', new Set());
|
||||
const [isManuallyControlled, setIsManuallyControlled] = usePersistedState('isManuallyControlled', false);
|
||||
const [selectedItemIds, setSelectedItemIds] = usePersistedState<Set<string>>('selectedItemIds', new Set());
|
||||
|
||||
// 테이블명 결정
|
||||
const tableName = config.useCustomTable
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef, useContext } from "react";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
import { TableListConfig, ColumnConfig } from "./types";
|
||||
import { WebType } from "@/types/common";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
|
@ -435,7 +436,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const newSearchValues: Record<string, any> = {};
|
||||
filters.forEach((filter) => {
|
||||
if (filter.value) {
|
||||
// operator 정보도 함께 전달 (백엔드에서 equals/contains 구분)
|
||||
newSearchValues[filter.columnName] = {
|
||||
value: filter.value,
|
||||
operator: filter.operator || "contains",
|
||||
|
|
@ -443,10 +443,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
// filters → searchValues 변환 완료
|
||||
|
||||
setSearchValues(newSearchValues);
|
||||
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
||||
|
||||
const filtersKey = JSON.stringify(filters);
|
||||
if (prevFiltersKeyRef.current !== null && prevFiltersKeyRef.current !== filtersKey) {
|
||||
setCurrentPage(1);
|
||||
}
|
||||
prevFiltersKeyRef.current = filtersKey;
|
||||
}, [filters]);
|
||||
|
||||
// grouping이 변경되면 groupByColumns 업데이트
|
||||
|
|
@ -640,13 +643,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return result;
|
||||
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
// TSP: 테이블 상태 자동 보존
|
||||
const [currentPage, setCurrentPage] = usePersistedState('currentPage', 1);
|
||||
const prevFiltersKeyRef = useRef<string | null>(null);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const hasInitializedSort = useRef(false);
|
||||
const [searchTerm, setSearchTerm] = usePersistedState('searchTerm', '');
|
||||
const [sortColumn, setSortColumn] = usePersistedState<string | null>('sortColumn', null);
|
||||
const [sortDirection, setSortDirection] = usePersistedState<"asc" | "desc">('sortDirection', "asc");
|
||||
const hasInitializedSort = useRef(sortColumn != null);
|
||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||
const [tableLabel, setTableLabel] = useState<string>("");
|
||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
||||
|
|
@ -662,8 +667,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
Record<string, Record<string, { label: string; color?: string }>>
|
||||
>({});
|
||||
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
|
||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||
const [searchValues, setSearchValues] = usePersistedState<Record<string, any>>('searchValues', {});
|
||||
const [selectedRows, setSelectedRows] = usePersistedState<Set<string>>('selectedRows', new Set());
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
// columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요)
|
||||
|
|
@ -676,8 +681,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
||||
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
||||
|
||||
// 🆕 키보드 네비게이션 관련 상태
|
||||
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||||
// TSP: 스크롤 위치 (자동 보존)
|
||||
const tableScrollRef = useRef({ top: 0, left: 0 });
|
||||
const tableScrollRestoredRef = useRef(false);
|
||||
const [_scrollPos, _setScrollPos] = usePersistedState<{ top: number; left: number }>('scrollPos', { top: 0, left: 0 });
|
||||
const scrollPosRef = useRef(_scrollPos);
|
||||
|
||||
// 스크롤 위치 복원 (데이터 로드 후)
|
||||
useEffect(() => {
|
||||
if (tableScrollRestoredRef.current || !scrollContainerRef.current || !data || data.length === 0) return;
|
||||
const { top, left } = scrollPosRef.current;
|
||||
if (top <= 0 && left <= 0) { tableScrollRestoredRef.current = true; return; }
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el) return;
|
||||
if (top > 0) el.scrollTop = top;
|
||||
if (left > 0) el.scrollLeft = left;
|
||||
tableScrollRestoredRef.current = true;
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [data]);
|
||||
|
||||
// 🆕 키보드 네비게이션 관련 상태 (자동 보존)
|
||||
const [focusedCell, setFocusedCell] = usePersistedState<{ rowIndex: number; colIndex: number } | null>('focusedCell', null);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 🆕 인라인 셀 편집 관련 상태
|
||||
|
|
@ -733,13 +760,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 그룹 설정 관련 상태
|
||||
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [collapsedGroups, setCollapsedGroups] = usePersistedState<Set<string>>('collapsedGroups', new Set());
|
||||
|
||||
// 🆕 그룹별 합산 설정 상태
|
||||
const [groupSumConfig, setGroupSumConfig] = useState<GroupSumConfig | null>(null);
|
||||
|
||||
// 🆕 Master-Detail 관련 상태
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set()); // 확장된 행 키 목록
|
||||
const [expandedRows, setExpandedRows] = usePersistedState<Set<string>>('expandedRows', new Set());
|
||||
const [detailData, setDetailData] = useState<Record<string, any[]>>({}); // 상세 데이터 캐시
|
||||
|
||||
// 🆕 Drag & Drop 재정렬 관련 상태
|
||||
|
|
@ -793,7 +820,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||
|
||||
// 🆕 Search Panel (통합 검색) 관련 상태
|
||||
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
|
||||
const [globalSearchTerm, setGlobalSearchTerm] = usePersistedState('globalSearchTerm', '');
|
||||
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
||||
const [searchHighlights, setSearchHighlights] = useState<Set<string>>(new Set()); // "rowIndex-colIndex" 형식
|
||||
|
||||
|
|
@ -801,6 +828,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false);
|
||||
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
||||
|
||||
// (TSP: 상태 저장은 usePersistedState가 자동 처리)
|
||||
|
||||
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
|
||||
useEffect(() => {
|
||||
const linkedFilters = tableConfig.linkedFilters;
|
||||
|
|
@ -2868,13 +2897,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조)
|
||||
|
||||
// 🆕 Virtual Scrolling: 스크롤 핸들러
|
||||
// 🆕 Virtual Scrolling: 스크롤 핸들러 + 스크롤 위치 추적
|
||||
const handleVirtualScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!isVirtualScrollEnabled) return;
|
||||
setScrollTop(e.currentTarget.scrollTop);
|
||||
const target = e.currentTarget;
|
||||
if (isVirtualScrollEnabled) {
|
||||
setScrollTop(target.scrollTop);
|
||||
}
|
||||
tableScrollRef.current = { top: target.scrollTop, left: target.scrollLeft };
|
||||
_setScrollPos({ top: target.scrollTop, left: target.scrollLeft });
|
||||
},
|
||||
[isVirtualScrollEnabled],
|
||||
[isVirtualScrollEnabled, _setScrollPos],
|
||||
);
|
||||
|
||||
// 🆕 State Persistence: 통합 상태 저장
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ export function TimelineSchedulerComponent({
|
|||
goToNext,
|
||||
goToToday,
|
||||
updateSchedule,
|
||||
} = useTimelineData(config, externalSchedules, externalResources);
|
||||
} = useTimelineData(
|
||||
config,
|
||||
externalSchedules,
|
||||
externalResources,
|
||||
);
|
||||
|
||||
const isLoading = externalLoading ?? hookLoading;
|
||||
const error = externalError ?? hookError;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { apiClient } from "@/lib/api/client";
|
|||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types";
|
||||
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
||||
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||
|
||||
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
|
||||
const SCHEDULE_TABLE = "schedule_mng";
|
||||
|
|
@ -38,17 +39,16 @@ export function useTimelineData(
|
|||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(config.defaultZoomLevel || "day");
|
||||
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
|
||||
if (config.initialDate) {
|
||||
return new Date(config.initialDate);
|
||||
}
|
||||
// 오늘 기준 1주일 전부터 시작
|
||||
|
||||
// TSP: 줌 레벨 / 시작 날짜 (자동 보존)
|
||||
const [zoomLevel, setZoomLevel] = usePersistedState<ZoomLevel>('zoomLevel', config.defaultZoomLevel || "day");
|
||||
const [viewStartDate, setViewStartDate] = usePersistedState<Date>('viewStartDate', (() => {
|
||||
if (config.initialDate) return new Date(config.initialDate);
|
||||
const today = new Date();
|
||||
today.setDate(today.getDate() - 7);
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return today;
|
||||
});
|
||||
})());
|
||||
|
||||
// 선택된 품목 코드 (좌측 테이블에서 선택된 데이터 기준)
|
||||
const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue