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 { AuthProvider } from "@/contexts/AuthContext";
|
||||||
import { MenuProvider } from "@/contexts/MenuContext";
|
import { MenuProvider } from "@/contexts/MenuContext";
|
||||||
|
import { TabProvider } from "@/contexts/TabContext";
|
||||||
import { AppLayout } from "@/components/layout/AppLayout";
|
import { AppLayout } from "@/components/layout/AppLayout";
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<MenuProvider>
|
<MenuProvider>
|
||||||
|
<TabProvider>
|
||||||
<AppLayout>{children}</AppLayout>
|
<AppLayout>{children}</AppLayout>
|
||||||
|
</TabProvider>
|
||||||
</MenuProvider>
|
</MenuProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,16 +29,64 @@ import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; //
|
||||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
||||||
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
|
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 { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const screenId = parseInt(params.screenId as string);
|
const screenId = screenIdProp || parseInt(params.screenId as string);
|
||||||
|
|
||||||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
// props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
|
||||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
const menuObjid = menuObjidProp || (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined);
|
||||||
|
|
||||||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
||||||
const previewCompanyCode = searchParams.get("company_code");
|
const previewCompanyCode = searchParams.get("company_code");
|
||||||
|
|
@ -63,16 +111,39 @@ function ScreenViewPage() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [tableSortBy, setTableSortBy] = useState<string | undefined>(
|
||||||
const [tableSortOrder, setTableSortOrder] = useState<"asc" | "desc">("asc");
|
() => initialCache?.tableSortBy
|
||||||
const [tableColumnOrder, setTableColumnOrder] = useState<string[] | undefined>();
|
);
|
||||||
const [tableDisplayData, setTableDisplayData] = useState<any[]>([]); // 화면에 표시된 데이터 (컬럼 순서 포함)
|
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[]>([]);
|
const [flowSelectedData, setFlowSelectedData] = useState<any[]>([]);
|
||||||
|
|
@ -122,6 +193,139 @@ function ScreenViewPage() {
|
||||||
initComponents();
|
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(() => {
|
useEffect(() => {
|
||||||
const handleOpenEditModal = (event: CustomEvent) => {
|
const handleOpenEditModal = (event: CustomEvent) => {
|
||||||
|
|
@ -384,6 +588,8 @@ function ScreenViewPage() {
|
||||||
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||||
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (cacheRestoredRef.current) return;
|
||||||
|
|
||||||
const loadMainTableData = async () => {
|
const loadMainTableData = async () => {
|
||||||
if (!screen || !layout || !layout.components || !companyCode) {
|
if (!screen || !layout || !layout.components || !companyCode) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -466,6 +672,8 @@ function ScreenViewPage() {
|
||||||
|
|
||||||
// 🆕 개별 autoFill 처리 (메인 테이블과 다른 테이블에서 조회하는 경우)
|
// 🆕 개별 autoFill 처리 (메인 테이블과 다른 테이블에서 조회하는 경우)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (cacheRestoredRef.current) return;
|
||||||
|
|
||||||
const initAutoFill = async () => {
|
const initAutoFill = async () => {
|
||||||
if (!layout || !layout.components || !user) {
|
if (!layout || !layout.components || !user) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -567,13 +775,13 @@ function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [conditionalFieldValues, layout?.components]);
|
}, [conditionalFieldValues, layout?.components]);
|
||||||
|
|
||||||
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일) - 초기 로딩 시에만 계산
|
// 캔버스 비율 조정 (사용자 화면에 맞게 자동 스케일)
|
||||||
// 브라우저 배율 조정 시 메뉴와 화면이 함께 축소/확대되도록 resize 이벤트는 감지하지 않음
|
// display:none 상태(비활성 탭)에서는 offsetWidth가 0이므로 건너뛰고,
|
||||||
|
// ResizeObserver로 탭이 보이게 될 때 자동 재계산
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 모바일 환경에서는 스케일 조정 비활성화 (반응형만 작동)
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setScale(1);
|
setScale(1);
|
||||||
setLayoutReady(true); // 모바일에서도 레이아웃 준비 완료 표시
|
setLayoutReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -582,57 +790,52 @@ function ScreenViewPage() {
|
||||||
const designWidth = layout?.screenResolution?.width || 1200;
|
const designWidth = layout?.screenResolution?.width || 1200;
|
||||||
const designHeight = layout?.screenResolution?.height || 800;
|
const designHeight = layout?.screenResolution?.height || 800;
|
||||||
|
|
||||||
// 컨테이너의 실제 크기 (프리뷰 모드에서는 window 크기 사용)
|
let cw: number;
|
||||||
let containerWidth: number;
|
let ch: number;
|
||||||
let containerHeight: number;
|
|
||||||
|
|
||||||
if (isPreviewMode) {
|
if (isPreviewMode) {
|
||||||
// iframe에서는 window 크기를 직접 사용
|
cw = window.innerWidth;
|
||||||
containerWidth = window.innerWidth;
|
ch = window.innerHeight;
|
||||||
containerHeight = window.innerHeight;
|
|
||||||
} else {
|
} else {
|
||||||
containerWidth = containerRef.current.offsetWidth;
|
cw = containerRef.current.offsetWidth;
|
||||||
containerHeight = containerRef.current.offsetHeight;
|
ch = containerRef.current.offsetHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비활성 탭(display:none)이면 offsetWidth=0 → 스케일 계산 건너뛰기
|
||||||
|
if (cw === 0) return;
|
||||||
|
|
||||||
let newScale: number;
|
let newScale: number;
|
||||||
|
|
||||||
if (isPreviewMode) {
|
if (isPreviewMode) {
|
||||||
// 프리뷰 모드: 가로/세로 모두 fit하도록 (여백 없이)
|
const scaleX = cw / designWidth;
|
||||||
const scaleX = containerWidth / designWidth;
|
const scaleY = ch / designHeight;
|
||||||
const scaleY = containerHeight / designHeight;
|
newScale = Math.min(scaleX, scaleY, 1);
|
||||||
newScale = Math.min(scaleX, scaleY, 1); // 최대 1배율
|
|
||||||
} else {
|
} else {
|
||||||
// 일반 모드: 가로 기준 스케일 (좌우 여백 16px씩 고정)
|
|
||||||
const MARGIN_X = 32;
|
const MARGIN_X = 32;
|
||||||
const availableWidth = containerWidth - MARGIN_X;
|
const availableWidth = cw - MARGIN_X;
|
||||||
newScale = availableWidth / designWidth;
|
newScale = availableWidth / designWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("📐 스케일 계산:", {
|
|
||||||
// containerWidth,
|
|
||||||
// containerHeight,
|
|
||||||
// designWidth,
|
|
||||||
// designHeight,
|
|
||||||
// finalScale: newScale,
|
|
||||||
// isPreviewMode,
|
|
||||||
// });
|
|
||||||
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
// 컨테이너 너비 업데이트
|
setContainerWidth(cw);
|
||||||
setContainerWidth(containerWidth);
|
|
||||||
|
|
||||||
// 스케일 계산 완료 후 레이아웃 준비 완료 표시
|
|
||||||
setLayoutReady(true);
|
setLayoutReady(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기 측정 (한 번만 실행)
|
|
||||||
const timer = setTimeout(updateScale, 100);
|
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 () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
};
|
};
|
||||||
}, [layout, isMobile, isPreviewMode]);
|
}, [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() {
|
function ScreenViewPageWrapper() {
|
||||||
return (
|
return (
|
||||||
<TableSearchWidgetHeightProvider>
|
<TableSearchWidgetHeightProvider>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ import { MenuItem } from "@/lib/api/menu";
|
||||||
import { menuScreenApi } from "@/lib/api/screen";
|
import { menuScreenApi } from "@/lib/api/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useTab } from "@/contexts/TabContext";
|
||||||
|
import { TabBar } from "./TabBar";
|
||||||
|
import { TabContent } from "./TabContent";
|
||||||
import { ProfileModal } from "./ProfileModal";
|
import { ProfileModal } from "./ProfileModal";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
import { SideMenu } from "./SideMenu";
|
import { SideMenu } from "./SideMenu";
|
||||||
|
|
@ -71,7 +74,7 @@ interface ExtendedUserInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppLayoutProps {
|
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) {
|
function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { user, logout, refreshUserData, switchCompany } = useAuth();
|
const { user, logout, refreshUserData, switchCompany } = useAuth();
|
||||||
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
|
||||||
|
const { openTab, tabs, activeTabId } = useTab();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
@ -249,6 +267,23 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
fetchCurrentCompanyName();
|
fetchCurrentCompanyName();
|
||||||
}, [(user as ExtendedUserInfo)?.companyCode, (user as ExtendedUserInfo)?.userType]);
|
}, [(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(() => {
|
useEffect(() => {
|
||||||
const checkIsMobile = () => {
|
const checkIsMobile = () => {
|
||||||
|
|
@ -320,8 +355,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
setExpandedMenus(newExpanded);
|
setExpandedMenus(newExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 메뉴 클릭 핸들러
|
// 메뉴 클릭 핸들러 (탭 시스템 통합)
|
||||||
const handleMenuClick = async (menu: any) => {
|
// parentMenuName: 사이드바에서 자식 메뉴 클릭 시 부모 이름 직접 전달
|
||||||
|
const handleMenuClick = async (menu: any, parentMenuName?: string) => {
|
||||||
if (menu.hasChildren) {
|
if (menu.hasChildren) {
|
||||||
toggleMenu(menu.id);
|
toggleMenu(menu.id);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -337,19 +373,33 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||||
|
|
||||||
if (assignedScreens.length > 0) {
|
if (assignedScreens.length > 0) {
|
||||||
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
|
||||||
const firstScreen = assignedScreens[0];
|
const firstScreen = assignedScreens[0];
|
||||||
|
|
||||||
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
|
// 상위 카테고리 이름: 직접 전달받은 값 우선, 없으면 트리에서 탐색
|
||||||
const params = new URLSearchParams();
|
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) {
|
if (isAdminMode) {
|
||||||
params.set("mode", "admin");
|
urlParams.set("mode", "admin");
|
||||||
}
|
}
|
||||||
params.set("menuObjid", menuObjid.toString());
|
urlParams.set("menuObjid", menuObjid.toString());
|
||||||
|
const screenPath = `/screens/${firstScreen.screenId}?${urlParams.toString()}`;
|
||||||
|
router.replace(screenPath);
|
||||||
|
|
||||||
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
|
|
||||||
|
|
||||||
router.push(screenPath);
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
|
|
@ -366,7 +416,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// URL도 없고 할당된 화면도 없으면 경고 메시지
|
|
||||||
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -428,12 +477,23 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
{menu.children?.map((child: any) => (
|
{menu.children?.map((child: any) => (
|
||||||
<div
|
<div
|
||||||
key={child.id}
|
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 ${
|
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
|
||||||
pathname === child.url
|
pathname === child.url
|
||||||
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
? "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"
|
: "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">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
{child.icon}
|
{child.icon}
|
||||||
|
|
@ -695,9 +755,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
{/* 가운데 컨텐츠 영역 */}
|
||||||
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
<main className={`flex min-w-0 flex-1 flex-col bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||||
{children}
|
{/* 탭 바 (데스크톱에서만 항상 표시) */}
|
||||||
|
{!isMobile && <TabBar />}
|
||||||
|
|
||||||
|
{/* 콘텐츠 영역 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<TabContent />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from "react";
|
import React, { useState, useRef, useCallback } from "react";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||||
import { GripVertical } from "lucide-react";
|
import { GripVertical } from "lucide-react";
|
||||||
|
|
@ -48,12 +49,12 @@ export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...p
|
||||||
// menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값
|
// menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값
|
||||||
const effectiveMenuObjid = menuObjid || props.menuObjid;
|
const effectiveMenuObjid = menuObjid || props.menuObjid;
|
||||||
|
|
||||||
const [selectedColumn, setSelectedColumn] = useState<{
|
const [selectedColumn, setSelectedColumn] = usePersistedState<{
|
||||||
uniqueKey: string; // 테이블명.컬럼명 형식
|
uniqueKey: string;
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
} | null>(null);
|
} | null>('selectedColumn', null);
|
||||||
|
|
||||||
const [leftWidth, setLeftWidth] = useState(15); // 초기값 15%
|
const [leftWidth, setLeftWidth] = useState(15); // 초기값 15%
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export function TabsWidget({
|
||||||
} = component;
|
} = component;
|
||||||
|
|
||||||
const storageKey = `tabs-${component.id}-selected`;
|
const storageKey = `tabs-${component.id}-selected`;
|
||||||
|
const sessionStorageKey = `tabs-session-${menuObjid || "g"}-${component.id}`;
|
||||||
|
|
||||||
// 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용)
|
// 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용)
|
||||||
// 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식
|
// 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식
|
||||||
|
|
@ -89,14 +90,23 @@ export function TabsWidget({
|
||||||
[externalOnSelectedRowsChange],
|
[externalOnSelectedRowsChange],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 초기 선택 탭 결정
|
// 초기 선택 탭 결정 (sessionStorage 우선 → localStorage → 기본값)
|
||||||
const getInitialTab = () => {
|
const getInitialTab = () => {
|
||||||
if (persistSelection && typeof window !== "undefined") {
|
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);
|
const saved = localStorage.getItem(storageKey);
|
||||||
if (saved && tabs.some((t) => t.id === saved)) {
|
if (saved && tabs.some((t) => t.id === saved)) {
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return defaultTab || tabs[0]?.id || "";
|
return defaultTab || tabs[0]?.id || "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -135,22 +145,32 @@ export function TabsWidget({
|
||||||
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||||
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||||
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||||
// 탭별 화면 정보 (screenId, tableName) - 인라인 컴포넌트의 테이블 설정에서 추출
|
// 탭별 화면 정보 (screenId, tableName) - screenId 기반 탭과 인라인 컴포넌트 모두 포함
|
||||||
const screenInfoMap = React.useMemo(() => {
|
const screenInfoMap = React.useMemo(() => {
|
||||||
const map: Record<string, { id?: number; tableName?: string }> = {};
|
const map: Record<string, { id?: number; tableName?: string }> = {};
|
||||||
for (const tab of tabs as ExtendedTabItem[]) {
|
for (const tab of tabs as ExtendedTabItem[]) {
|
||||||
const inlineComponents = tab.components || [];
|
const inlineComponents = tab.components || [];
|
||||||
|
|
||||||
|
// screenId 기반 탭 (별도 화면 로드 방식)
|
||||||
|
if (tab.screenId != null && inlineComponents.length === 0) {
|
||||||
|
map[tab.id] = { id: tab.screenId };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인라인 컴포넌트 탭
|
||||||
if (inlineComponents.length > 0) {
|
if (inlineComponents.length > 0) {
|
||||||
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
|
|
||||||
const tableComp = inlineComponents.find(
|
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;
|
const selectedTable = tableComp?.componentConfig?.selectedTable;
|
||||||
if (selectedTable || tab.screenId) {
|
if (selectedTable || tab.screenId != null) {
|
||||||
map[tab.id] = {
|
const entry: { id?: number; tableName?: string } = {};
|
||||||
id: tab.screenId,
|
if (tab.screenId != null) entry.id = tab.screenId;
|
||||||
tableName: selectedTable,
|
if (selectedTable) entry.tableName = selectedTable;
|
||||||
};
|
map[tab.id] = entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -193,11 +213,14 @@ export function TabsWidget({
|
||||||
loadScreenLayouts();
|
loadScreenLayouts();
|
||||||
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
||||||
|
|
||||||
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
// 선택된 탭 변경 시 storage에 저장 + ActiveTab Context 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (persistSelection && typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
|
try { sessionStorage.setItem(sessionStorageKey, selectedTab); } catch { /* 무시 */ }
|
||||||
|
if (persistSelection) {
|
||||||
localStorage.setItem(storageKey, selectedTab);
|
localStorage.setItem(storageKey, selectedTab);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab);
|
const currentTabInfo = visibleTabs.find((t) => t.id === selectedTab);
|
||||||
if (currentTabInfo) {
|
if (currentTabInfo) {
|
||||||
|
|
@ -207,7 +230,7 @@ export function TabsWidget({
|
||||||
label: currentTabInfo.label,
|
label: currentTabInfo.label,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [selectedTab, persistSelection, storageKey, component.id, visibleTabs, setActiveTab]);
|
}, [selectedTab, persistSelection, storageKey, sessionStorageKey, component.id, visibleTabs, setActiveTab]);
|
||||||
|
|
||||||
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
|
// 컴포넌트 언마운트 시 ActiveTab Context에서 제거
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -412,11 +435,11 @@ export function TabsWidget({
|
||||||
onSelectedRowsChange={handleSelectedRowsChange}
|
onSelectedRowsChange={handleSelectedRowsChange}
|
||||||
parentTabId={tab.id}
|
parentTabId={tab.id}
|
||||||
parentTabsComponentId={component.id}
|
parentTabsComponentId={component.id}
|
||||||
// 탭에 screenId가 있으면 해당 화면의 tableName/screenId로 오버라이드
|
// 탭에 screenId/tableName이 있으면 오버라이드 (undefined로 부모값을 덮어쓰지 않도록 조건부 적용)
|
||||||
{...(screenInfoMap[tab.id]
|
{...(screenInfoMap[tab.id]
|
||||||
? {
|
? {
|
||||||
tableName: screenInfoMap[tab.id].tableName,
|
...(screenInfoMap[tab.id].tableName != null && { tableName: screenInfoMap[tab.id].tableName }),
|
||||||
screenId: screenInfoMap[tab.id].id,
|
...(screenInfoMap[tab.id].id != null && { screenId: screenInfoMap[tab.id].id }),
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
@ -30,6 +31,7 @@ interface CategoryValueManagerProps {
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
onValueCountChange?: (count: number) => void;
|
onValueCountChange?: (count: number) => void;
|
||||||
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
|
||||||
|
screenId?: number | string; // 비활성 탭 F5 복원용 캐시 키
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
|
|
@ -38,20 +40,20 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
|
||||||
columnLabel,
|
columnLabel,
|
||||||
onValueCountChange,
|
onValueCountChange,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
|
screenId,
|
||||||
}) => {
|
}) => {
|
||||||
const { toast } = useToast();
|
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 [values, setValues] = useState<TableCategoryValue[]>([]);
|
||||||
const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>(
|
const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>([]);
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [selectedValueIds, setSelectedValueIds] = useState<number[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(
|
const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(null);
|
||||||
null
|
|
||||||
);
|
|
||||||
const [showInactive, setShowInactive] = useState(false); // 비활성 항목 표시 옵션 (기본: 숨김)
|
|
||||||
|
|
||||||
// 카테고리 값 로드
|
// 카테고리 값 로드
|
||||||
useEffect(() => {
|
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 {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|
@ -58,6 +59,7 @@ interface CategoryValueManagerTreeProps {
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
onValueCountChange?: (count: number) => void;
|
onValueCountChange?: (count: number) => void;
|
||||||
|
screenId?: number | string; // 비활성 탭 F5 복원용 캐시 키
|
||||||
}
|
}
|
||||||
|
|
||||||
// 트리 노드 컴포넌트
|
// 트리 노드 컴포넌트
|
||||||
|
|
@ -271,15 +273,24 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
columnName,
|
columnName,
|
||||||
columnLabel,
|
columnLabel,
|
||||||
onValueCountChange,
|
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 [tree, setTree] = useState<CategoryValue[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
|
const [selectedValue, setSelectedValueRaw] = useState<CategoryValue | null>(null);
|
||||||
const [selectedValue, setSelectedValue] = useState<CategoryValue | null>(null);
|
const setSelectedValue = useCallback((val: CategoryValue | null) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
setSelectedValueRaw(val);
|
||||||
const [showInactive, setShowInactive] = useState(false);
|
setFocusedValueId(val?.valueId ?? null);
|
||||||
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
|
}, [setFocusedValueId]);
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||||
|
|
@ -386,9 +397,26 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
|
||||||
|
|
||||||
setTree(filteredTree);
|
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) {
|
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);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange],
|
[tableName, columnName, showInactive, countAllValues, filterActiveNodes, onValueCountChange, findNodeById],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
||||||
import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
import { useV2FormOptional } from "@/components/v2/V2FormContext";
|
||||||
|
// TSP (Tab State Persistence) - 컴포넌트별 상태 보존
|
||||||
|
import { TSPProvider } from "@/hooks/usePersistedState";
|
||||||
|
|
||||||
// 컴포넌트 렌더러 인터페이스
|
// 컴포넌트 렌더러 인터페이스
|
||||||
export interface ComponentRenderer {
|
export interface ComponentRenderer {
|
||||||
|
|
@ -669,14 +671,18 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
NewComponentRenderer.prototype.render;
|
NewComponentRenderer.prototype.render;
|
||||||
|
|
||||||
if (isClass) {
|
if (isClass) {
|
||||||
// 클래스 기반 렌더러 (AutoRegisteringComponentRenderer 상속)
|
|
||||||
const rendererInstance = new NewComponentRenderer(rendererProps);
|
const rendererInstance = new NewComponentRenderer(rendererProps);
|
||||||
return rendererInstance.render();
|
return (
|
||||||
|
<TSPProvider screenId={screenId} componentId={component.id}>
|
||||||
|
{rendererInstance.render()}
|
||||||
|
</TSPProvider>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 함수형 컴포넌트
|
return (
|
||||||
// refreshKey를 React key로 전달하여 컴포넌트 리마운트 강제
|
<TSPProvider screenId={screenId} componentId={component.id}>
|
||||||
|
<NewComponentRenderer key={refreshKey} {...rendererProps} />
|
||||||
return <NewComponentRenderer key={refreshKey} {...rendererProps} />;
|
</TSPProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -718,22 +724,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 레거시 시스템에서도 DOM 안전한 props만 전달
|
// 레거시 시스템에서도 DOM 안전한 props만 전달
|
||||||
const safeLegacyProps = filterDOMProps(props);
|
const safeLegacyProps = filterDOMProps(props);
|
||||||
|
|
||||||
return renderer({
|
const legacyRendered = renderer({
|
||||||
component,
|
component,
|
||||||
isSelected,
|
isSelected,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
children,
|
children,
|
||||||
// React 전용 props들은 명시적으로 전달 (레거시 컴포넌트가 필요한 경우)
|
|
||||||
isInteractive: props.isInteractive,
|
isInteractive: props.isInteractive,
|
||||||
formData: props.formData,
|
formData: props.formData,
|
||||||
onFormDataChange: props.onFormDataChange,
|
onFormDataChange: props.onFormDataChange,
|
||||||
screenId: props.screenId,
|
screenId: props.screenId,
|
||||||
tableName: props.tableName,
|
tableName: props.tableName,
|
||||||
userId: props.userId, // 🆕 사용자 ID
|
userId: props.userId,
|
||||||
userName: props.userName, // 🆕 사용자 이름
|
userName: props.userName,
|
||||||
companyCode: props.companyCode, // 🆕 회사 코드
|
companyCode: props.companyCode,
|
||||||
onRefresh: props.onRefresh,
|
onRefresh: props.onRefresh,
|
||||||
onClose: props.onClose,
|
onClose: props.onClose,
|
||||||
mode: props.mode,
|
mode: props.mode,
|
||||||
|
|
@ -743,18 +748,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
onZoneClick: props.onZoneClick,
|
onZoneClick: props.onZoneClick,
|
||||||
onZoneComponentDrop: props.onZoneComponentDrop,
|
onZoneComponentDrop: props.onZoneComponentDrop,
|
||||||
allComponents: props.allComponents,
|
allComponents: props.allComponents,
|
||||||
// 테이블 선택된 행 정보 전달
|
|
||||||
selectedRows: props.selectedRows,
|
selectedRows: props.selectedRows,
|
||||||
selectedRowsData: props.selectedRowsData,
|
selectedRowsData: props.selectedRowsData,
|
||||||
onSelectedRowsChange: props.onSelectedRowsChange,
|
onSelectedRowsChange: props.onSelectedRowsChange,
|
||||||
// 플로우 선택된 데이터 정보 전달
|
|
||||||
flowSelectedData: props.flowSelectedData,
|
flowSelectedData: props.flowSelectedData,
|
||||||
flowSelectedStepId: props.flowSelectedStepId,
|
flowSelectedStepId: props.flowSelectedStepId,
|
||||||
onFlowSelectedDataChange: props.onFlowSelectedDataChange,
|
onFlowSelectedDataChange: props.onFlowSelectedDataChange,
|
||||||
refreshKey: props.refreshKey,
|
refreshKey: props.refreshKey,
|
||||||
// DOM 안전한 props들
|
|
||||||
...safeLegacyProps,
|
...safeLegacyProps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TSPProvider screenId={props.screenId} componentId={component.id}>
|
||||||
|
{legacyRendered}
|
||||||
|
</TSPProvider>
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, Star, Loader2, ExternalLink } from "lucide-react";
|
import { Plus, Star, Loader2, ExternalLink } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -38,7 +39,7 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
|
||||||
style,
|
style,
|
||||||
}) => {
|
}) => {
|
||||||
const [buttons, setButtons] = useState<ButtonItem[]>([]);
|
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 [selectedItem, setSelectedItem] = useState<ButtonItem | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [masterData, setMasterData] = useState<Record<string, any> | null>(null);
|
const [masterData, setMasterData] = useState<Record<string, any> | null>(null);
|
||||||
|
|
@ -160,10 +161,11 @@ export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentPr
|
||||||
|
|
||||||
setButtons(items);
|
setButtons(items);
|
||||||
|
|
||||||
// 자동 선택: 기본 항목 또는 첫 번째 항목
|
// 자동 선택: 캐시 복원 → 기본 항목 → 첫 번째 항목
|
||||||
if (config.autoSelectFirst && items.length > 0) {
|
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 defaultItem = items.find(item => item.isDefault);
|
||||||
const targetItem = defaultItem || items[0];
|
const targetItem = cachedItem || defaultItem || items[0];
|
||||||
setSelectedId(targetItem.id);
|
setSelectedId(targetItem.id);
|
||||||
setSelectedItem(targetItem);
|
setSelectedItem(targetItem);
|
||||||
emitSelection(targetItem);
|
emitSelection(targetItem);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
import { ComponentRendererProps } from "../../types";
|
import { ComponentRendererProps } from "../../types";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig } from "./types";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 [leftData, setLeftData] = useState<any[]>([]);
|
||||||
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
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 [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
|
||||||
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
||||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
||||||
|
|
@ -2286,6 +2287,19 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isDesignMode, componentConfig.autoLoad]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"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 { ComponentRendererProps } from "@/types/component";
|
||||||
import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig, GroupingConfig, ColumnDisplayConfig, TabConfig } from "./types";
|
import { SplitPanelLayout2Config, ColumnConfig, DataTransferField, ActionButtonConfig, JoinTableConfig, GroupingConfig, ColumnDisplayConfig, TabConfig } from "./types";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
|
|
||||||
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
|
export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
|
|
@ -66,12 +67,19 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
// 상태 관리
|
// 상태 관리
|
||||||
const [leftData, setLeftData] = useState<any[]>([]);
|
const [leftData, setLeftData] = useState<any[]>([]);
|
||||||
const [rightData, setRightData] = useState<any[]>([]);
|
const [rightData, setRightData] = useState<any[]>([]);
|
||||||
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
|
||||||
const [leftSearchTerm, setLeftSearchTerm] = useState("");
|
// TSP: 탭 상태 보존 대상 (usePersistedState)
|
||||||
const [rightSearchTerm, setRightSearchTerm] = useState("");
|
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 [leftLoading, setLeftLoading] = useState(false);
|
||||||
const [rightLoading, setRightLoading] = useState(false);
|
const [rightLoading, setRightLoading] = useState(false);
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
|
||||||
const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30);
|
const [splitPosition, setSplitPosition] = useState(config.splitRatio || 30);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
||||||
|
|
@ -79,19 +87,12 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
|
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({});
|
||||||
const [rightColumnLabels, setRightColumnLabels] = 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 [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState<any>(null);
|
const [itemToDelete, setItemToDelete] = useState<any>(null);
|
||||||
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
const [isBulkDelete, setIsBulkDelete] = useState(false);
|
||||||
const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right");
|
const [deleteTargetPanel, setDeleteTargetPanel] = useState<"left" | "right">("right");
|
||||||
|
|
||||||
// 탭 상태 (좌측/우측 각각)
|
|
||||||
const [leftActiveTab, setLeftActiveTab] = useState<string | null>(null);
|
|
||||||
const [rightActiveTab, setRightActiveTab] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 프론트엔드 그룹핑 함수
|
// 프론트엔드 그룹핑 함수
|
||||||
const groupData = useCallback(
|
const groupData = useCallback(
|
||||||
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
|
(data: Record<string, any>[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record<string, any>[] => {
|
||||||
|
|
@ -1241,6 +1242,20 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
||||||
config.rightPanel?.tableName,
|
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 해제
|
// 컴포넌트 언마운트 시 DataProvider 해제
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"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 { ChevronRight, ChevronDown, Package, Layers, Box, AlertCircle } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BOM 트리 노드 데이터
|
* BOM 트리 노드 데이터
|
||||||
|
|
@ -66,11 +67,13 @@ export function BomTreeComponent({
|
||||||
selectedRowsData,
|
selectedRowsData,
|
||||||
...props
|
...props
|
||||||
}: BomTreeComponentProps) {
|
}: 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 [headerInfo, setHeaderInfo] = useState<BomHeaderInfo | null>(null);
|
||||||
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
|
const [treeData, setTreeData] = useState<BomTreeNode[]>([]);
|
||||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const config = component?.componentConfig || {};
|
const config = component?.componentConfig || {};
|
||||||
|
|
||||||
|
|
@ -93,6 +96,14 @@ export function BomTreeComponent({
|
||||||
return null;
|
return null;
|
||||||
}, [formData, selectedRowsData]);
|
}, [formData, selectedRowsData]);
|
||||||
|
|
||||||
|
// ref로 현재 상태 참조 (useCallback 의존성 순환 방지)
|
||||||
|
const expandedNodesRef = useRef(expandedNodes);
|
||||||
|
expandedNodesRef.current = expandedNodes;
|
||||||
|
const selectedNodeIdRef = useRef(selectedNodeId);
|
||||||
|
selectedNodeIdRef.current = selectedNodeId;
|
||||||
|
|
||||||
|
const cacheRestoredRef = useRef(false);
|
||||||
|
|
||||||
// BOM 디테일 데이터 로드
|
// BOM 디테일 데이터 로드
|
||||||
const loadBomDetails = useCallback(async (bomId: string) => {
|
const loadBomDetails = useCallback(async (bomId: string) => {
|
||||||
if (!bomId) return;
|
if (!bomId) return;
|
||||||
|
|
@ -110,8 +121,30 @@ export function BomTreeComponent({
|
||||||
const rows = result.data || [];
|
const rows = result.data || [];
|
||||||
const tree = buildTree(rows);
|
const tree = buildTree(rows);
|
||||||
setTreeData(tree);
|
setTreeData(tree);
|
||||||
|
|
||||||
|
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));
|
const firstLevelIds = new Set<string>(tree.map((n: BomTreeNode) => n.id));
|
||||||
setExpandedNodes(firstLevelIds);
|
setExpandedNodes(firstLevelIds);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BomTree] 데이터 로드 실패:", error);
|
console.error("[BomTree] 데이터 로드 실패:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
import { CategoryColumnList } from "@/components/table-category/CategoryColumnList";
|
||||||
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
import { CategoryValueManager } from "@/components/table-category/CategoryValueManager";
|
||||||
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
import { CategoryValueManagerTree } from "@/components/table-category/CategoryValueManagerTree";
|
||||||
|
|
@ -46,6 +47,9 @@ export function V2CategoryManagerComponent({
|
||||||
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
|
const propsMenuObjid = typeof props.menuObjid === "number" ? props.menuObjid : undefined;
|
||||||
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
|
const effectiveMenuObjid = menuObjid || propsMenuObjid || selectedScreen?.menuObjid;
|
||||||
|
|
||||||
|
// screenId (비활성 탭 F5 복원용 캐시 키로 사용)
|
||||||
|
const effectiveScreenId = typeof props.screenId === "number" || typeof props.screenId === "string" ? props.screenId : undefined;
|
||||||
|
|
||||||
// 디버그 로그
|
// 디버그 로그
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("🔍 V2CategoryManagerComponent props:", {
|
console.log("🔍 V2CategoryManagerComponent props:", {
|
||||||
|
|
@ -58,16 +62,16 @@ export function V2CategoryManagerComponent({
|
||||||
});
|
});
|
||||||
}, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]);
|
}, [tableName, menuObjid, selectedScreen, effectiveTableName, effectiveMenuObjid, config]);
|
||||||
|
|
||||||
// 선택된 컬럼 상태
|
// TSP: 선택된 컬럼 상태 (자동 보존)
|
||||||
const [selectedColumn, setSelectedColumn] = useState<{
|
const [selectedColumn, setSelectedColumn] = usePersistedState<{
|
||||||
uniqueKey: string;
|
uniqueKey: string;
|
||||||
columnName: string;
|
columnName: string;
|
||||||
columnLabel: string;
|
columnLabel: string;
|
||||||
tableName: string;
|
tableName: string;
|
||||||
} | null>(null);
|
} | null>('selectedColumn', null);
|
||||||
|
|
||||||
// 뷰 모드 상태
|
// TSP: 뷰 모드 상태 (자동 보존)
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(config.viewMode);
|
const [viewMode, setViewMode] = usePersistedState<ViewMode>('viewMode', config.viewMode);
|
||||||
|
|
||||||
// 좌측 패널 너비 상태
|
// 좌측 패널 너비 상태
|
||||||
const [leftWidth, setLeftWidth] = useState(config.leftPanelWidth);
|
const [leftWidth, setLeftWidth] = useState(config.leftPanelWidth);
|
||||||
|
|
@ -112,7 +116,7 @@ export function V2CategoryManagerComponent({
|
||||||
const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => {
|
const handleColumnSelect = useCallback((uniqueKey: string, columnLabel: string, tableName: string) => {
|
||||||
const columnName = uniqueKey.split(".")[1];
|
const columnName = uniqueKey.split(".")[1];
|
||||||
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
|
setSelectedColumn({ uniqueKey, columnName, columnLabel, tableName });
|
||||||
}, []);
|
}, [setSelectedColumn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="flex h-full min-h-[10px] gap-0 overflow-hidden" style={{ height: config.height }}>
|
<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}
|
tableName={selectedColumn.tableName}
|
||||||
columnName={selectedColumn.columnName}
|
columnName={selectedColumn.columnName}
|
||||||
columnLabel={selectedColumn.columnLabel}
|
columnLabel={selectedColumn.columnLabel}
|
||||||
|
screenId={effectiveScreenId}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CategoryValueManager
|
<CategoryValueManager
|
||||||
|
|
@ -189,6 +194,7 @@ export function V2CategoryManagerComponent({
|
||||||
columnName={selectedColumn.columnName}
|
columnName={selectedColumn.columnName}
|
||||||
columnLabel={selectedColumn.columnLabel}
|
columnLabel={selectedColumn.columnLabel}
|
||||||
menuObjid={effectiveMenuObjid}
|
menuObjid={effectiveMenuObjid}
|
||||||
|
screenId={effectiveScreenId}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
import React, { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
PivotGridProps,
|
PivotGridProps,
|
||||||
|
|
@ -327,13 +328,13 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
// 🆕 초기 로드 시 자동 확장 (첫 레벨만)
|
// 🆕 초기 로드 시 자동 확장 (첫 레벨만)
|
||||||
const [isInitialExpanded, setIsInitialExpanded] = useState(false);
|
const [isInitialExpanded, setIsInitialExpanded] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [showFieldPanel, setShowFieldPanel] = useState(false); // 기본적으로 접힌 상태
|
const [showFieldPanel, setShowFieldPanel] = usePersistedState('showFieldPanel', false);
|
||||||
const [showFieldChooser, setShowFieldChooser] = useState(false);
|
const [showFieldChooser, setShowFieldChooser] = useState(false);
|
||||||
const [drillDownData, setDrillDownData] = useState<{
|
const [drillDownData, setDrillDownData] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
cellData: PivotCellData | null;
|
cellData: PivotCellData | null;
|
||||||
}>({ open: false, cellData: 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 [containerHeight, setContainerHeight] = useState(400);
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
|
@ -997,7 +998,7 @@ export const PivotGridComponent: React.FC<PivotGridProps> = ({
|
||||||
}, [stateStorageKey, initialFields]);
|
}, [stateStorageKey, initialFields]);
|
||||||
|
|
||||||
// 필드 숨기기/표시 상태
|
// 필드 숨기기/표시 상태
|
||||||
const [hiddenFields, setHiddenFields] = useState<Set<string>>(new Set());
|
const [hiddenFields, setHiddenFields] = usePersistedState<Set<string>>('hiddenFields', new Set());
|
||||||
|
|
||||||
const toggleFieldVisibility = useCallback((fieldName: string) => {
|
const toggleFieldVisibility = useCallback((fieldName: string) => {
|
||||||
setHiddenFields((prev) => {
|
setHiddenFields((prev) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"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 { ComponentRendererProps } from "../../types";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig } from "./types";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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;
|
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;
|
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 [rightGrouping, setRightGrouping] = useState<string[]>([]);
|
||||||
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
|
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 [leftData, setLeftData] = useState<any[]>([]);
|
||||||
const [rightData, setRightData] = useState<any[] | any>(null); // 조인 모드는 배열, 상세 모드는 객체
|
const [rightData, setRightData] = useState<any[] | any>(null);
|
||||||
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
|
|
||||||
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
|
|
||||||
const [customLeftSelectedData, setCustomLeftSelectedData] = useState<Record<string, any>>({}); // 커스텀 모드: 좌측 선택 데이터
|
const [customLeftSelectedData, setCustomLeftSelectedData] = useState<Record<string, any>>({}); // 커스텀 모드: 좌측 선택 데이터
|
||||||
// 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용)
|
// 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용)
|
||||||
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
|
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
|
||||||
|
|
@ -205,15 +200,51 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
},
|
},
|
||||||
[(props as any).onSelectedRowsChange],
|
[(props as any).onSelectedRowsChange],
|
||||||
);
|
);
|
||||||
const [leftSearchQuery, setLeftSearchQuery] = useState("");
|
// (TSP: selectedLeftItem 저장은 usePersistedState가 자동 처리)
|
||||||
const [rightSearchQuery, setRightSearchQuery] = useState("");
|
|
||||||
|
// 좌/우측 패널 스크롤 위치 저장/복원
|
||||||
|
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 [isLoadingLeft, setIsLoadingLeft] = useState(false);
|
||||||
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
const [isLoadingRight, setIsLoadingRight] = useState(false);
|
||||||
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
|
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]);
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
|
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 [tabsData, setTabsData] = useState<Record<number, any[]>>({}); // 탭별 데이터
|
||||||
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
|
const [tabsLoading, setTabsLoading] = useState<Record<number, boolean>>({}); // 탭별 로딩 상태
|
||||||
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
|
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 }>>
|
Record<string, Record<string, { label: string; color?: string }>>
|
||||||
>({}); // 우측 카테고리 매핑
|
>({}); // 우측 카테고리 매핑
|
||||||
|
|
||||||
|
// (TSP: UI 상태 저장은 usePersistedState가 자동 처리)
|
||||||
|
|
||||||
// 🆕 커스텀 모드: 드래그/리사이즈 상태
|
// 🆕 커스텀 모드: 드래그/리사이즈 상태
|
||||||
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
||||||
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | 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(
|
const handleLeftItemSelect = useCallback(
|
||||||
(item: any) => {
|
(item: any) => {
|
||||||
|
userInteractedLeftRef.current = true;
|
||||||
// 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀)
|
// 동일 항목 클릭 시 선택 해제 (전체 보기로 복귀)
|
||||||
const leftPk = componentConfig.rightPanel?.relation?.leftColumn ||
|
const leftPk = componentConfig.rightPanel?.relation?.leftColumn ||
|
||||||
componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn;
|
componentConfig.rightPanel?.relation?.keys?.[0]?.leftColumn;
|
||||||
|
|
@ -1568,6 +1602,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setExpandedRightItems(new Set());
|
setExpandedRightItems(new Set());
|
||||||
setTabsData({});
|
setTabsData({});
|
||||||
|
|
||||||
|
// 부모에게 선택 해제 전파 (탭 상태 캐시용)
|
||||||
|
(props as any).onSelectedRowsChange?.([], []);
|
||||||
|
|
||||||
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
|
const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail";
|
||||||
if (mainRelationType === "detail") {
|
if (mainRelationType === "detail") {
|
||||||
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
|
// "선택 시 표시" 모드: 선택 해제 시 데이터 비움
|
||||||
|
|
@ -1593,6 +1630,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
setSelectedLeftItem(item);
|
setSelectedLeftItem(item);
|
||||||
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
|
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
|
||||||
|
|
||||||
|
// 부모에게 선택 전파 (탭 상태 캐시용)
|
||||||
|
(props as any).onSelectedRowsChange?.([item], [item]);
|
||||||
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
|
||||||
setTabsData({}); // 모든 탭 데이터 초기화
|
setTabsData({}); // 모든 탭 데이터 초기화
|
||||||
|
|
||||||
|
|
@ -2809,6 +2849,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isDesignMode, componentConfig.autoLoad]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
if (!isDesignMode && componentConfig.autoLoad !== false) {
|
||||||
|
|
@ -2948,9 +3063,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</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" ? (
|
{componentConfig.leftPanel?.displayMode === "custom" ? (
|
||||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||||
<div
|
<div
|
||||||
|
|
@ -3087,7 +3201,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}}
|
}}
|
||||||
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
|
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
|
||||||
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
|
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
|
||||||
console.log("🔍 [SplitPanel-Left] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id });
|
|
||||||
// 탭 내 컴포넌트 선택 상태 업데이트
|
// 탭 내 컴포넌트 선택 상태 업데이트
|
||||||
setNestedTabSelectedCompId(compId);
|
setNestedTabSelectedCompId(compId);
|
||||||
// 부모 분할 패널 정보와 함께 전역 이벤트 발생
|
// 부모 분할 패널 정보와 함께 전역 이벤트 발생
|
||||||
|
|
@ -3171,9 +3284,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
onFormDataChange={(data: any) => {
|
onFormDataChange={(data: any) => {
|
||||||
// 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처
|
// 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처
|
||||||
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
|
||||||
|
userInteractedLeftRef.current = true;
|
||||||
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
setCustomLeftSelectedData(data.selectedRowsData[0]);
|
||||||
setSelectedLeftItem(data.selectedRowsData[0]);
|
setSelectedLeftItem(data.selectedRowsData[0]);
|
||||||
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
|
} else if (data?.selectedRowsData && data.selectedRowsData.length === 0) {
|
||||||
|
// 사용자가 아직 상호작용하지 않았고 캐시값이 있으면 초기화 시 리셋 방지
|
||||||
|
if (!userInteractedLeftRef.current && initialCachedLeftItemRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCustomLeftSelectedData({});
|
setCustomLeftSelectedData({});
|
||||||
setSelectedLeftItem(null);
|
setSelectedLeftItem(null);
|
||||||
}
|
}
|
||||||
|
|
@ -3743,7 +3861,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-hidden p-4">
|
<CardContent ref={rightPanelContentRef} className="flex-1 overflow-hidden p-4">
|
||||||
{/* 추가 탭 컨텐츠 */}
|
{/* 추가 탭 컨텐츠 */}
|
||||||
{activeTabIndex > 0 ? (
|
{activeTabIndex > 0 ? (
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -4147,7 +4265,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}}
|
}}
|
||||||
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
|
// 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함
|
||||||
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
|
onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => {
|
||||||
console.log("🔍 [SplitPanel-Right] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id });
|
|
||||||
// 탭 내 컴포넌트 선택 상태 업데이트
|
// 탭 내 컴포넌트 선택 상태 업데이트
|
||||||
setNestedTabSelectedCompId(compId);
|
setNestedTabSelectedCompId(compId);
|
||||||
// 부모 분할 패널 정보와 함께 전역 이벤트 발생
|
// 부모 분할 패널 정보와 함께 전역 이벤트 발생
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
TableGroupedConfig,
|
TableGroupedConfig,
|
||||||
GroupState,
|
GroupState,
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
UseGroupedDataResult,
|
UseGroupedDataResult,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그룹 요약 데이터 계산
|
* 그룹 요약 데이터 계산
|
||||||
|
|
@ -105,22 +106,17 @@ function formatGroupLabel(
|
||||||
export function useGroupedData(
|
export function useGroupedData(
|
||||||
config: TableGroupedConfig,
|
config: TableGroupedConfig,
|
||||||
externalData?: any[],
|
externalData?: any[],
|
||||||
searchFilters?: Record<string, any>
|
searchFilters?: Record<string, any>,
|
||||||
): UseGroupedDataResult {
|
): UseGroupedDataResult {
|
||||||
// 원본 데이터
|
// 원본 데이터
|
||||||
const [rawData, setRawData] = useState<any[]>([]);
|
const [rawData, setRawData] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 그룹 펼침 상태 관리
|
// TSP: 그룹 펼침 / 선택 상태 (자동 보존)
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
const [expandedGroups, setExpandedGroups] = usePersistedState<Set<string>>('expandedGroups', new Set());
|
||||||
// 사용자가 수동으로 펼침/접기를 조작했는지 여부
|
const [isManuallyControlled, setIsManuallyControlled] = usePersistedState('isManuallyControlled', false);
|
||||||
const [isManuallyControlled, setIsManuallyControlled] = useState(false);
|
const [selectedItemIds, setSelectedItemIds] = usePersistedState<Set<string>>('selectedItemIds', new Set());
|
||||||
|
|
||||||
// 선택 상태 관리
|
|
||||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(
|
|
||||||
new Set()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 테이블명 결정
|
// 테이블명 결정
|
||||||
const tableName = config.useCustomTable
|
const tableName = config.useCustomTable
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"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 { TableListConfig, ColumnConfig } from "./types";
|
||||||
import { WebType } from "@/types/common";
|
import { WebType } from "@/types/common";
|
||||||
import { tableTypeApi } from "@/lib/api/screen";
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
|
@ -435,7 +436,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const newSearchValues: Record<string, any> = {};
|
const newSearchValues: Record<string, any> = {};
|
||||||
filters.forEach((filter) => {
|
filters.forEach((filter) => {
|
||||||
if (filter.value) {
|
if (filter.value) {
|
||||||
// operator 정보도 함께 전달 (백엔드에서 equals/contains 구분)
|
|
||||||
newSearchValues[filter.columnName] = {
|
newSearchValues[filter.columnName] = {
|
||||||
value: filter.value,
|
value: filter.value,
|
||||||
operator: filter.operator || "contains",
|
operator: filter.operator || "contains",
|
||||||
|
|
@ -443,10 +443,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// filters → searchValues 변환 완료
|
|
||||||
|
|
||||||
setSearchValues(newSearchValues);
|
setSearchValues(newSearchValues);
|
||||||
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
|
||||||
|
const filtersKey = JSON.stringify(filters);
|
||||||
|
if (prevFiltersKeyRef.current !== null && prevFiltersKeyRef.current !== filtersKey) {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}
|
||||||
|
prevFiltersKeyRef.current = filtersKey;
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
// grouping이 변경되면 groupByColumns 업데이트
|
// grouping이 변경되면 groupByColumns 업데이트
|
||||||
|
|
@ -640,13 +643,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return result;
|
return result;
|
||||||
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
|
}, [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 [totalPages, setTotalPages] = useState(0);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = usePersistedState('searchTerm', '');
|
||||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
const [sortColumn, setSortColumn] = usePersistedState<string | null>('sortColumn', null);
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
const [sortDirection, setSortDirection] = usePersistedState<"asc" | "desc">('sortDirection', "asc");
|
||||||
const hasInitializedSort = useRef(false);
|
const hasInitializedSort = useRef(sortColumn != null);
|
||||||
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({});
|
||||||
const [tableLabel, setTableLabel] = useState<string>("");
|
const [tableLabel, setTableLabel] = useState<string>("");
|
||||||
const [localPageSize, setLocalPageSize] = useState<number>(tableConfig.pagination?.pageSize || 20);
|
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 }>>
|
Record<string, Record<string, { label: string; color?: string }>>
|
||||||
>({});
|
>({});
|
||||||
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
|
const [categoryMappingsKey, setCategoryMappingsKey] = useState(0); // 강제 리렌더링용
|
||||||
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
|
const [searchValues, setSearchValues] = usePersistedState<Record<string, any>>('searchValues', {});
|
||||||
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
const [selectedRows, setSelectedRows] = usePersistedState<Set<string>>('selectedRows', new Set());
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
// columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요)
|
// columnOrder는 상단에서 정의됨 (visibleColumns보다 먼저 필요)
|
||||||
|
|
@ -676,8 +681,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
const [isFilterSettingOpen, setIsFilterSettingOpen] = useState(false);
|
||||||
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
const [visibleFilterColumns, setVisibleFilterColumns] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 🆕 키보드 네비게이션 관련 상태
|
// TSP: 스크롤 위치 (자동 보존)
|
||||||
const [focusedCell, setFocusedCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
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);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 🆕 인라인 셀 편집 관련 상태
|
// 🆕 인라인 셀 편집 관련 상태
|
||||||
|
|
@ -733,13 +760,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 그룹 설정 관련 상태
|
// 그룹 설정 관련 상태
|
||||||
const [groupByColumns, setGroupByColumns] = useState<string[]>([]);
|
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);
|
const [groupSumConfig, setGroupSumConfig] = useState<GroupSumConfig | null>(null);
|
||||||
|
|
||||||
// 🆕 Master-Detail 관련 상태
|
// 🆕 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[]>>({}); // 상세 데이터 캐시
|
const [detailData, setDetailData] = useState<Record<string, any[]>>({}); // 상세 데이터 캐시
|
||||||
|
|
||||||
// 🆕 Drag & Drop 재정렬 관련 상태
|
// 🆕 Drag & Drop 재정렬 관련 상태
|
||||||
|
|
@ -793,7 +820,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||||
|
|
||||||
// 🆕 Search Panel (통합 검색) 관련 상태
|
// 🆕 Search Panel (통합 검색) 관련 상태
|
||||||
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
|
const [globalSearchTerm, setGlobalSearchTerm] = usePersistedState('globalSearchTerm', '');
|
||||||
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
const [isSearchPanelOpen, setIsSearchPanelOpen] = useState(false);
|
||||||
const [searchHighlights, setSearchHighlights] = useState<Set<string>>(new Set()); // "rowIndex-colIndex" 형식
|
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 [isFilterBuilderOpen, setIsFilterBuilderOpen] = useState(false);
|
||||||
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
const [activeFilterCount, setActiveFilterCount] = useState(0);
|
||||||
|
|
||||||
|
// (TSP: 상태 저장은 usePersistedState가 자동 처리)
|
||||||
|
|
||||||
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
|
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const linkedFilters = tableConfig.linkedFilters;
|
const linkedFilters = tableConfig.linkedFilters;
|
||||||
|
|
@ -2868,13 +2897,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
|
|
||||||
// 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조)
|
// 🆕 Virtual Scrolling: virtualScrollInfo는 displayData 정의 이후로 이동됨 (아래 참조)
|
||||||
|
|
||||||
// 🆕 Virtual Scrolling: 스크롤 핸들러
|
// 🆕 Virtual Scrolling: 스크롤 핸들러 + 스크롤 위치 추적
|
||||||
const handleVirtualScroll = useCallback(
|
const handleVirtualScroll = useCallback(
|
||||||
(e: React.UIEvent<HTMLDivElement>) => {
|
(e: React.UIEvent<HTMLDivElement>) => {
|
||||||
if (!isVirtualScrollEnabled) return;
|
const target = e.currentTarget;
|
||||||
setScrollTop(e.currentTarget.scrollTop);
|
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: 통합 상태 저장
|
// 🆕 State Persistence: 통합 상태 저장
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,11 @@ export function TimelineSchedulerComponent({
|
||||||
goToNext,
|
goToNext,
|
||||||
goToToday,
|
goToToday,
|
||||||
updateSchedule,
|
updateSchedule,
|
||||||
} = useTimelineData(config, externalSchedules, externalResources);
|
} = useTimelineData(
|
||||||
|
config,
|
||||||
|
externalSchedules,
|
||||||
|
externalResources,
|
||||||
|
);
|
||||||
|
|
||||||
const isLoading = externalLoading ?? hookLoading;
|
const isLoading = externalLoading ?? hookLoading;
|
||||||
const error = externalError ?? hookError;
|
const error = externalError ?? hookError;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { apiClient } from "@/lib/api/client";
|
||||||
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||||
import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types";
|
import { TimelineSchedulerConfig, ScheduleItem, Resource, ZoomLevel, UseTimelineDataResult } from "../types";
|
||||||
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
||||||
|
import { usePersistedState } from "@/hooks/usePersistedState";
|
||||||
|
|
||||||
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
|
// schedule_mng 테이블 고정 (공통 스케줄 테이블)
|
||||||
const SCHEDULE_TABLE = "schedule_mng";
|
const SCHEDULE_TABLE = "schedule_mng";
|
||||||
|
|
@ -38,17 +39,16 @@ export function useTimelineData(
|
||||||
const [resources, setResources] = useState<Resource[]>([]);
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(config.defaultZoomLevel || "day");
|
|
||||||
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
|
// TSP: 줌 레벨 / 시작 날짜 (자동 보존)
|
||||||
if (config.initialDate) {
|
const [zoomLevel, setZoomLevel] = usePersistedState<ZoomLevel>('zoomLevel', config.defaultZoomLevel || "day");
|
||||||
return new Date(config.initialDate);
|
const [viewStartDate, setViewStartDate] = usePersistedState<Date>('viewStartDate', (() => {
|
||||||
}
|
if (config.initialDate) return new Date(config.initialDate);
|
||||||
// 오늘 기준 1주일 전부터 시작
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setDate(today.getDate() - 7);
|
today.setDate(today.getDate() - 7);
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
return today;
|
return today;
|
||||||
});
|
})());
|
||||||
|
|
||||||
// 선택된 품목 코드 (좌측 테이블에서 선택된 데이터 기준)
|
// 선택된 품목 코드 (좌측 테이블에서 선택된 데이터 기준)
|
||||||
const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
|
const [selectedSourceKeys, setSelectedSourceKeys] = useState<string[]>([]);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue