jskim-node #393
|
|
@ -24,6 +24,7 @@ import {
|
|||
subscribe as canvasSplitSubscribe,
|
||||
getSnapshot as canvasSplitGetSnapshot,
|
||||
getServerSnapshot as canvasSplitGetServerSnapshot,
|
||||
subscribeDom as canvasSplitSubscribeDom,
|
||||
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
|
|
@ -1181,14 +1182,66 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
: undefined,
|
||||
};
|
||||
|
||||
// 스플릿 조정된 컴포넌트 객체 캐싱 (드래그 끝난 후 최종 렌더링용)
|
||||
const splitAdjustedComponent = React.useMemo(() => {
|
||||
if (isSplitActive && adjustedW !== origW) {
|
||||
return { ...component, size: { ...(component as any).size, width: Math.round(adjustedW) } };
|
||||
}
|
||||
return component;
|
||||
}, [component, isSplitActive, adjustedW, origW]);
|
||||
|
||||
// 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트)
|
||||
const elRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
const compType = (component as any).componentType || "";
|
||||
if (type === "component" && compType === "v2-split-line") return;
|
||||
|
||||
const unsubscribe = canvasSplitSubscribeDom((snap) => {
|
||||
if (!snap.isDragging || !snap.active || !snap.scopeId) return;
|
||||
if (myScopeIdRef.current !== snap.scopeId) return;
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const origX = position?.x || 0;
|
||||
const oW = size?.width || 200;
|
||||
const { initialDividerX, currentDividerX, canvasWidth } = snap;
|
||||
const delta = currentDividerX - initialDividerX;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
|
||||
if (canvasSplitSideRef.current === null) {
|
||||
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
const GAP = 4;
|
||||
let nx: number, nw: number;
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1;
|
||||
nx = origX * scale;
|
||||
nw = oW * scale;
|
||||
if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx;
|
||||
} else {
|
||||
const irw = canvasWidth - initialDividerX;
|
||||
const crw = Math.max(20, canvasWidth - currentDividerX - GAP);
|
||||
const scale = irw > 0 ? crw / irw : 1;
|
||||
nx = currentDividerX + GAP + (origX - initialDividerX) * scale;
|
||||
nw = oW * scale;
|
||||
if (nx < currentDividerX + GAP) nx = currentDividerX + GAP;
|
||||
if (nx + nw > canvasWidth) nw = canvasWidth - nx;
|
||||
}
|
||||
nx = Math.max(0, nx);
|
||||
nw = Math.max(20, nw);
|
||||
|
||||
el.style.left = `${nx}px`;
|
||||
el.style.width = `${Math.round(nw)}px`;
|
||||
el.style.overflow = nw < oW ? "hidden" : "";
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [component.id, position?.x, size?.width, type]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
||||
{renderInteractiveWidget(
|
||||
isSplitActive && adjustedW !== origW
|
||||
? { ...component, size: { ...(component as any).size, width: adjustedW } }
|
||||
: component
|
||||
)}
|
||||
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
||||
{renderInteractiveWidget(splitAdjustedComponent)}
|
||||
</div>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
subscribe as canvasSplitSubscribe,
|
||||
getSnapshot as canvasSplitGetSnapshot,
|
||||
getServerSnapshot as canvasSplitGetServerSnapshot,
|
||||
subscribeDom as canvasSplitSubscribeDom,
|
||||
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
|
|
@ -603,6 +604,60 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
onDragEnd?.();
|
||||
};
|
||||
|
||||
const splitAdjustedComp = React.useMemo(() => {
|
||||
if (isSplitShrunk && splitAdjustedWidth !== null) {
|
||||
return { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: Math.round(splitAdjustedWidth) } };
|
||||
}
|
||||
return enhancedComponent;
|
||||
}, [enhancedComponent, isSplitShrunk, splitAdjustedWidth]);
|
||||
|
||||
// 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트)
|
||||
React.useEffect(() => {
|
||||
const isSplitLine = type === "component" && componentType === "v2-split-line";
|
||||
if (isSplitLine) return;
|
||||
|
||||
const unsubscribe = canvasSplitSubscribeDom((snap) => {
|
||||
if (!snap.isDragging || !snap.active || !snap.scopeId) return;
|
||||
if (myScopeIdRef.current !== snap.scopeId) return;
|
||||
const el = outerDivRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const origX = position.x;
|
||||
const oW = size?.width || 100;
|
||||
const { initialDividerX, currentDividerX, canvasWidth } = snap;
|
||||
const delta = currentDividerX - initialDividerX;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
|
||||
if (canvasSplitSideRef.current === null) {
|
||||
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
const GAP = 4;
|
||||
let nx: number, nw: number;
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1;
|
||||
nx = origX * scale;
|
||||
nw = oW * scale;
|
||||
if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx;
|
||||
} else {
|
||||
const irw = canvasWidth - initialDividerX;
|
||||
const crw = Math.max(20, canvasWidth - currentDividerX - GAP);
|
||||
const scale = irw > 0 ? crw / irw : 1;
|
||||
nx = currentDividerX + GAP + (origX - initialDividerX) * scale;
|
||||
nw = oW * scale;
|
||||
if (nx < currentDividerX + GAP) nx = currentDividerX + GAP;
|
||||
if (nx + nw > canvasWidth) nw = canvasWidth - nx;
|
||||
}
|
||||
nx = Math.max(0, nx);
|
||||
nw = Math.max(20, nw);
|
||||
|
||||
el.style.left = `${nx}px`;
|
||||
el.style.width = `${Math.round(nw)}px`;
|
||||
el.style.overflow = nw < oW ? "hidden" : "";
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [id, position.x, size?.width, type, componentType]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerDivRef}
|
||||
|
|
@ -629,10 +684,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
style={{ width: "100%", maxWidth: "100%" }}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={isSplitShrunk
|
||||
? { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: splitAdjustedWidth } }
|
||||
: enhancedComponent
|
||||
}
|
||||
component={splitAdjustedComp}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MenuItem, MenuState } from "@/types/menu";
|
||||
import { LAYOUT_CONFIG } from "@/constants/layout";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* 메뉴 관련 비즈니스 로직을 관리하는 커스텀 훅
|
||||
* - API 호출은 apiClient를 통해 수행 (토큰 관리/401 처리 자동)
|
||||
* - 메뉴 로드 실패 시 토큰 삭제/리다이렉트하지 않음 (client.ts가 처리)
|
||||
*/
|
||||
export const useMenu = (user: any, authLoading: boolean) => {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태 관리
|
||||
const [menuState, setMenuState] = useState<MenuState>({
|
||||
menuList: [],
|
||||
expandedMenus: new Set(),
|
||||
|
|
@ -36,103 +37,58 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
|||
* 메뉴 트리 구조 생성
|
||||
*/
|
||||
const buildMenuTree = useCallback((menuItems: MenuItem[]): MenuItem[] => {
|
||||
console.log("빌드 메뉴 트리 - 원본 메뉴 아이템들:", menuItems);
|
||||
|
||||
const menuMap = new Map<string, MenuItem>();
|
||||
const rootMenus: MenuItem[] = [];
|
||||
|
||||
// 모든 메뉴를 맵에 저장 (ID를 문자열로 변환)
|
||||
menuItems.forEach((menu) => {
|
||||
const objId = String(menu.OBJID);
|
||||
const parentId = String(menu.PARENT_OBJ_ID);
|
||||
console.log(`메뉴 처리: ${menu.MENU_NAME_KOR}, OBJID: ${objId}, PARENT_OBJ_ID: ${parentId}`);
|
||||
menuMap.set(objId, { ...menu, OBJID: objId, PARENT_OBJ_ID: parentId, children: [] });
|
||||
});
|
||||
|
||||
console.log("메뉴 맵 생성 완료, 총 메뉴 수:", menuMap.size);
|
||||
|
||||
// 부모-자식 관계 설정
|
||||
menuItems.forEach((menu) => {
|
||||
const objId = String(menu.OBJID);
|
||||
const parentId = String(menu.PARENT_OBJ_ID);
|
||||
const menuItem = menuMap.get(objId)!;
|
||||
|
||||
// PARENT_OBJ_ID가 특정 값이 아닌 경우 (루트가 아닌 경우)
|
||||
if (parentId !== "-395553955") {
|
||||
const parent = menuMap.get(parentId);
|
||||
if (parent) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(menuItem);
|
||||
console.log(`자식 메뉴 추가: ${menu.MENU_NAME_KOR} -> ${parent.MENU_NAME_KOR}`);
|
||||
} else {
|
||||
console.log(`부모 메뉴를 찾을 수 없음: ${menu.MENU_NAME_KOR}, 부모 ID: ${parentId}`);
|
||||
}
|
||||
} else {
|
||||
rootMenus.push(menuItem);
|
||||
console.log(`루트 메뉴 추가: ${menu.MENU_NAME_KOR}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("루트 메뉴 개수:", rootMenus.length);
|
||||
console.log(
|
||||
"최종 루트 메뉴들:",
|
||||
rootMenus.map((m) => m.MENU_NAME_KOR),
|
||||
);
|
||||
|
||||
return rootMenus.sort((a, b) => (a.SEQ || 0) - (b.SEQ || 0));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 메뉴 데이터 로드
|
||||
* - apiClient 사용으로 토큰/401 자동 처리
|
||||
* - 실패 시 빈 메뉴 유지 (로그인 리다이렉트는 client.ts 인터셉터가 담당)
|
||||
*/
|
||||
const loadMenuData = useCallback(async () => {
|
||||
try {
|
||||
// JWT 토큰 가져오기
|
||||
const token = localStorage.getItem("authToken");
|
||||
const response = await apiClient.get("/admin/user-menus");
|
||||
|
||||
if (!token) {
|
||||
console.error("JWT 토큰이 없습니다.");
|
||||
router.push("/login");
|
||||
return;
|
||||
if (response.data?.success && response.data?.data) {
|
||||
const convertedMenuData = convertToUpperCaseKeys(response.data.data || []);
|
||||
setMenuState((prev: MenuState) => ({
|
||||
...prev,
|
||||
menuList: buildMenuTree(convertedMenuData),
|
||||
isLoading: false,
|
||||
}));
|
||||
} else {
|
||||
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
|
||||
// 메뉴 목록 조회
|
||||
const menuResponse = await fetch(`${LAYOUT_CONFIG.API_BASE_URL}${LAYOUT_CONFIG.ENDPOINTS.USER_MENUS}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (menuResponse.ok) {
|
||||
const menuResult = await menuResponse.json();
|
||||
console.log("메뉴 응답 데이터:", menuResult);
|
||||
|
||||
if (menuResult.success && menuResult.data) {
|
||||
console.log("메뉴 데이터 배열:", menuResult.data);
|
||||
const convertedMenuData = convertToUpperCaseKeys(menuResult.data || []);
|
||||
console.log("변환된 메뉴 데이터:", convertedMenuData);
|
||||
|
||||
setMenuState((prev: MenuState) => ({
|
||||
...prev,
|
||||
menuList: buildMenuTree(convertedMenuData),
|
||||
isLoading: false,
|
||||
}));
|
||||
}
|
||||
} else if (menuResponse.status === 401) {
|
||||
// 인증 실패 시 토큰 제거 및 로그인 페이지로 리다이렉트
|
||||
localStorage.removeItem("authToken");
|
||||
router.push("/login");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메뉴 데이터 로드 실패:", error);
|
||||
localStorage.removeItem("authToken");
|
||||
router.push("/login");
|
||||
} catch {
|
||||
// API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리)
|
||||
setMenuState((prev: MenuState) => ({ ...prev, isLoading: false }));
|
||||
}
|
||||
}, [router, convertToUpperCaseKeys, buildMenuTree]);
|
||||
}, [convertToUpperCaseKeys, buildMenuTree]);
|
||||
|
||||
/**
|
||||
* 메뉴 토글
|
||||
|
|
@ -160,13 +116,11 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
|||
if (menu.children && menu.children.length > 0) {
|
||||
toggleMenu(String(menu.OBJID));
|
||||
} else {
|
||||
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
|
||||
const menuName = menu.MENU_NAME_KOR || menu.menuNameKor || menu.TRANSLATED_NAME || "메뉴";
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("currentMenuName", menuName);
|
||||
}
|
||||
|
||||
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
|
||||
try {
|
||||
const menuObjid = menu.OBJID || menu.objid;
|
||||
if (menuObjid) {
|
||||
|
|
@ -174,9 +128,7 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
|||
const assignedScreens = await menuScreenApi.getScreensByMenu(parseInt(menuObjid.toString()));
|
||||
|
||||
if (assignedScreens.length > 0) {
|
||||
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
||||
const firstScreen = assignedScreens[0];
|
||||
// menuObjid를 쿼리 파라미터로 전달
|
||||
router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -185,11 +137,9 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
|||
console.warn("할당된 화면 조회 실패:", error);
|
||||
}
|
||||
|
||||
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
|
||||
if (menu.MENU_URL) {
|
||||
router.push(menu.MENU_URL);
|
||||
} else {
|
||||
// URL도 없고 할당된 화면도 없으면 경고 메시지
|
||||
console.warn("메뉴에 URL이나 할당된 화면이 없습니다:", menu);
|
||||
const { toast } = await import("sonner");
|
||||
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
||||
|
|
@ -199,7 +149,6 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
|||
[toggleMenu, router],
|
||||
);
|
||||
|
||||
// 사용자 정보가 있고 로딩이 완료되면 메뉴 데이터 로드
|
||||
useEffect(() => {
|
||||
if (user && !authLoading) {
|
||||
loadMenuData();
|
||||
|
|
@ -212,6 +161,6 @@ export const useMenu = (user: any, authLoading: boolean) => {
|
|||
isMenuLoading: menuState.isLoading,
|
||||
handleMenuClick,
|
||||
toggleMenu,
|
||||
refreshMenus: loadMenuData, // 메뉴 새로고침 함수 추가
|
||||
refreshMenus: loadMenuData,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -73,12 +73,14 @@ const TokenManager = {
|
|||
setToken: (token: string): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("authToken", token);
|
||||
document.cookie = `authToken=${token}; path=/; max-age=86400; SameSite=Lax`;
|
||||
}
|
||||
},
|
||||
|
||||
removeToken: (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("authToken");
|
||||
document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax";
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -641,18 +641,139 @@ export function BomItemEditorComponent({
|
|||
});
|
||||
};
|
||||
|
||||
// ─── 디자인 모드 ───
|
||||
// ─── 디자인 모드 미리보기 ───
|
||||
|
||||
if (isDesignMode) {
|
||||
const cfg = component?.componentConfig || {};
|
||||
const hasConfig =
|
||||
cfg.mainTableName || cfg.dataSource?.sourceTable || (cfg.columns && cfg.columns.length > 0);
|
||||
|
||||
const sourceColumns = (cfg.columns || []).filter((c: any) => c.isSourceDisplay);
|
||||
const inputColumns = (cfg.columns || []).filter((c: any) => !c.isSourceDisplay);
|
||||
|
||||
if (!hasConfig) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-6 text-center">
|
||||
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
BOM 하위 품목 편집기
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
트리 구조로 하위 품목을 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dummyRows = [
|
||||
{ depth: 0, code: "ASM-001", name: "본체 조립", type: "조립", qty: "1" },
|
||||
{ depth: 1, code: "PRT-010", name: "프레임", type: "구매", qty: "2" },
|
||||
{ depth: 1, code: "PRT-011", name: "커버", type: "구매", qty: "1" },
|
||||
{ depth: 0, code: "ASM-002", name: "전장 조립", type: "조립", qty: "1" },
|
||||
{ depth: 1, code: "PRT-020", name: "PCB 보드", type: "구매", qty: "3" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-6 text-center">
|
||||
<Package className="text-muted-foreground mx-auto mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
BOM 하위 품목 편집기
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
트리 구조로 하위 품목을 관리합니다
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">하위 품목 구성</h4>
|
||||
<Button size="sm" className="h-7 text-xs" disabled>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
품목추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 설정 요약 뱃지 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cfg.mainTableName && (
|
||||
<span className="rounded bg-orange-100 px-1.5 py-0.5 text-[10px] text-orange-700">
|
||||
저장: {cfg.mainTableName}
|
||||
</span>
|
||||
)}
|
||||
{cfg.dataSource?.sourceTable && (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-700">
|
||||
소스: {cfg.dataSource.sourceTable}
|
||||
</span>
|
||||
)}
|
||||
{cfg.parentKeyColumn && (
|
||||
<span className="rounded bg-green-100 px-1.5 py-0.5 text-[10px] text-green-700">
|
||||
트리: {cfg.parentKeyColumn}
|
||||
</span>
|
||||
)}
|
||||
{inputColumns.length > 0 && (
|
||||
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600">
|
||||
입력 {inputColumns.length}개
|
||||
</span>
|
||||
)}
|
||||
{sourceColumns.length > 0 && (
|
||||
<span className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-600">
|
||||
표시 {sourceColumns.length}개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 더미 트리 미리보기 */}
|
||||
<div className="space-y-0.5 rounded-md border p-1.5">
|
||||
{dummyRows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded px-1.5 py-1",
|
||||
row.depth > 0 && "border-l-2 border-l-primary/20",
|
||||
i === 0 && "bg-accent/30",
|
||||
)}
|
||||
style={{ marginLeft: `${row.depth * 20}px` }}
|
||||
>
|
||||
<GripVertical className="text-muted-foreground h-3 w-3 shrink-0 opacity-40" />
|
||||
{row.depth === 0 ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
|
||||
) : (
|
||||
<span className="w-3" />
|
||||
)}
|
||||
<span className="text-muted-foreground w-4 text-center text-[10px]">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="w-16 shrink-0 truncate font-mono text-[10px] font-medium">
|
||||
{row.code}
|
||||
</span>
|
||||
<span className="min-w-[50px] flex-1 truncate text-[10px]">
|
||||
{row.name}
|
||||
</span>
|
||||
|
||||
{/* 소스 표시 컬럼 미리보기 */}
|
||||
{sourceColumns.slice(0, 2).map((col: any) => (
|
||||
<span
|
||||
key={col.key}
|
||||
className="w-12 shrink-0 truncate text-center text-[10px] text-blue-500"
|
||||
>
|
||||
{col.title}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* 입력 컬럼 미리보기 */}
|
||||
{inputColumns.slice(0, 2).map((col: any) => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="h-5 w-12 shrink-0 rounded border bg-background text-center text-[10px] leading-5"
|
||||
>
|
||||
{col.key === "quantity" || col.title === "수량"
|
||||
? row.qty
|
||||
: ""}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex shrink-0 gap-0.5">
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
||||
<Plus className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded opacity-40">
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,10 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
|||
};
|
||||
}, [isDesignMode, component.id, component.position?.x, detectCanvasWidth]);
|
||||
|
||||
// 드래그 핸들러 (requestAnimationFrame으로 스로틀링 → 렉 방지)
|
||||
// 드래그 중 최종 오프셋 (DOM 직접 조작용)
|
||||
const latestOffsetRef = useRef(dragOffset);
|
||||
latestOffsetRef.current = dragOffset;
|
||||
|
||||
const rafIdRef = useRef(0);
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
|
|
@ -120,7 +123,7 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
|||
|
||||
const posX = component.position?.x || 0;
|
||||
const startX = e.clientX;
|
||||
const startOffset = dragOffset;
|
||||
const startOffset = latestOffsetRef.current;
|
||||
const scaleFactor = getScaleFactor();
|
||||
const cw = detectCanvasWidth();
|
||||
const MIN_POS = Math.max(50, cw * 0.15);
|
||||
|
|
@ -130,7 +133,6 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
|||
setCanvasSplit({ isDragging: true });
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
// rAF로 스로틀링: 프레임당 1회만 업데이트
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
const rawDelta = moveEvent.clientX - startX;
|
||||
|
|
@ -141,7 +143,13 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
|||
if (newDividerX < MIN_POS) newOffset = MIN_POS - posX;
|
||||
if (newDividerX > MAX_POS) newOffset = MAX_POS - posX;
|
||||
|
||||
setDragOffset(newOffset);
|
||||
latestOffsetRef.current = newOffset;
|
||||
|
||||
// 스플릿선 자체는 DOM 직접 조작 (React 리렌더 없음)
|
||||
if (containerRef.current) {
|
||||
containerRef.current.style.transform = `translateX(${newOffset}px)`;
|
||||
}
|
||||
// 스토어 업데이트 → DOM 리스너만 호출 (React 리렌더 없음)
|
||||
setCanvasSplit({ currentDividerX: posX + newOffset });
|
||||
});
|
||||
};
|
||||
|
|
@ -153,6 +161,8 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
|||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
|
||||
// 최종 오프셋을 React 상태에 동기화 (1회만 리렌더)
|
||||
setDragOffset(latestOffsetRef.current);
|
||||
setIsDragging(false);
|
||||
setCanvasSplit({ isDragging: false });
|
||||
};
|
||||
|
|
@ -162,7 +172,7 @@ export const SplitLineComponent: React.FC<SplitLineComponentProps> = ({
|
|||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[resizable, isDesignMode, dragOffset, component.position?.x, getScaleFactor, detectCanvasWidth],
|
||||
[resizable, isDesignMode, component.position?.x, getScaleFactor, detectCanvasWidth],
|
||||
);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,21 @@
|
|||
/**
|
||||
* 캔버스 분할선 글로벌 스토어
|
||||
*
|
||||
* React Context를 우회하여 useSyncExternalStore로 직접 상태를 공유.
|
||||
* SplitLineComponent가 드래그 시 이 스토어를 업데이트하고,
|
||||
* RealtimePreviewDynamic이 구독하여 컴포넌트 위치를 조정.
|
||||
* 성능 최적화: 이중 리스너 구조
|
||||
* - React 리스너 (subscribe): 구조적 변경만 알림 (active, isDragging 시작/종료)
|
||||
* - DOM 리스너 (subscribeDom): 드래그 중 위치 변경 알림 (React 우회, 직접 DOM 조작)
|
||||
*/
|
||||
|
||||
export interface CanvasSplitState {
|
||||
/** 스플릿선의 초기 X 위치 (캔버스 기준 px) */
|
||||
initialDividerX: number;
|
||||
/** 스플릿선의 현재 X 위치 (드래그 중 변경) */
|
||||
currentDividerX: number;
|
||||
/** 캔버스 전체 너비 (px) */
|
||||
canvasWidth: number;
|
||||
/** 드래그 진행 중 여부 */
|
||||
isDragging: boolean;
|
||||
/** 활성 여부 (스플릿선이 등록되었는지) */
|
||||
active: boolean;
|
||||
/** 스코프 ID (같은 data-screen-runtime 컨테이너의 컴포넌트만 영향) */
|
||||
scopeId: string;
|
||||
}
|
||||
|
||||
let state: CanvasSplitState = {
|
||||
const initialState: CanvasSplitState = {
|
||||
initialDividerX: 0,
|
||||
currentDividerX: 0,
|
||||
canvasWidth: 0,
|
||||
|
|
@ -30,44 +24,71 @@ let state: CanvasSplitState = {
|
|||
scopeId: "",
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let state: CanvasSplitState = { ...initialState };
|
||||
|
||||
// React 리렌더링을 트리거하는 리스너 (구조적 변경 전용)
|
||||
const reactListeners = new Set<() => void>();
|
||||
|
||||
// DOM 직접 조작용 리스너 (드래그 중 위치 업데이트, React 우회)
|
||||
const domListeners = new Set<(state: CanvasSplitState) => void>();
|
||||
|
||||
// React용 스냅샷 (드래그 중 위치 변경에는 갱신 안 함)
|
||||
let reactSnapshot: CanvasSplitState = { ...initialState };
|
||||
|
||||
export function setCanvasSplit(updates: Partial<CanvasSplitState>): void {
|
||||
state = { ...state, ...updates };
|
||||
listeners.forEach((fn) => fn());
|
||||
|
||||
// 드래그 중 위치만 변경 → DOM 리스너만 호출 (React 리렌더 없음)
|
||||
const isPositionOnlyDuringDrag =
|
||||
state.isDragging &&
|
||||
Object.keys(updates).length === 1 &&
|
||||
"currentDividerX" in updates;
|
||||
|
||||
if (isPositionOnlyDuringDrag) {
|
||||
domListeners.forEach((fn) => fn(state));
|
||||
return;
|
||||
}
|
||||
|
||||
// 구조적 변경 → React 리스너 + DOM 리스너 모두 호출
|
||||
reactSnapshot = { ...state };
|
||||
reactListeners.forEach((fn) => fn());
|
||||
domListeners.forEach((fn) => fn(state));
|
||||
}
|
||||
|
||||
export function resetCanvasSplit(): void {
|
||||
state = {
|
||||
initialDividerX: 0,
|
||||
currentDividerX: 0,
|
||||
canvasWidth: 0,
|
||||
isDragging: false,
|
||||
active: false,
|
||||
scopeId: "",
|
||||
};
|
||||
listeners.forEach((fn) => fn());
|
||||
state = { ...initialState };
|
||||
reactSnapshot = { ...initialState };
|
||||
reactListeners.forEach((fn) => fn());
|
||||
domListeners.forEach((fn) => fn(state));
|
||||
}
|
||||
|
||||
// React용: useSyncExternalStore에 연결
|
||||
export function subscribe(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
reactListeners.add(callback);
|
||||
return () => {
|
||||
listeners.delete(callback);
|
||||
reactListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function getSnapshot(): CanvasSplitState {
|
||||
return state;
|
||||
return reactSnapshot;
|
||||
}
|
||||
|
||||
// SSR 호환
|
||||
export function getServerSnapshot(): CanvasSplitState {
|
||||
return {
|
||||
initialDividerX: 0,
|
||||
currentDividerX: 0,
|
||||
canvasWidth: 0,
|
||||
isDragging: false,
|
||||
active: false,
|
||||
scopeId: "",
|
||||
return { ...initialState };
|
||||
}
|
||||
|
||||
// DOM 직접 조작용: 드래그 중 매 프레임 위치 업데이트 수신
|
||||
export function subscribeDom(
|
||||
callback: (state: CanvasSplitState) => void,
|
||||
): () => void {
|
||||
domListeners.add(callback);
|
||||
return () => {
|
||||
domListeners.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
// 현재 상태 직접 참조 (DOM 리스너 콜백 외부에서 최신 상태 필요 시)
|
||||
export function getCurrentState(): CanvasSplitState {
|
||||
return state;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,36 +4,41 @@ import type { NextRequest } from "next/server";
|
|||
/**
|
||||
* Next.js 미들웨어
|
||||
* 페이지 렌더링 전에 실행되어 인증 상태를 확인하고 리다이렉트 처리
|
||||
*
|
||||
* 주의: 미들웨어는 쿠키만 접근 가능 (localStorage 접근 불가)
|
||||
* 따라서 토큰이 localStorage에만 있고 쿠키에 없는 경우는
|
||||
* 클라이언트 사이드의 AuthGuard에서 처리
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// 인증 토큰 확인
|
||||
const token = request.cookies.get("authToken")?.value || request.headers.get("authorization")?.replace("Bearer ", "");
|
||||
const token = request.cookies.get("authToken")?.value;
|
||||
|
||||
// /login 페이지 접근 시
|
||||
// /login 페이지 접근 시 - 토큰이 있으면 메인으로
|
||||
if (pathname === "/login") {
|
||||
// 토큰이 있으면 메인 페이지로 리다이렉트
|
||||
if (token) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/main";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
// 토큰이 없으면 로그인 페이지 표시
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 인증이 필요한 페이지들
|
||||
const protectedPaths = ["/main", "/admin", "/dashboard", "/settings"];
|
||||
const isProtectedPath = protectedPaths.some((path) => pathname.startsWith(path));
|
||||
// 보호 경로 목록 - 쿠키에 토큰이 없으면 로그인으로
|
||||
// 단, 쿠키 없이 localStorage에만 토큰이 있을 수 있으므로
|
||||
// 클라이언트에서 한 번 더 확인 후 리다이렉트 (AuthGuard)
|
||||
const strictProtectedPaths = ["/admin"];
|
||||
|
||||
if (isProtectedPath && !token) {
|
||||
// 인증되지 않은 사용자는 로그인 페이지로 리다이렉트
|
||||
const isStrictProtected = strictProtectedPaths.some((path) => pathname.startsWith(path));
|
||||
|
||||
if (isStrictProtected && !token) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
// /main, /screens, /dashboard 등은 쿠키 없어도 통과 허용
|
||||
// (localStorage 토큰이 있을 수 있으므로 클라이언트 AuthGuard에 위임)
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
|
@ -42,14 +47,6 @@ export function middleware(request: NextRequest) {
|
|||
*/
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* 다음 경로를 제외한 모든 요청에 대해 실행:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public 폴더의 파일들
|
||||
*/
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.svg$).*)",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue