Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
@@ -29,17 +29,24 @@ import { evaluateConditional } from "@/lib/utils/conditionalEvaluator"; // 조
|
||||
import { ScreenMultiLangProvider } from "@/contexts/ScreenMultiLangContext"; // 화면 다국어
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; // V2 Zod 기반 변환
|
||||
import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/services/ScheduleGeneratorService"; // 스케줄 자동 생성
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
function ScreenViewPage() {
|
||||
export interface ScreenViewPageProps {
|
||||
screenIdProp?: number;
|
||||
menuObjidProp?: number;
|
||||
}
|
||||
|
||||
function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
||||
// 스케줄 자동 생성 서비스 활성화
|
||||
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const screenId = parseInt(params.screenId as string);
|
||||
const screenId = screenIdProp ?? parseInt(params.screenId as string);
|
||||
|
||||
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
|
||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||
// props 우선, 없으면 URL 쿼리에서 menuObjid 가져오기
|
||||
const menuObjid = menuObjidProp ?? (searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined);
|
||||
|
||||
// URL 쿼리에서 프리뷰용 company_code 가져오기
|
||||
const previewCompanyCode = searchParams.get("company_code");
|
||||
@@ -125,10 +132,13 @@ function ScreenViewPage() {
|
||||
initComponents();
|
||||
}, []);
|
||||
|
||||
// 편집 모달 이벤트 리스너 등록
|
||||
// 편집 모달 이벤트 리스너 등록 (활성 탭에서만 처리)
|
||||
const tabId = useTabId();
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||
const state = useTabStore.getState();
|
||||
const currentActiveTabId = state[state.mode].activeTabId;
|
||||
if (tabId && tabId !== currentActiveTabId) return;
|
||||
|
||||
setEditModalConfig({
|
||||
screenId: event.detail.screenId,
|
||||
@@ -148,7 +158,7 @@ function ScreenViewPage() {
|
||||
// @ts-expect-error - CustomEvent type
|
||||
window.removeEventListener("openEditModal", handleOpenEditModal);
|
||||
};
|
||||
}, []);
|
||||
}, [tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScreen = async () => {
|
||||
@@ -1344,16 +1354,17 @@ function ScreenViewPage() {
|
||||
}
|
||||
|
||||
// 실제 컴포넌트를 Provider로 감싸기
|
||||
function ScreenViewPageWrapper() {
|
||||
function ScreenViewPageWrapper({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {}) {
|
||||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenContextProvider>
|
||||
<SplitPanelProvider>
|
||||
<ScreenViewPage />
|
||||
<ScreenViewPage screenIdProp={screenIdProp} menuObjidProp={menuObjidProp} />
|
||||
</SplitPanelProvider>
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScreenViewPageWrapper };
|
||||
export default ScreenViewPageWrapper;
|
||||
|
||||
@@ -424,4 +424,38 @@ select {
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 모달 필수 입력 검증 ===== */
|
||||
@keyframes validationShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20%, 60% { transform: translateX(-4px); }
|
||||
40%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
/* 흔들림 애니메이션 (일회성) */
|
||||
[data-validation-highlight] {
|
||||
animation: validationShake 400ms ease-in-out;
|
||||
}
|
||||
|
||||
/* 빨간 테두리 (값 입력 전까지 유지) */
|
||||
[data-validation-error] {
|
||||
border-color: hsl(var(--destructive)) !important;
|
||||
}
|
||||
|
||||
/* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
|
||||
.validation-error-msg-wrapper {
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.validation-error-msg-wrapper > p {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 0;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--destructive));
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== End of Global Styles ===== */
|
||||
|
||||
@@ -4,7 +4,7 @@ import "./globals.css";
|
||||
import { QueryProvider } from "@/providers/QueryProvider";
|
||||
import { RegistryProvider } from "./registry-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import ScreenModal from "@/components/common/ScreenModal";
|
||||
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -45,7 +45,6 @@ export default function RootLayout({
|
||||
<QueryProvider>
|
||||
<RegistryProvider>{children}</RegistryProvider>
|
||||
<Toaster position="top-right" />
|
||||
<ScreenModal />
|
||||
</QueryProvider>
|
||||
{/* Portal 컨테이너 */}
|
||||
<div id="portal-root" data-radix-portal="true" />
|
||||
|
||||
@@ -27,6 +27,8 @@ import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
@@ -43,6 +45,8 @@ interface ScreenModalProps {
|
||||
export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
const { userId, userName, user } = useAuth();
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
const tabId = useTabId();
|
||||
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
|
||||
|
||||
const [modalState, setModalState] = useState<ScreenModalState>({
|
||||
isOpen: false,
|
||||
@@ -170,6 +174,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
// 활성 탭에서만 이벤트 처리 (다른 탭의 ScreenModal 인스턴스는 무시)
|
||||
const storeState = useTabStore.getState();
|
||||
const currentActiveTabId = storeState[storeState.mode].activeTabId;
|
||||
if (tabId && tabId !== currentActiveTabId) return;
|
||||
|
||||
const {
|
||||
screenId,
|
||||
title,
|
||||
@@ -191,7 +200,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
isCreateMode,
|
||||
});
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
// 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
|
||||
// 폼 변경 추적 초기화
|
||||
@@ -443,7 +452,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
window.removeEventListener("closeSaveModal", handleCloseModal);
|
||||
window.removeEventListener("saveSuccessInModal", handleSaveSuccess);
|
||||
};
|
||||
}, [continuousMode]); // continuousMode 의존성 추가
|
||||
}, [tabId, continuousMode]);
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
@@ -852,7 +861,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
} else {
|
||||
handleCloseInternal();
|
||||
}
|
||||
}, []);
|
||||
}, [tabId]);
|
||||
|
||||
// 확인 후 실제로 모달을 닫는 함수
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
@@ -990,7 +999,6 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
<Dialog
|
||||
open={modalState.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// X 버튼 클릭 시에도 확인 다이얼로그 표시
|
||||
if (!open) {
|
||||
handleCloseAttempt();
|
||||
}
|
||||
@@ -1217,6 +1225,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
isInModal={true}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1260,6 +1269,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
isInModal={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
109
frontend/components/layout/AdminPageRenderer.tsx
Normal file
109
frontend/components/layout/AdminPageRenderer.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 관리자 페이지를 URL 기반으로 동적 로딩하는 레지스트리.
|
||||
* 사이드바 메뉴에서 접근하는 주요 페이지를 명시적으로 매핑한다.
|
||||
* 매핑되지 않은 URL은 catch-all fallback으로 처리된다.
|
||||
*/
|
||||
const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
// 관리자 메인
|
||||
"/admin": dynamic(() => import("@/app/(main)/admin/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 메뉴 관리
|
||||
"/admin/menu": dynamic(() => import("@/app/(main)/admin/menu/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 사용자 관리
|
||||
"/admin/userMng/userMngList": dynamic(() => import("@/app/(main)/admin/userMng/userMngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/userMng/rolesList": dynamic(() => import("@/app/(main)/admin/userMng/rolesList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/userMng/userAuthList": dynamic(() => import("@/app/(main)/admin/userMng/userAuthList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/userMng/companyList": dynamic(() => import("@/app/(main)/admin/userMng/companyList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 화면 관리
|
||||
"/admin/screenMng/screenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/screenMngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/screenMng/popScreenMngList": dynamic(() => import("@/app/(main)/admin/screenMng/popScreenMngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/screenMng/dashboardList": dynamic(() => import("@/app/(main)/admin/screenMng/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/screenMng/reportList": dynamic(() => import("@/app/(main)/admin/screenMng/reportList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 시스템 관리
|
||||
"/admin/systemMng/commonCodeList": dynamic(() => import("@/app/(main)/admin/systemMng/commonCodeList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/tableMngList": dynamic(() => import("@/app/(main)/admin/systemMng/tableMngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/i18nList": dynamic(() => import("@/app/(main)/admin/systemMng/i18nList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/collection-managementList": dynamic(() => import("@/app/(main)/admin/systemMng/collection-managementList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/systemMng/dataflow": dynamic(() => import("@/app/(main)/admin/systemMng/dataflow/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 자동화 관리
|
||||
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 메일
|
||||
"/admin/automaticMng/mail/send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/send/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/receive": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/receive/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/sent": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/sent/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/drafts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/drafts/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/trash": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/trash/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/accounts": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/accounts/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/templates": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/templates/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/dashboardList": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/dashboardList/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/automaticMng/mail/bulk-send": dynamic(() => import("@/app/(main)/admin/automaticMng/mail/bulk-send/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 배치 관리
|
||||
"/admin/batch-management": dynamic(() => import("@/app/(main)/admin/batch-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/batch-management-new": dynamic(() => import("@/app/(main)/admin/batch-management-new/page"), { ssr: false, loading: LoadingFallback }),
|
||||
|
||||
// 기타
|
||||
"/admin/cascading-management": dynamic(() => import("@/app/(main)/admin/cascading-management/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/cascading-relations": dynamic(() => import("@/app/(main)/admin/cascading-relations/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/layouts": dynamic(() => import("@/app/(main)/admin/layouts/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/templates": dynamic(() => import("@/app/(main)/admin/templates/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/monitoring": dynamic(() => import("@/app/(main)/admin/monitoring/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/standards": dynamic(() => import("@/app/(main)/admin/standards/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/flow-external-db": dynamic(() => import("@/app/(main)/admin/flow-external-db/page"), { ssr: false, loading: LoadingFallback }),
|
||||
"/admin/auto-fill": dynamic(() => import("@/app/(main)/admin/auto-fill/page"), { ssr: false, loading: LoadingFallback }),
|
||||
};
|
||||
|
||||
// 매핑되지 않은 URL용 Fallback
|
||||
function AdminPageFallback({ url }: { url: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-semibold text-foreground">페이지 로딩 불가</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
경로: {url}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
AdminPageRenderer 레지스트리에 이 URL을 추가해주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AdminPageRendererProps {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
|
||||
const PageComponent = useMemo(() => {
|
||||
// URL에서 쿼리스트링/해시 제거 후 매칭
|
||||
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
|
||||
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
|
||||
}, [url]);
|
||||
|
||||
if (!PageComponent) {
|
||||
return <AdminPageFallback url={url} />;
|
||||
}
|
||||
|
||||
return <PageComponent />;
|
||||
}
|
||||
@@ -30,6 +30,9 @@ import { toast } from "sonner";
|
||||
import { ProfileModal } from "./ProfileModal";
|
||||
import { Logo } from "./Logo";
|
||||
import { SideMenu } from "./SideMenu";
|
||||
import { TabBar } from "./TabBar";
|
||||
import { TabContent } from "./TabContent";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -97,7 +100,8 @@ const getMenuIcon = (menuName: string, dbIconName?: string | null) => {
|
||||
};
|
||||
|
||||
// 메뉴 데이터를 UI용으로 변환하는 함수 (최상위 "사용자", "관리자" 제외)
|
||||
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0"): any[] => {
|
||||
// parentPath: 탭 제목에 "기준정보 - 회사관리" 형태로 상위 카테고리를 포함하기 위한 경로
|
||||
const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, parentId: string = "0", parentPath: string = ""): any[] => {
|
||||
const filteredMenus = menus
|
||||
.filter((menu) => (menu.parent_obj_id || menu.PARENT_OBJ_ID) === parentId)
|
||||
.filter((menu) => (menu.status || menu.STATUS) === "active")
|
||||
@@ -110,40 +114,34 @@ const convertMenuToUI = (menus: MenuItem[], userInfo: ExtendedUserInfo | null, p
|
||||
for (const menu of filteredMenus) {
|
||||
const menuName = (menu.menu_name_kor || menu.MENU_NAME_KOR || "").toLowerCase();
|
||||
|
||||
// "사용자" 또는 "관리자" 카테고리면 하위 메뉴들을 직접 추가
|
||||
if (menuName.includes("사용자") || menuName.includes("관리자")) {
|
||||
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID);
|
||||
const childMenus = convertMenuToUI(menus, userInfo, menu.objid || menu.OBJID, "");
|
||||
allMenus.push(...childMenus);
|
||||
} else {
|
||||
// 일반 메뉴는 그대로 추가
|
||||
allMenus.push(convertSingleMenu(menu, menus, userInfo));
|
||||
allMenus.push(convertSingleMenu(menu, menus, userInfo, ""));
|
||||
}
|
||||
}
|
||||
|
||||
return allMenus;
|
||||
}
|
||||
|
||||
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo));
|
||||
return filteredMenus.map((menu) => convertSingleMenu(menu, menus, userInfo, parentPath));
|
||||
};
|
||||
|
||||
// 단일 메뉴 변환 함수
|
||||
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null): any => {
|
||||
const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: ExtendedUserInfo | null, parentPath: string = ""): any => {
|
||||
const menuId = menu.objid || menu.OBJID;
|
||||
|
||||
// 사용자 locale 기준으로 번역 처리
|
||||
const getDisplayText = (menu: MenuItem) => {
|
||||
// 다국어 텍스트가 있으면 사용, 없으면 기본 텍스트 사용
|
||||
if (menu.translated_name || menu.TRANSLATED_NAME) {
|
||||
return menu.translated_name || menu.TRANSLATED_NAME;
|
||||
const getDisplayText = (m: MenuItem) => {
|
||||
if (m.translated_name || m.TRANSLATED_NAME) {
|
||||
return m.translated_name || m.TRANSLATED_NAME;
|
||||
}
|
||||
|
||||
const baseName = menu.menu_name_kor || menu.MENU_NAME_KOR || "메뉴명 없음";
|
||||
|
||||
// 사용자 정보에서 locale 가져오기
|
||||
const baseName = m.menu_name_kor || m.MENU_NAME_KOR || "메뉴명 없음";
|
||||
const userLocale = userInfo?.locale || "ko";
|
||||
|
||||
if (userLocale === "EN") {
|
||||
// 영어 번역
|
||||
const translations: { [key: string]: string } = {
|
||||
관리자: "Administrator",
|
||||
사용자: "User Management",
|
||||
@@ -163,7 +161,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
||||
}
|
||||
}
|
||||
} else if (userLocale === "JA") {
|
||||
// 일본어 번역
|
||||
const translations: { [key: string]: string } = {
|
||||
관리자: "管理者",
|
||||
사용자: "ユーザー管理",
|
||||
@@ -183,7 +180,6 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
||||
}
|
||||
}
|
||||
} else if (userLocale === "ZH") {
|
||||
// 중국어 번역
|
||||
const translations: { [key: string]: string } = {
|
||||
관리자: "管理员",
|
||||
사용자: "用户管理",
|
||||
@@ -207,11 +203,15 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten
|
||||
return baseName;
|
||||
};
|
||||
|
||||
const children = convertMenuToUI(allMenus, userInfo, menuId);
|
||||
const displayName = getDisplayText(menu);
|
||||
const tabTitle = parentPath ? `${parentPath} - ${displayName}` : displayName;
|
||||
|
||||
const children = convertMenuToUI(allMenus, userInfo, menuId, tabTitle);
|
||||
|
||||
return {
|
||||
id: menuId,
|
||||
name: getDisplayText(menu),
|
||||
name: displayName,
|
||||
tabTitle,
|
||||
icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON),
|
||||
url: menu.menu_url || menu.MENU_URL || "#",
|
||||
children: children.length > 0 ? children : undefined,
|
||||
@@ -231,6 +231,28 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
||||
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||
|
||||
// URL 직접 접근 시 탭 자동 열기 (북마크/공유 링크 대응)
|
||||
useEffect(() => {
|
||||
const store = useTabStore.getState();
|
||||
const currentModeTabs = store[store.mode].tabs;
|
||||
if (currentModeTabs.length > 0) return;
|
||||
|
||||
// /screens/[screenId] 패턴 감지
|
||||
const screenMatch = pathname.match(/^\/screens\/(\d+)/);
|
||||
if (screenMatch) {
|
||||
const screenId = parseInt(screenMatch[1]);
|
||||
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
|
||||
store.openTab({ type: "screen", title: `화면 ${screenId}`, screenId, menuObjid });
|
||||
return;
|
||||
}
|
||||
|
||||
// /admin/* 패턴 감지 -> admin 모드로 전환 후 탭 열기
|
||||
if (pathname.startsWith("/admin") && pathname !== "/admin") {
|
||||
store.setMode("admin");
|
||||
store.openTab({ type: "admin", title: pathname.split("/").pop() || "관리자", adminUrl: pathname });
|
||||
}
|
||||
}, []); // 마운트 시 1회만 실행
|
||||
|
||||
// 현재 회사명 조회 (SUPER_ADMIN 전용)
|
||||
useEffect(() => {
|
||||
const fetchCurrentCompanyName = async () => {
|
||||
@@ -306,8 +328,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
handleRegisterVehicle,
|
||||
} = useProfile(user, refreshUserData, refreshMenus);
|
||||
|
||||
// 현재 경로에 따라 어드민 모드인지 판단 (쿼리 파라미터도 고려)
|
||||
const isAdminMode = pathname.startsWith("/admin") || searchParams.get("mode") === "admin";
|
||||
// 탭 스토어에서 현재 모드 가져오기
|
||||
const tabMode = useTabStore((s) => s.mode);
|
||||
const setTabMode = useTabStore((s) => s.setMode);
|
||||
const isAdminMode = tabMode === "admin";
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (iframe 임베드용)
|
||||
const isPreviewMode = searchParams.get("preview") === "true";
|
||||
@@ -327,67 +351,55 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
setExpandedMenus(newExpanded);
|
||||
};
|
||||
|
||||
// 메뉴 클릭 핸들러
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
// 메뉴 클릭 핸들러 (탭으로 열기)
|
||||
const handleMenuClick = async (menu: any) => {
|
||||
if (menu.hasChildren) {
|
||||
toggleMenu(menu.id);
|
||||
} else {
|
||||
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
|
||||
const menuName = menu.label || menu.name || "메뉴";
|
||||
// tabTitle: "기준정보 - 회사관리" 형태의 상위 포함 이름
|
||||
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("currentMenuName", menuName);
|
||||
}
|
||||
|
||||
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
|
||||
try {
|
||||
const menuObjid = menu.objid || menu.id;
|
||||
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||
|
||||
if (assignedScreens.length > 0) {
|
||||
// 할당된 화면이 있으면 첫 번째 화면으로 이동
|
||||
const firstScreen = assignedScreens[0];
|
||||
|
||||
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
|
||||
const params = new URLSearchParams();
|
||||
if (isAdminMode) {
|
||||
params.set("mode", "admin");
|
||||
}
|
||||
params.set("menuObjid", menuObjid.toString());
|
||||
|
||||
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
|
||||
|
||||
router.push(screenPath);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
openTab({
|
||||
type: "screen",
|
||||
title: menuName,
|
||||
screenId: firstScreen.screenId,
|
||||
menuObjid: parseInt(menuObjid),
|
||||
});
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("할당된 화면 조회 실패:", error);
|
||||
}
|
||||
|
||||
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
|
||||
if (menu.url && menu.url !== "#") {
|
||||
router.push(menu.url);
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
openTab({
|
||||
type: "admin",
|
||||
title: menuName,
|
||||
adminUrl: menu.url,
|
||||
});
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
} else {
|
||||
// URL도 없고 할당된 화면도 없으면 경고 메시지
|
||||
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 모드 전환 핸들러
|
||||
// 모드 전환: 탭 스토어의 모드만 변경 (각 모드 탭은 독립 보존)
|
||||
const handleModeSwitch = () => {
|
||||
if (isAdminMode) {
|
||||
// 관리자 → 사용자 모드: 선택한 회사 유지
|
||||
router.push("/main");
|
||||
} else {
|
||||
// 사용자 → 관리자 모드: 선택한 회사 유지 (회사 전환 없음)
|
||||
router.push("/admin");
|
||||
}
|
||||
setTabMode(isAdminMode ? "user" : "admin");
|
||||
};
|
||||
|
||||
// 로그아웃 핸들러
|
||||
@@ -400,13 +412,57 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용)
|
||||
// 사이드바 메뉴 -> 탭 바 드래그용 데이터 생성
|
||||
const buildMenuDragData = async (menu: any): Promise<string | null> => {
|
||||
const menuName = menu.label || menu.name || "메뉴";
|
||||
const menuObjid = menu.objid || menu.id;
|
||||
|
||||
try {
|
||||
const assignedScreens = await menuScreenApi.getScreensByMenu(menuObjid);
|
||||
if (assignedScreens.length > 0) {
|
||||
return JSON.stringify({
|
||||
type: "screen" as const,
|
||||
title: menuName,
|
||||
screenId: assignedScreens[0].screenId,
|
||||
menuObjid: parseInt(menuObjid),
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
if (menu.url && menu.url !== "#") {
|
||||
return JSON.stringify({
|
||||
type: "admin" as const,
|
||||
title: menuName,
|
||||
adminUrl: menu.url,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleMenuDragStart = (e: React.DragEvent, menu: any) => {
|
||||
if (menu.hasChildren) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
const menuName = menu.tabTitle || menu.label || menu.name || "메뉴";
|
||||
const menuObjid = menu.objid || menu.id;
|
||||
const dragPayload = JSON.stringify({ menuName, menuObjid, url: menu.url });
|
||||
e.dataTransfer.setData("application/tab-menu-pending", dragPayload);
|
||||
e.dataTransfer.setData("text/plain", menuName);
|
||||
};
|
||||
|
||||
// 메뉴 트리 렌더링 (드래그 가능)
|
||||
const renderMenu = (menu: any, level: number = 0) => {
|
||||
const isExpanded = expandedMenus.has(menu.id);
|
||||
const isLeaf = !menu.hasChildren;
|
||||
|
||||
return (
|
||||
<div key={menu.id}>
|
||||
<div
|
||||
draggable={isLeaf}
|
||||
onDragStart={(e) => handleMenuDragStart(e, menu)}
|
||||
className={`group flex h-10 cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out ${
|
||||
pathname === menu.url
|
||||
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||
@@ -435,6 +491,8 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
{menu.children?.map((child: any) => (
|
||||
<div
|
||||
key={child.id}
|
||||
draggable={!child.hasChildren}
|
||||
onDragStart={(e) => handleMenuDragStart(e, child)}
|
||||
className={`flex cursor-pointer items-center rounded-lg px-3 py-2 text-sm transition-colors hover:cursor-pointer ${
|
||||
pathname === child.url
|
||||
? "border-primary border-l-4 bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900"
|
||||
@@ -712,9 +770,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
|
||||
<main className={`min-w-0 flex-1 overflow-auto bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||
{children}
|
||||
{/* 가운데 컨텐츠 영역 - 탭 시스템 */}
|
||||
<main className={`flex min-w-0 flex-1 flex-col overflow-hidden bg-white ${isMobile ? "h-[calc(100vh-56px)]" : "h-screen"}`}>
|
||||
<TabBar />
|
||||
<TabContent />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
23
frontend/components/layout/EmptyDashboard.tsx
Normal file
23
frontend/components/layout/EmptyDashboard.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
|
||||
export function EmptyDashboard() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-white">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-muted">
|
||||
<LayoutGrid className="h-10 w-10 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xl font-semibold text-foreground">
|
||||
열린 탭이 없습니다
|
||||
</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
왼쪽 사이드바에서 메뉴를 클릭하거나 드래그하여 탭을 추가하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
698
frontend/components/layout/TabBar.tsx
Normal file
698
frontend/components/layout/TabBar.tsx
Normal file
@@ -0,0 +1,698 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect, useCallback } from "react";
|
||||
import { X, RotateCw, ChevronDown } from "lucide-react";
|
||||
import { useTabStore, selectTabs, selectActiveTabId, Tab } from "@/stores/tabStore";
|
||||
import { menuScreenApi } from "@/lib/api/screen";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TAB_WIDTH = 180;
|
||||
const TAB_GAP = 2;
|
||||
const TAB_UNIT = TAB_WIDTH + TAB_GAP;
|
||||
const OVERFLOW_BTN_WIDTH = 48;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
const SETTLE_MS = 70;
|
||||
const DROP_SETTLE_MS = 180;
|
||||
const BAR_PAD_X = 8;
|
||||
|
||||
interface DragState {
|
||||
tabId: string;
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
currentX: number;
|
||||
tabRect: DOMRect;
|
||||
fromIndex: number;
|
||||
targetIndex: number;
|
||||
activated: boolean;
|
||||
settling: boolean;
|
||||
}
|
||||
|
||||
interface DropGhost {
|
||||
title: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
targetIdx: number;
|
||||
tabCountAtCreation: number;
|
||||
}
|
||||
|
||||
export function TabBar() {
|
||||
const tabs = useTabStore(selectTabs);
|
||||
const activeTabId = useTabStore(selectActiveTabId);
|
||||
const {
|
||||
switchTab,
|
||||
closeTab,
|
||||
refreshTab,
|
||||
closeOtherTabs,
|
||||
closeTabsToLeft,
|
||||
closeTabsToRight,
|
||||
closeAllTabs,
|
||||
updateTabOrder,
|
||||
openTab,
|
||||
} = useTabStore();
|
||||
|
||||
// --- Refs ---
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dragActiveRef = useRef(false);
|
||||
const dragLeaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dropGhostRef = useRef<HTMLDivElement>(null);
|
||||
const prevTabCountRef = useRef(tabs.length);
|
||||
|
||||
// --- State ---
|
||||
const [visibleCount, setVisibleCount] = useState(tabs.length);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
tabId: string;
|
||||
} | null>(null);
|
||||
const [dragState, setDragState] = useState<DragState | null>(null);
|
||||
const [externalDragIdx, setExternalDragIdx] = useState<number | null>(null);
|
||||
const [dropGhost, setDropGhost] = useState<DropGhost | null>(null);
|
||||
|
||||
dragActiveRef.current = !!dragState;
|
||||
|
||||
// --- 타이머 정리 ---
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (settleTimer.current) clearTimeout(settleTimer.current);
|
||||
if (dragLeaveTimerRef.current) clearTimeout(dragLeaveTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- 드롭 고스트: Web Animations API로 드롭 위치 → 목표 슬롯 이동 ---
|
||||
useEffect(() => {
|
||||
if (!dropGhost) return;
|
||||
const el = dropGhostRef.current;
|
||||
const bar = containerRef.current?.getBoundingClientRect();
|
||||
if (!el || !bar) return;
|
||||
|
||||
const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT;
|
||||
const targetY = bar.bottom - 28;
|
||||
const dx = dropGhost.startX - targetX;
|
||||
const dy = dropGhost.startY - targetY;
|
||||
|
||||
const anim = el.animate(
|
||||
[
|
||||
{ transform: `translate(${dx}px, ${dy}px)`, opacity: 0.85 },
|
||||
{ transform: "translate(0, 0)", opacity: 1 },
|
||||
],
|
||||
{
|
||||
duration: DROP_SETTLE_MS,
|
||||
easing: "cubic-bezier(0.25, 1, 0.5, 1)",
|
||||
fill: "forwards",
|
||||
},
|
||||
);
|
||||
|
||||
anim.onfinish = () => {
|
||||
setDropGhost(null);
|
||||
setExternalDragIdx(null);
|
||||
};
|
||||
|
||||
const safety = setTimeout(() => {
|
||||
setDropGhost(null);
|
||||
setExternalDragIdx(null);
|
||||
}, DROP_SETTLE_MS + 500);
|
||||
|
||||
return () => {
|
||||
anim.cancel();
|
||||
clearTimeout(safety);
|
||||
};
|
||||
}, [dropGhost]);
|
||||
|
||||
// --- 오버플로우 계산 (드래그 중 재계산 방지) ---
|
||||
const recalcVisible = useCallback(() => {
|
||||
if (dragActiveRef.current) return;
|
||||
if (!containerRef.current) return;
|
||||
const w = containerRef.current.clientWidth;
|
||||
setVisibleCount(Math.max(1, Math.floor((w - OVERFLOW_BTN_WIDTH) / TAB_UNIT)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
recalcVisible();
|
||||
const obs = new ResizeObserver(recalcVisible);
|
||||
if (containerRef.current) obs.observe(containerRef.current);
|
||||
return () => obs.disconnect();
|
||||
}, [recalcVisible]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
recalcVisible();
|
||||
}, [tabs.length, recalcVisible]);
|
||||
|
||||
const visibleTabs = tabs.slice(0, visibleCount);
|
||||
const overflowTabs = tabs.slice(visibleCount);
|
||||
const hasOverflow = overflowTabs.length > 0;
|
||||
|
||||
const activeInOverflow = activeTabId && overflowTabs.some((t) => t.id === activeTabId);
|
||||
let displayVisible = visibleTabs;
|
||||
let displayOverflow = overflowTabs;
|
||||
|
||||
if (activeInOverflow && activeTabId) {
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId)!;
|
||||
displayVisible = [...visibleTabs.slice(0, -1), activeTab];
|
||||
displayOverflow = overflowTabs.filter((t) => t.id !== activeTabId);
|
||||
if (visibleTabs.length > 0) {
|
||||
displayOverflow = [visibleTabs[visibleTabs.length - 1], ...displayOverflow];
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 사이드바 -> 탭 바 드롭 (네이티브 DnD + 삽입 위치 애니메이션)
|
||||
// ============================================================
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (tabs.length !== prevTabCountRef.current && externalDragIdx !== null) {
|
||||
setExternalDragIdx(null);
|
||||
}
|
||||
prevTabCountRef.current = tabs.length;
|
||||
}, [tabs.length, externalDragIdx]);
|
||||
|
||||
const resolveMenuAndOpenTab = async (
|
||||
menuName: string,
|
||||
menuObjid: string | number,
|
||||
url: string,
|
||||
insertIndex?: number,
|
||||
) => {
|
||||
const numericObjid = typeof menuObjid === "string" ? parseInt(menuObjid) : menuObjid;
|
||||
try {
|
||||
const screens = await menuScreenApi.getScreensByMenu(numericObjid);
|
||||
if (screens.length > 0) {
|
||||
openTab(
|
||||
{ type: "screen", title: menuName, screenId: screens[0].screenId, menuObjid: numericObjid },
|
||||
insertIndex,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (url && url !== "#") {
|
||||
openTab({ type: "admin", title: menuName, adminUrl: url }, insertIndex);
|
||||
} else {
|
||||
setExternalDragIdx(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBarDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
if (dragLeaveTimerRef.current) {
|
||||
clearTimeout(dragLeaveTimerRef.current);
|
||||
dragLeaveTimerRef.current = null;
|
||||
}
|
||||
const bar = containerRef.current?.getBoundingClientRect();
|
||||
if (bar) {
|
||||
const idx = Math.max(
|
||||
0,
|
||||
Math.min(Math.round((e.clientX - bar.left - BAR_PAD_X) / TAB_UNIT), displayVisible.length),
|
||||
);
|
||||
setExternalDragIdx(idx);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBarDragLeave = (e: React.DragEvent) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
dragLeaveTimerRef.current = setTimeout(() => {
|
||||
setExternalDragIdx(null);
|
||||
dragLeaveTimerRef.current = null;
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const createDropGhost = (e: React.DragEvent, title: string, targetIdx: number) => {
|
||||
setDropGhost({
|
||||
title,
|
||||
startX: e.clientX - TAB_WIDTH / 2,
|
||||
startY: e.clientY - 14,
|
||||
targetIdx,
|
||||
tabCountAtCreation: tabs.length,
|
||||
});
|
||||
};
|
||||
|
||||
const handleBarDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (dragLeaveTimerRef.current) {
|
||||
clearTimeout(dragLeaveTimerRef.current);
|
||||
dragLeaveTimerRef.current = null;
|
||||
}
|
||||
const insertIdx = externalDragIdx ?? undefined;
|
||||
const ghostIdx = insertIdx ?? displayVisible.length;
|
||||
|
||||
const pending = e.dataTransfer.getData("application/tab-menu-pending");
|
||||
if (pending) {
|
||||
try {
|
||||
const { menuName, menuObjid, url } = JSON.parse(pending);
|
||||
createDropGhost(e, menuName, ghostIdx);
|
||||
resolveMenuAndOpenTab(menuName, menuObjid, url, insertIdx);
|
||||
} catch {
|
||||
setExternalDragIdx(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const menuData = e.dataTransfer.getData("application/tab-menu");
|
||||
if (menuData && menuData.length > 2) {
|
||||
try {
|
||||
const parsed = JSON.parse(menuData);
|
||||
createDropGhost(e, parsed.title || "새 탭", ghostIdx);
|
||||
setExternalDragIdx(null);
|
||||
openTab(parsed, insertIdx);
|
||||
} catch {
|
||||
setExternalDragIdx(null);
|
||||
}
|
||||
} else {
|
||||
setExternalDragIdx(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 탭 드래그 (Pointer Events) - 임계값 + settling 애니메이션
|
||||
// ============================================================
|
||||
|
||||
const calcTarget = useCallback(
|
||||
(clientX: number, startX: number, fromIndex: number): number => {
|
||||
const delta = Math.round((clientX - startX) / TAB_UNIT);
|
||||
return Math.max(0, Math.min(fromIndex + delta, displayVisible.length - 1));
|
||||
},
|
||||
[displayVisible.length],
|
||||
);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent, tabId: string, idx: number) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
if (dragState?.settling) return;
|
||||
|
||||
if (settleTimer.current) {
|
||||
clearTimeout(settleTimer.current);
|
||||
settleTimer.current = null;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
setDragState({
|
||||
tabId,
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
currentX: e.clientX,
|
||||
tabRect: (e.currentTarget as HTMLElement).getBoundingClientRect(),
|
||||
fromIndex: idx,
|
||||
targetIndex: idx,
|
||||
activated: false,
|
||||
settling: false,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!dragState || dragState.settling) return;
|
||||
if (e.pointerId !== dragState.pointerId) return;
|
||||
const bar = containerRef.current?.getBoundingClientRect();
|
||||
if (!bar) return;
|
||||
|
||||
const clampedX = Math.max(bar.left, Math.min(e.clientX, bar.right));
|
||||
|
||||
if (!dragState.activated) {
|
||||
if (Math.abs(clampedX - dragState.startX) < DRAG_THRESHOLD) return;
|
||||
setDragState((p) =>
|
||||
p
|
||||
? {
|
||||
...p,
|
||||
activated: true,
|
||||
currentX: clampedX,
|
||||
targetIndex: calcTarget(clampedX, p.startX, p.fromIndex),
|
||||
}
|
||||
: null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setDragState((p) =>
|
||||
p ? { ...p, currentX: clampedX, targetIndex: calcTarget(clampedX, p.startX, p.fromIndex) } : null,
|
||||
);
|
||||
},
|
||||
[dragState, calcTarget],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!dragState || dragState.settling) return;
|
||||
if (e.pointerId !== dragState.pointerId) return;
|
||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||
|
||||
if (!dragState.activated) {
|
||||
switchTab(dragState.tabId);
|
||||
setDragState(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { fromIndex, targetIndex, tabId } = dragState;
|
||||
|
||||
setDragState((p) => (p ? { ...p, settling: true } : null));
|
||||
|
||||
if (targetIndex === fromIndex) {
|
||||
settleTimer.current = setTimeout(() => setDragState(null), SETTLE_MS + 10);
|
||||
return;
|
||||
}
|
||||
|
||||
const actualFrom = tabs.findIndex((t) => t.id === tabId);
|
||||
const tgtTab = displayVisible[targetIndex];
|
||||
const actualTo = tgtTab ? tabs.findIndex((t) => t.id === tgtTab.id) : actualFrom;
|
||||
|
||||
settleTimer.current = setTimeout(() => {
|
||||
setDragState(null);
|
||||
if (actualFrom !== -1 && actualTo !== -1 && actualFrom !== actualTo) {
|
||||
updateTabOrder(actualFrom, actualTo);
|
||||
}
|
||||
}, SETTLE_MS + 10);
|
||||
},
|
||||
[dragState, tabs, displayVisible, switchTab, updateTabOrder],
|
||||
);
|
||||
|
||||
const handleLostPointerCapture = useCallback(() => {
|
||||
if (dragState && !dragState.settling) {
|
||||
setDragState(null);
|
||||
if (settleTimer.current) {
|
||||
clearTimeout(settleTimer.current);
|
||||
settleTimer.current = null;
|
||||
}
|
||||
}
|
||||
}, [dragState]);
|
||||
|
||||
// ============================================================
|
||||
// 스타일 계산
|
||||
// ============================================================
|
||||
|
||||
const getTabAnimStyle = (tabId: string, index: number): React.CSSProperties => {
|
||||
if (externalDragIdx !== null && !dragState) {
|
||||
return {
|
||||
transform: index >= externalDragIdx ? `translateX(${TAB_UNIT}px)` : "none",
|
||||
transition: `transform ${DROP_SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!dragState || !dragState.activated) return {};
|
||||
|
||||
const { fromIndex, targetIndex, tabId: draggedId } = dragState;
|
||||
|
||||
if (tabId === draggedId) {
|
||||
return { opacity: 0, transition: "none" };
|
||||
}
|
||||
|
||||
let shift = 0;
|
||||
if (fromIndex < targetIndex) {
|
||||
if (index > fromIndex && index <= targetIndex) shift = -TAB_UNIT;
|
||||
} else if (fromIndex > targetIndex) {
|
||||
if (index >= targetIndex && index < fromIndex) shift = TAB_UNIT;
|
||||
}
|
||||
|
||||
return {
|
||||
transform: shift !== 0 ? `translateX(${shift}px)` : "none",
|
||||
transition: `transform ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1)`,
|
||||
};
|
||||
};
|
||||
|
||||
const getGhostStyle = (): React.CSSProperties | null => {
|
||||
if (!dragState || !dragState.activated) return null;
|
||||
const bar = containerRef.current?.getBoundingClientRect();
|
||||
if (!bar) return null;
|
||||
|
||||
const base: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
top: dragState.tabRect.top,
|
||||
width: TAB_WIDTH,
|
||||
height: dragState.tabRect.height,
|
||||
zIndex: 100,
|
||||
pointerEvents: "none",
|
||||
opacity: 0.9,
|
||||
};
|
||||
|
||||
if (dragState.settling) {
|
||||
return {
|
||||
...base,
|
||||
left: bar.left + BAR_PAD_X + dragState.targetIndex * TAB_UNIT,
|
||||
opacity: 1,
|
||||
boxShadow: "none",
|
||||
transition: `left ${SETTLE_MS}ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 80ms ease-out`,
|
||||
};
|
||||
}
|
||||
|
||||
const offsetX = dragState.currentX - dragState.startX;
|
||||
const rawLeft = dragState.tabRect.left + offsetX;
|
||||
return {
|
||||
...base,
|
||||
left: Math.max(bar.left, Math.min(rawLeft, bar.right - TAB_WIDTH)),
|
||||
transition: "none",
|
||||
};
|
||||
};
|
||||
|
||||
const ghostStyle = getGhostStyle();
|
||||
const draggedTab = dragState ? tabs.find((t) => t.id === dragState.tabId) : null;
|
||||
|
||||
// ============================================================
|
||||
// 우클릭 컨텍스트 메뉴
|
||||
// ============================================================
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
const close = () => setContextMenu(null);
|
||||
window.addEventListener("click", close);
|
||||
window.addEventListener("scroll", close);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
window.removeEventListener("scroll", close);
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
// ============================================================
|
||||
// 렌더링
|
||||
// ============================================================
|
||||
|
||||
const renderTab = (tab: Tab, displayIndex: number) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const animStyle = getTabAnimStyle(tab.id, displayIndex);
|
||||
const hiddenByGhost =
|
||||
!!dropGhost && displayIndex === dropGhost.targetIdx && tabs.length > dropGhost.tabCountAtCreation;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
onPointerDown={(e) => handlePointerDown(e, tab.id, displayIndex)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onLostPointerCapture={handleLostPointerCapture}
|
||||
onContextMenu={(e) => handleContextMenu(e, tab.id)}
|
||||
className={cn(
|
||||
"group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none",
|
||||
isActive
|
||||
? "text-foreground z-10 -mb-px h-[30px] bg-white"
|
||||
: "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent",
|
||||
)}
|
||||
style={{
|
||||
width: TAB_WIDTH,
|
||||
touchAction: "none",
|
||||
...animStyle,
|
||||
...(hiddenByGhost ? { opacity: 0 } : {}),
|
||||
...(isActive ? {} : {}),
|
||||
}}
|
||||
title={tab.title}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] font-medium">{tab.title}</span>
|
||||
|
||||
<div className="flex shrink-0 items-center">
|
||||
{isActive && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
refreshTab(tab.id);
|
||||
}}
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground flex h-4 w-4 items-center justify-center rounded-sm transition-colors"
|
||||
>
|
||||
<RotateCw className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 items-center justify-center rounded-sm transition-colors",
|
||||
!isActive && "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-border bg-background relative flex h-[33px] shrink-0 items-end gap-[2px] overflow-hidden px-1.5"
|
||||
onDragOver={handleBarDragOver}
|
||||
onDragLeave={handleBarDragLeave}
|
||||
onDrop={handleBarDrop}
|
||||
>
|
||||
<div className="border-border pointer-events-none absolute inset-x-0 bottom-0 z-0 border-b" />
|
||||
<div className="pointer-events-none absolute inset-0 z-5" />
|
||||
{displayVisible.map((tab, i) => renderTab(tab, i))}
|
||||
|
||||
{hasOverflow && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground flex h-7 shrink-0 items-center gap-0.5 rounded-t-md border border-b-0 border-transparent px-2 text-[11px] font-medium transition-colors">
|
||||
+{displayOverflow.length}
|
||||
<ChevronDown className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-[300px] overflow-y-auto">
|
||||
{displayOverflow.map((tab) => (
|
||||
<DropdownMenuItem
|
||||
key={tab.id}
|
||||
onClick={() => switchTab(tab.id)}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{tab.title}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab.id);
|
||||
}}
|
||||
className="hover:bg-destructive/10 hover:text-destructive flex h-4 w-4 shrink-0 items-center justify-center rounded-sm"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 탭 드래그 고스트 (내부 재정렬) */}
|
||||
{ghostStyle && draggedTab && (
|
||||
<div
|
||||
style={ghostStyle}
|
||||
className="border-primary/50 bg-background rounded-t-md border border-b-0 px-3"
|
||||
>
|
||||
<div className="flex h-full items-center">
|
||||
<span className="truncate text-[11px] font-medium">{draggedTab.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 사이드바 드롭 고스트 (드롭 지점 → 탭 슬롯 이동) */}
|
||||
{dropGhost &&
|
||||
(() => {
|
||||
const bar = containerRef.current?.getBoundingClientRect();
|
||||
if (!bar) return null;
|
||||
|
||||
const targetX = bar.left + BAR_PAD_X + dropGhost.targetIdx * TAB_UNIT;
|
||||
const targetY = bar.bottom - 28;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropGhostRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: targetX,
|
||||
top: targetY,
|
||||
width: TAB_WIDTH,
|
||||
height: 28,
|
||||
zIndex: 100,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
className="border-border bg-background rounded-t-md border border-b-0 px-3"
|
||||
>
|
||||
<div className="flex h-full items-center">
|
||||
<span className="truncate text-[11px] font-medium">{dropGhost.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 우클릭 컨텍스트 메뉴 */}
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="border-border bg-popover fixed z-50 min-w-[180px] rounded-md border p-1 shadow-md"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<ContextMenuItem
|
||||
label="새로고침"
|
||||
onClick={() => {
|
||||
refreshTab(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div className="bg-border my-1 h-px" />
|
||||
<ContextMenuItem
|
||||
label="왼쪽 탭 닫기"
|
||||
onClick={() => {
|
||||
closeTabsToLeft(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
label="오른쪽 탭 닫기"
|
||||
onClick={() => {
|
||||
closeTabsToRight(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
label="다른 탭 모두 닫기"
|
||||
onClick={() => {
|
||||
closeOtherTabs(contextMenu.tabId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
<div className="bg-border my-1 h-px" />
|
||||
<ContextMenuItem
|
||||
label="모든 탭 닫기"
|
||||
onClick={() => {
|
||||
closeAllTabs();
|
||||
setContextMenu(null);
|
||||
}}
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
label,
|
||||
onClick,
|
||||
destructive,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
destructive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center rounded-sm px-2 py-1.5 text-xs transition-colors",
|
||||
destructive ? "text-destructive hover:bg-destructive/10" : "text-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
248
frontend/components/layout/TabContent.tsx
Normal file
248
frontend/components/layout/TabContent.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useEffect, useCallback } from "react";
|
||||
import { useTabStore, selectTabs, selectActiveTabId } from "@/stores/tabStore";
|
||||
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
|
||||
import { AdminPageRenderer } from "./AdminPageRenderer";
|
||||
import { EmptyDashboard } from "./EmptyDashboard";
|
||||
import { TabIdProvider } from "@/contexts/TabIdContext";
|
||||
import { registerModalPortal } from "@/lib/modalPortalRef";
|
||||
import ScreenModal from "@/components/common/ScreenModal";
|
||||
import {
|
||||
saveTabCacheImmediate,
|
||||
loadTabCache,
|
||||
captureAllScrollPositions,
|
||||
restoreAllScrollPositions,
|
||||
getElementPath,
|
||||
captureFormState,
|
||||
restoreFormState,
|
||||
clearTabCache,
|
||||
} from "@/lib/tabStateCache";
|
||||
|
||||
export function TabContent() {
|
||||
const tabs = useTabStore(selectTabs);
|
||||
const activeTabId = useTabStore(selectActiveTabId);
|
||||
const refreshKeys = useTabStore((s) => s.refreshKeys);
|
||||
|
||||
// 한 번이라도 활성화된 탭만 마운트 (지연 마운트)
|
||||
const mountedTabIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// 각 탭의 스크롤 컨테이너 ref
|
||||
const scrollRefsMap = useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||
|
||||
// 이전 활성 탭 ID 추적
|
||||
const prevActiveTabIdRef = useRef<string | null>(null);
|
||||
|
||||
// 활성 탭의 스크롤 위치를 실시간 추적 (display:none 전에 캡처하기 위함)
|
||||
// Map<tabId, Map<elementPath, {top, left}>> - 탭 내 여러 스크롤 영역을 각각 추적
|
||||
const lastScrollMapRef = useRef<Map<string, Map<string, { top: number; left: number }>>>(new Map());
|
||||
// 요소 → 경로 캐시 (매 스크롤 이벤트마다 경로를 재계산하지 않기 위함)
|
||||
const pathCacheRef = useRef<WeakMap<HTMLElement, string | null>>(new WeakMap());
|
||||
|
||||
if (activeTabId) {
|
||||
mountedTabIdsRef.current.add(activeTabId);
|
||||
}
|
||||
|
||||
// 활성 탭의 scroll 이벤트를 감지하여 요소별 위치를 실시간 저장
|
||||
useEffect(() => {
|
||||
if (!activeTabId) return;
|
||||
|
||||
const container = scrollRefsMap.current.get(activeTabId);
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = (e: Event) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
let path = pathCacheRef.current.get(target);
|
||||
if (path === undefined) {
|
||||
path = getElementPath(target, container);
|
||||
pathCacheRef.current.set(target, path);
|
||||
}
|
||||
if (path === null) return;
|
||||
|
||||
let tabMap = lastScrollMapRef.current.get(activeTabId);
|
||||
if (!tabMap) {
|
||||
tabMap = new Map();
|
||||
lastScrollMapRef.current.set(activeTabId, tabMap);
|
||||
}
|
||||
|
||||
if (target.scrollTop > 0 || target.scrollLeft > 0) {
|
||||
tabMap.set(path, { top: target.scrollTop, left: target.scrollLeft });
|
||||
} else {
|
||||
tabMap.delete(path);
|
||||
}
|
||||
};
|
||||
|
||||
container.addEventListener("scroll", handleScroll, true);
|
||||
return () => container.removeEventListener("scroll", handleScroll, true);
|
||||
}, [activeTabId]);
|
||||
|
||||
// 복원 관련 cleanup ref
|
||||
const scrollRestoreCleanupRef = useRef<(() => void) | null>(null);
|
||||
const formRestoreCleanupRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// 탭 전환 시: 이전 탭 상태 캐싱, 새 탭 상태 복원
|
||||
useEffect(() => {
|
||||
// 이전 복원 작업 취소
|
||||
if (scrollRestoreCleanupRef.current) {
|
||||
scrollRestoreCleanupRef.current();
|
||||
scrollRestoreCleanupRef.current = null;
|
||||
}
|
||||
if (formRestoreCleanupRef.current) {
|
||||
formRestoreCleanupRef.current();
|
||||
formRestoreCleanupRef.current = null;
|
||||
}
|
||||
|
||||
const prevId = prevActiveTabIdRef.current;
|
||||
|
||||
// 이전 활성 탭의 스크롤 + 폼 상태 저장
|
||||
// 키를 항상 포함하여 이전 캐시의 오래된 값이 병합으로 살아남지 않도록 함
|
||||
if (prevId && prevId !== activeTabId) {
|
||||
const tabMap = lastScrollMapRef.current.get(prevId);
|
||||
const scrollPositions =
|
||||
tabMap && tabMap.size > 0
|
||||
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
|
||||
: undefined;
|
||||
const prevEl = scrollRefsMap.current.get(prevId);
|
||||
const formFields = captureFormState(prevEl ?? null);
|
||||
saveTabCacheImmediate(prevId, {
|
||||
scrollPositions,
|
||||
domFormFields: formFields ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// 새 활성 탭의 스크롤 + 폼 상태 복원
|
||||
if (activeTabId) {
|
||||
const cache = loadTabCache(activeTabId);
|
||||
if (cache) {
|
||||
const el = scrollRefsMap.current.get(activeTabId);
|
||||
if (cache.scrollPositions) {
|
||||
const cleanup = restoreAllScrollPositions(el ?? null, cache.scrollPositions);
|
||||
if (cleanup) scrollRestoreCleanupRef.current = cleanup;
|
||||
}
|
||||
if (cache.domFormFields) {
|
||||
const cleanup = restoreFormState(el ?? null, cache.domFormFields ?? null);
|
||||
if (cleanup) formRestoreCleanupRef.current = cleanup;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevActiveTabIdRef.current = activeTabId;
|
||||
}, [activeTabId]);
|
||||
|
||||
// F5 새로고침 직전에 활성 탭의 스크롤/폼 상태를 저장
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
const currentActiveId = prevActiveTabIdRef.current;
|
||||
if (!currentActiveId) return;
|
||||
|
||||
const el = scrollRefsMap.current.get(currentActiveId);
|
||||
// 활성 탭은 display:block이므로 DOM에서 직접 캡처 (가장 정확)
|
||||
const scrollPositions = captureAllScrollPositions(el ?? null);
|
||||
// DOM 캡처 실패 시 실시간 추적 데이터 fallback
|
||||
const tabMap = lastScrollMapRef.current.get(currentActiveId);
|
||||
const trackedPositions =
|
||||
!scrollPositions && tabMap && tabMap.size > 0
|
||||
? Array.from(tabMap.entries()).map(([path, pos]) => ({ path, ...pos }))
|
||||
: undefined;
|
||||
|
||||
const finalPositions = scrollPositions || trackedPositions;
|
||||
const formFields = captureFormState(el ?? null);
|
||||
saveTabCacheImmediate(currentActiveId, {
|
||||
scrollPositions: finalPositions,
|
||||
domFormFields: formFields ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
if (scrollRestoreCleanupRef.current) scrollRestoreCleanupRef.current();
|
||||
if (formRestoreCleanupRef.current) formRestoreCleanupRef.current();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 탭 닫기 시 캐시 정리 (tabs 배열 변화 감지)
|
||||
useEffect(() => {
|
||||
const currentTabIds = new Set(tabs.map((t) => t.id));
|
||||
const mountedIds = mountedTabIdsRef.current;
|
||||
|
||||
mountedIds.forEach((id) => {
|
||||
if (!currentTabIds.has(id)) {
|
||||
clearTabCache(id);
|
||||
scrollRefsMap.current.delete(id);
|
||||
mountedIds.delete(id);
|
||||
}
|
||||
});
|
||||
}, [tabs]);
|
||||
|
||||
const setScrollRef = useCallback((tabId: string, el: HTMLDivElement | null) => {
|
||||
scrollRefsMap.current.set(tabId, el);
|
||||
}, []);
|
||||
|
||||
// 포탈 컨테이너 ref callback: 전역 레퍼런스에 등록
|
||||
const portalRefCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
registerModalPortal(el);
|
||||
}, []);
|
||||
|
||||
if (tabs.length === 0) {
|
||||
return <EmptyDashboard />;
|
||||
}
|
||||
|
||||
const tabLookup = new Map(tabs.map((t) => [t.id, t]));
|
||||
const stableIds = Array.from(mountedTabIdsRef.current);
|
||||
|
||||
return (
|
||||
<div ref={portalRefCallback} className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{stableIds.map((tabId) => {
|
||||
const tab = tabLookup.get(tabId);
|
||||
if (!tab) return null;
|
||||
|
||||
const isActive = tab.id === activeTabId;
|
||||
const refreshKey = refreshKeys[tab.id] || 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
ref={(el) => setScrollRef(tab.id, el)}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{ display: isActive ? "block" : "none" }}
|
||||
>
|
||||
<TabIdProvider value={tab.id}>
|
||||
<TabPageRenderer tab={tab} refreshKey={refreshKey} />
|
||||
<ScreenModal key={`modal-${tab.id}-${refreshKey}`} />
|
||||
</TabIdProvider>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabPageRenderer({
|
||||
tab,
|
||||
refreshKey,
|
||||
}: {
|
||||
tab: { id: string; type: string; screenId?: number; menuObjid?: number; adminUrl?: string };
|
||||
refreshKey: number;
|
||||
}) {
|
||||
if (tab.type === "screen" && tab.screenId != null) {
|
||||
return (
|
||||
<ScreenViewPageWrapper
|
||||
key={`${tab.id}-${refreshKey}`}
|
||||
screenIdProp={tab.screenId}
|
||||
menuObjidProp={tab.menuObjid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (tab.type === "admin" && tab.adminUrl) {
|
||||
return (
|
||||
<div key={`${tab.id}-${refreshKey}`} className="h-full">
|
||||
<AdminPageRenderer url={tab.adminUrl} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
interface EditModalState {
|
||||
isOpen: boolean;
|
||||
screenId: number | null;
|
||||
@@ -49,16 +52,13 @@ interface EditModalProps {
|
||||
*/
|
||||
const findSaveButtonInComponents = (components: any[]): any | null => {
|
||||
if (!components || !Array.isArray(components)) return null;
|
||||
|
||||
|
||||
for (const comp of components) {
|
||||
// button-primary이고 action.type이 save인 경우
|
||||
if (
|
||||
comp.componentType === "button-primary" &&
|
||||
comp.componentConfig?.action?.type === "save"
|
||||
) {
|
||||
if (comp.componentType === "button-primary" && comp.componentConfig?.action?.type === "save") {
|
||||
return comp;
|
||||
}
|
||||
|
||||
|
||||
// conditional-container의 sections 내부 탐색
|
||||
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
|
||||
for (const section of comp.componentConfig.sections) {
|
||||
@@ -69,19 +69,22 @@ const findSaveButtonInComponents = (components: any[]): any | null => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 자식 컴포넌트가 있으면 재귀 탐색
|
||||
if (comp.children && Array.isArray(comp.children)) {
|
||||
const found = findSaveButtonInComponents(comp.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
const { user } = useAuth();
|
||||
const tabId = useTabId();
|
||||
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
|
||||
const isTabActive = !tabId || tabId === activeTabId;
|
||||
const [modalState, setModalState] = useState<EditModalState>({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
@@ -176,7 +179,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
};
|
||||
|
||||
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||
const loadSaveButtonConfig = async (targetScreenId: number): Promise<{
|
||||
const loadSaveButtonConfig = async (
|
||||
targetScreenId: number,
|
||||
): Promise<{
|
||||
enableDataflowControl?: boolean;
|
||||
dataflowConfig?: any;
|
||||
dataflowTiming?: string;
|
||||
@@ -184,15 +189,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
try {
|
||||
// 1. 대상 화면의 레이아웃 조회
|
||||
const layoutData = await screenApi.getLayout(targetScreenId);
|
||||
|
||||
|
||||
if (!layoutData?.components) {
|
||||
console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 2. 저장 버튼 찾기
|
||||
let saveButton = findSaveButtonInComponents(layoutData.components);
|
||||
|
||||
|
||||
// 3. conditional-container가 있는 경우 내부 화면도 탐색
|
||||
if (!saveButton) {
|
||||
for (const comp of layoutData.components) {
|
||||
@@ -218,12 +223,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!saveButton) {
|
||||
// console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 4. webTypeConfig에서 제어로직 설정 추출
|
||||
const webTypeConfig = saveButton.webTypeConfig;
|
||||
if (webTypeConfig?.enableDataflowControl) {
|
||||
@@ -235,7 +240,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
// console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
// console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
|
||||
return null;
|
||||
} catch (error) {
|
||||
@@ -277,7 +282,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
return {};
|
||||
}
|
||||
|
||||
console.log("[EditModal:MasterLoad] entity join:", joinConfigs.map((c) => `${c.sourceColumn} → ${c.referenceTable}`));
|
||||
console.log(
|
||||
"[EditModal:MasterLoad] entity join:",
|
||||
joinConfigs.map((c) => `${c.sourceColumn} → ${c.referenceTable}`),
|
||||
);
|
||||
|
||||
const masterDataResult: Record<string, any> = {};
|
||||
const processedTables = new Set<string>();
|
||||
@@ -291,15 +299,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
if (!fkValue) continue;
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${referenceTable}/data`,
|
||||
{
|
||||
search: { [referenceColumn || "id"]: fkValue },
|
||||
size: 1,
|
||||
page: 1,
|
||||
autoFilter: true,
|
||||
},
|
||||
);
|
||||
const response = await apiClient.post(`/table-management/tables/${referenceTable}/data`, {
|
||||
search: { [referenceColumn || "id"]: fkValue },
|
||||
size: 1,
|
||||
page: 1,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
const rows = response.data?.data?.data || response.data?.data?.rows || [];
|
||||
if (rows.length > 0) {
|
||||
@@ -330,10 +335,27 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
// 전역 모달 이벤트 리스너 (활성 탭에서만 처리)
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = async (event: CustomEvent) => {
|
||||
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext, menuObjid } = event.detail;
|
||||
const storeState = useTabStore.getState();
|
||||
const currentActiveTabId = storeState[storeState.mode].activeTabId;
|
||||
if (tabId && tabId !== currentActiveTabId) return;
|
||||
|
||||
const {
|
||||
screenId,
|
||||
title,
|
||||
description,
|
||||
modalSize,
|
||||
editData,
|
||||
onSave,
|
||||
groupByColumns,
|
||||
tableName,
|
||||
isCreateMode,
|
||||
buttonConfig,
|
||||
buttonContext,
|
||||
menuObjid,
|
||||
} = event.detail;
|
||||
|
||||
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
|
||||
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
|
||||
@@ -434,7 +456,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
|
||||
window.removeEventListener("closeEditModal", handleCloseEditModal);
|
||||
};
|
||||
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
|
||||
}, [tabId, modalState.onSave]);
|
||||
|
||||
// 화면 데이터 로딩
|
||||
useEffect(() => {
|
||||
@@ -615,9 +637,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
const layerConditionValue = conditionConfig.condition_value;
|
||||
|
||||
// 이 레이어가 속한 Zone 찾기
|
||||
const associatedZone = loadedZones.find(
|
||||
(z) => z.zone_id === layerZoneId
|
||||
);
|
||||
const associatedZone = loadedZones.find((z) => z.zone_id === layerZoneId);
|
||||
|
||||
layerDefinitions.push({
|
||||
id: `layer-${layer.layer_id}`,
|
||||
@@ -642,13 +662,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[EditModal] 조건부 레이어 로드 완료:", layerDefinitions.length, "개",
|
||||
console.log(
|
||||
"[EditModal] 조건부 레이어 로드 완료:",
|
||||
layerDefinitions.length,
|
||||
"개",
|
||||
layerDefinitions.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
conditionValue: l.conditionValue,
|
||||
condition: l.condition,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
setConditionalLayers(layerDefinitions);
|
||||
@@ -694,7 +717,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
if (Array.isArray(value)) {
|
||||
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
|
||||
} else if (typeof value === "string" && value.includes(",")) {
|
||||
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
|
||||
isMatch = value
|
||||
.split(",")
|
||||
.map((v) => v.trim())
|
||||
.includes(String(targetValue ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -972,14 +998,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`);
|
||||
// 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
|
||||
let finalValue = dateFields.includes(key) ? currentValue : currentData[key];
|
||||
|
||||
|
||||
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
|
||||
if (Array.isArray(finalValue)) {
|
||||
const isRepeaterData = finalValue.length > 0 &&
|
||||
typeof finalValue[0] === "object" &&
|
||||
const isRepeaterData =
|
||||
finalValue.length > 0 &&
|
||||
typeof finalValue[0] === "object" &&
|
||||
finalValue[0] !== null &&
|
||||
("_targetTable" in finalValue[0] || "_isNewItem" in finalValue[0] || "_existingRecord" in finalValue[0]);
|
||||
|
||||
("_targetTable" in finalValue[0] ||
|
||||
"_isNewItem" in finalValue[0] ||
|
||||
"_existingRecord" in finalValue[0]);
|
||||
|
||||
if (!isRepeaterData) {
|
||||
// 🔧 손상된 값 필터링 헬퍼
|
||||
const isValidValue = (v: any): boolean => {
|
||||
@@ -989,17 +1018,21 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
const validValues = finalValue
|
||||
.map((v: any) => typeof v === "number" ? String(v) : v)
|
||||
.map((v: any) => (typeof v === "number" ? String(v) : v))
|
||||
.filter(isValidValue);
|
||||
|
||||
|
||||
const stringValue = validValues.join(",");
|
||||
console.log(`🔧 [EditModal 그룹UPDATE] 배열→문자열 변환: ${key}`, { original: finalValue.length, valid: validValues.length, converted: stringValue });
|
||||
console.log(`🔧 [EditModal 그룹UPDATE] 배열→문자열 변환: ${key}`, {
|
||||
original: finalValue.length,
|
||||
valid: validValues.length,
|
||||
converted: stringValue,
|
||||
});
|
||||
finalValue = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
changedData[key] = finalValue;
|
||||
}
|
||||
});
|
||||
@@ -1096,37 +1129,35 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
|
||||
console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", {
|
||||
hasSaveButtonConfig: !!modalState.saveButtonConfig,
|
||||
hasButtonConfig: !!modalState.buttonConfig,
|
||||
controlConfig,
|
||||
});
|
||||
|
||||
|
||||
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||
const flowTiming = controlConfig?.dataflowTiming
|
||||
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
const flowTiming =
|
||||
controlConfig?.dataflowTiming ||
|
||||
controlConfig?.dataflowConfig?.flowConfig?.executionTiming ||
|
||||
(controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
if (controlConfig?.enableDataflowControl && flowTiming === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
|
||||
// buttonActions의 executeAfterSaveControl 동적 import
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
|
||||
// 제어로직 실행
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData: modalState.editData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(controlConfig, {
|
||||
formData: modalState.editData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
});
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
} else {
|
||||
console.log("ℹ️ [EditModal] 저장 후 실행할 제어로직 없음");
|
||||
@@ -1198,29 +1229,27 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
|
||||
// 🚀 Promise.all로 병렬 처리 (여러 채번 필드가 있을 경우 성능 향상)
|
||||
const allocationPromises = Object.entries(fieldsWithNumbering).map(
|
||||
async ([fieldName, ruleId]) => {
|
||||
const userInputCode = dataToSave[fieldName] as string;
|
||||
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||||
|
||||
try {
|
||||
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
return { fieldName, success: true, code: allocateResult.data.generatedCode };
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
|
||||
}
|
||||
} catch (allocateError) {
|
||||
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
|
||||
const allocationPromises = Object.entries(fieldsWithNumbering).map(async ([fieldName, ruleId]) => {
|
||||
const userInputCode = dataToSave[fieldName] as string;
|
||||
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||||
|
||||
try {
|
||||
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
|
||||
|
||||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||||
return { fieldName, success: true, code: allocateResult.data.generatedCode };
|
||||
} else {
|
||||
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||||
return { fieldName, success: false, hasExistingValue: !!dataToSave[fieldName] };
|
||||
}
|
||||
} catch (allocateError) {
|
||||
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
|
||||
return { fieldName, success: false, hasExistingValue: !!dataToSave[fieldName] };
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const allocationResults = await Promise.all(allocationPromises);
|
||||
|
||||
|
||||
// 결과 처리
|
||||
const failedFields: string[] = [];
|
||||
for (const result of allocationResults) {
|
||||
@@ -1256,11 +1285,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
masterDataToSave[key] = value;
|
||||
} else {
|
||||
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
|
||||
const isRepeaterData = value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
const isRepeaterData =
|
||||
value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
value[0] !== null &&
|
||||
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
|
||||
|
||||
|
||||
if (isRepeaterData) {
|
||||
console.log(`🔄 [EditModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
||||
} else {
|
||||
@@ -1272,14 +1302,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
|
||||
const validValues = value
|
||||
.map((v: any) => typeof v === "number" ? String(v) : v)
|
||||
.filter(isValidValue);
|
||||
|
||||
const validValues = value.map((v: any) => (typeof v === "number" ? String(v) : v)).filter(isValidValue);
|
||||
|
||||
const stringValue = validValues.join(",");
|
||||
console.log(`🔧 [EditModal CREATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
|
||||
console.log(`🔧 [EditModal CREATE] 배열→문자열 변환: ${key}`, {
|
||||
original: value.length,
|
||||
valid: validValues.length,
|
||||
converted: stringValue,
|
||||
});
|
||||
masterDataToSave[key] = stringValue;
|
||||
}
|
||||
}
|
||||
@@ -1295,7 +1327,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
|
||||
if (response.success) {
|
||||
const masterRecordId = response.data?.id || formData.id;
|
||||
|
||||
|
||||
toast.success("데이터가 생성되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
@@ -1311,31 +1343,29 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
|
||||
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
|
||||
|
||||
|
||||
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||
const flowTimingInsert = controlConfig?.dataflowTiming
|
||||
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
const flowTimingInsert =
|
||||
controlConfig?.dataflowTiming ||
|
||||
controlConfig?.dataflowConfig?.flowConfig?.executionTiming ||
|
||||
(controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
if (controlConfig?.enableDataflowControl && flowTimingInsert === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(controlConfig, {
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
});
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
}
|
||||
} catch (controlError) {
|
||||
@@ -1395,18 +1425,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
const dataToSave: Record<string, any> = {};
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const isRepeaterData = value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
const isRepeaterData =
|
||||
value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
value[0] !== null &&
|
||||
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
|
||||
|
||||
|
||||
if (isRepeaterData) {
|
||||
// 리피터 데이터는 제외 (별도 저장)
|
||||
return;
|
||||
}
|
||||
// 다중 선택 배열 → 쉼표 구분 문자열
|
||||
const validValues = value
|
||||
.map((v: any) => typeof v === "number" ? String(v) : v)
|
||||
.map((v: any) => (typeof v === "number" ? String(v) : v))
|
||||
.filter((v: any) => {
|
||||
if (typeof v === "number") return true;
|
||||
if (typeof v !== "string") return false;
|
||||
@@ -1438,31 +1469,29 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
|
||||
|
||||
|
||||
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
|
||||
|
||||
|
||||
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
|
||||
const flowTimingUpdate = controlConfig?.dataflowTiming
|
||||
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|
||||
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
const flowTimingUpdate =
|
||||
controlConfig?.dataflowTiming ||
|
||||
controlConfig?.dataflowConfig?.flowConfig?.executionTiming ||
|
||||
(controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
|
||||
|
||||
if (controlConfig?.enableDataflowControl && flowTimingUpdate === "after") {
|
||||
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
|
||||
|
||||
|
||||
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(
|
||||
controlConfig,
|
||||
{
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
await ButtonActionExecutor.executeAfterSaveControl(controlConfig, {
|
||||
formData,
|
||||
screenId: modalState.buttonContext?.screenId || modalState.screenId,
|
||||
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
|
||||
userId: user?.userId,
|
||||
companyCode: user?.companyCode,
|
||||
onRefresh: modalState.onSave,
|
||||
});
|
||||
|
||||
console.log("✅ [EditModal] 제어로직 실행 완료");
|
||||
}
|
||||
} catch (controlError) {
|
||||
@@ -1503,7 +1532,9 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
|
||||
// 리피터 저장 완료 후 메인 테이블 새로고침
|
||||
if (modalState.onSave) {
|
||||
try { modalState.onSave(); } catch {}
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch {}
|
||||
}
|
||||
handleClose();
|
||||
} else {
|
||||
@@ -1575,171 +1606,170 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
||||
screenId={modalState.screenId || undefined}
|
||||
tableName={screenData.screenInfo?.tableName}
|
||||
>
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="relative m-auto bg-white"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||
height: (() => {
|
||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||
if (activeConditionalComponents.length > 0) {
|
||||
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
let maxBottom = 0;
|
||||
activeConditionalComponents.forEach((comp) => {
|
||||
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
|
||||
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||
maxBottom = Math.max(maxBottom, y + h);
|
||||
});
|
||||
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
|
||||
}
|
||||
return baseHeight;
|
||||
})(),
|
||||
transformOrigin: "center center",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{/* 기본 레이어 컴포넌트 렌더링 */}
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="relative m-auto bg-white"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
// 조건부 레이어가 활성화되면 높이 자동 확장
|
||||
height: (() => {
|
||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||
if (activeConditionalComponents.length > 0) {
|
||||
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
let maxBottom = 0;
|
||||
activeConditionalComponents.forEach((comp) => {
|
||||
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
|
||||
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||
maxBottom = Math.max(maxBottom, y + h);
|
||||
});
|
||||
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
|
||||
}
|
||||
return baseHeight;
|
||||
})(),
|
||||
transformOrigin: "center center",
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
{/* 기본 레이어 컴포넌트 렌더링 */}
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
|
||||
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
|
||||
},
|
||||
};
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
|
||||
},
|
||||
};
|
||||
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
const hasUniversalFormModal = screenData.components.some(
|
||||
(c) => {
|
||||
const hasUniversalFormModal = screenData.components.some((c) => {
|
||||
if (c.componentType === "universal-form-modal") return true;
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
const hasTableSectionData = Object.keys(formData).some(k =>
|
||||
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
|
||||
);
|
||||
|
||||
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
||||
});
|
||||
|
||||
const enrichedFormData = {
|
||||
// 마스터 데이터(formData)를 기본으로 깔고, groupData[0]으로 덮어쓰기
|
||||
// → 디테일 행 수정 시에도 마스터 폼 필드가 표시됨
|
||||
...formData,
|
||||
...(groupData.length > 0 ? groupData[0] : {}),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
const hasTableSectionData = Object.keys(formData).some(
|
||||
(k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"),
|
||||
);
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
const shouldUseEditModalSave =
|
||||
!hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
||||
|
||||
const enrichedFormData = {
|
||||
// 마스터 데이터(formData)를 기본으로 깔고, groupData[0]으로 덮어쓰기
|
||||
// → 디테일 행 수정 시에도 마스터 폼 필드가 표시됨
|
||||
...formData,
|
||||
...(groupData.length > 0 ? groupData[0] : {}),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||
{activeConditionalComponents.map((component) => {
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30;
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||
{activeConditionalComponents.map((component) => {
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30;
|
||||
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
|
||||
},
|
||||
};
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
|
||||
},
|
||||
};
|
||||
|
||||
const enrichedFormData = {
|
||||
...formData,
|
||||
...(groupData.length > 0 ? groupData[0] : {}),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
const enrichedFormData = {
|
||||
...formData,
|
||||
...(groupData.length > 0 ? groupData[0] : {}),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`conditional-${component.id}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`conditional-${component.id}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScreenContextProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
||||
@@ -47,6 +47,7 @@ import { useFormValidation } from "@/hooks/useFormValidation";
|
||||
import { V2ColumnInfo as ColumnInfo } from "@/types";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { buildGridClasses } from "@/lib/constants/columnSpans";
|
||||
import { ButtonIconRenderer } from "@/lib/button-icon-map";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||
@@ -2068,7 +2069,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||
borderColor: config?.borderColor,
|
||||
}}
|
||||
>
|
||||
{label || "버튼"}
|
||||
<ButtonIconRenderer componentConfig={(component as any).componentConfig} fallbackLabel={label || "버튼"} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
getServerSnapshot as canvasSplitGetServerSnapshot,
|
||||
subscribeDom as canvasSplitSubscribeDom,
|
||||
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
||||
import { ButtonIconRenderer } from "@/lib/button-icon-map";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
@@ -447,6 +448,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||
onClose={() => {
|
||||
// buttonActions.ts가 이미 처리함
|
||||
}}
|
||||
isInModal={isInModal}
|
||||
// 탭 관련 정보 전달
|
||||
parentTabId={parentTabId}
|
||||
parentTabsComponentId={parentTabsComponentId}
|
||||
@@ -968,7 +970,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||
|
||||
// 커스텀 색상이 있으면 Tailwind 클래스 대신 직접 스타일 적용
|
||||
const hasCustomColors = config?.backgroundColor || config?.textColor || comp.style?.backgroundColor || comp.style?.color;
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
@@ -989,7 +991,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{label || "버튼"}
|
||||
<ButtonIconRenderer componentConfig={(comp as any).componentConfig} fallbackLabel={label || "버튼"} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1297,6 +1299,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||
|
||||
const externalLabelComponent = needsExternalLabel ? (
|
||||
<label
|
||||
htmlFor={component.id}
|
||||
className="text-sm font-medium leading-none"
|
||||
style={{
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
@@ -1346,6 +1349,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||
isHorizLabel ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<label
|
||||
htmlFor={component.id}
|
||||
className="text-sm font-medium leading-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { executeButtonWithFlow, handleFlowExecutionResult } from "@/lib/utils/nodeFlowButtonExecutor";
|
||||
import { useCurrentFlowStep } from "@/stores/flowStepStore";
|
||||
import { ButtonIconRenderer, getButtonDisplayContent } from "@/lib/button-icon-map";
|
||||
|
||||
interface OptimizedButtonProps {
|
||||
component: ComponentData;
|
||||
@@ -650,17 +651,14 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={isExecuting || disabled}
|
||||
// 색상이 설정되어 있으면 variant를 적용하지 않아서 Tailwind 색상 클래스가 덮어씌우지 않도록 함
|
||||
variant={hasCustomColors ? undefined : (config?.variant || "default")}
|
||||
className={cn(
|
||||
"transition-all duration-200",
|
||||
isExecuting && "cursor-wait opacity-75",
|
||||
backgroundJobs.size > 0 && "border-primary/20 bg-accent",
|
||||
// 커스텀 색상이 없을 때만 기본 스타일 적용
|
||||
!hasCustomColors && "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
)}
|
||||
style={{
|
||||
// 커스텀 색상이 있을 때만 인라인 스타일 적용
|
||||
...(config?.backgroundColor && { backgroundColor: config.backgroundColor }),
|
||||
...(config?.textColor && { color: config.textColor }),
|
||||
...(config?.borderColor && { borderColor: config.borderColor }),
|
||||
@@ -669,7 +667,14 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||
{/* 메인 버튼 내용 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon()}
|
||||
<span>{isExecuting ? "처리 중..." : buttonLabel}</span>
|
||||
<span>
|
||||
{isExecuting ? "처리 중..." : (
|
||||
<ButtonIconRenderer
|
||||
componentConfig={component.componentConfig}
|
||||
fallbackLabel={buttonLabel}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 개발 모드에서 성능 정보 표시 */}
|
||||
|
||||
@@ -407,8 +407,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
const isButtonComponent =
|
||||
(type === "widget" && widgetType === "button") ||
|
||||
(type === "component" &&
|
||||
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||
["button-primary", "button-secondary"].includes(componentId)));
|
||||
(["button-primary", "button-secondary", "v2-button-primary"].includes(componentType) ||
|
||||
["button-primary", "button-secondary", "v2-button-primary"].includes(componentId)));
|
||||
|
||||
// 레거시 분할 패널용 refs
|
||||
const initialPanelRatioRef = React.useRef<number | null>(null);
|
||||
@@ -548,13 +548,16 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
const origWidth = size?.width || 100;
|
||||
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
|
||||
|
||||
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리)
|
||||
// position wrapper에서 border 제거 (내부 컴포넌트가 자체적으로 border를 렌더링하는 경우)
|
||||
// - v2 수평 라벨 컴포넌트: DynamicComponentRenderer가 내부에서 처리
|
||||
// - 버튼 컴포넌트: buttonElementStyle에서 자체 border 적용
|
||||
const isV2HorizLabel = !!(
|
||||
componentStyle &&
|
||||
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
|
||||
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
|
||||
);
|
||||
const safeComponentStyle = isV2HorizLabel
|
||||
const needsStripBorder = isV2HorizLabel || isButtonComponent;
|
||||
const safeComponentStyle = needsStripBorder
|
||||
? (() => {
|
||||
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
|
||||
return rest;
|
||||
|
||||
@@ -13,7 +13,6 @@ import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||
import { ComponentData } from "@/lib/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface SaveModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -124,9 +123,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
// 테이블 타입관리의 NOT NULL 설정 기반으로 필수 여부 판단
|
||||
const isColumnRequired = (columnName: string): boolean => {
|
||||
if (!columnName || tableColumnsInfo.length === 0) return false;
|
||||
const colInfo = tableColumnsInfo.find(
|
||||
(c) => c.columnName.toLowerCase() === columnName.toLowerCase()
|
||||
);
|
||||
const colInfo = tableColumnsInfo.find((c) => c.columnName.toLowerCase() === columnName.toLowerCase());
|
||||
if (!colInfo) return false;
|
||||
// is_nullable가 "NO"이면 필수
|
||||
return colInfo.isNullable === "NO" || colInfo.isNullable === "N";
|
||||
@@ -141,8 +138,8 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
const label = component.label || component.style?.label || columnName;
|
||||
|
||||
// 기존 required 속성 (화면 디자이너에서 수동 설정한 것)
|
||||
const manualRequired =
|
||||
component.required === true ||
|
||||
const manualRequired =
|
||||
component.required === true ||
|
||||
component.style?.required === true ||
|
||||
component.componentConfig?.required === true;
|
||||
|
||||
@@ -210,7 +207,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
|
||||
const writerValue = user.userId;
|
||||
const companyCodeValue = user.companyCode || "";
|
||||
|
||||
|
||||
console.log("👤 현재 사용자 정보:", {
|
||||
userId: user.userId,
|
||||
userName: userName,
|
||||
@@ -255,11 +252,11 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
|
||||
if (result.success) {
|
||||
const masterRecordId = result.data?.id || dataToSave.id;
|
||||
|
||||
|
||||
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId,
|
||||
mainFormData: dataToSave,
|
||||
@@ -268,7 +265,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
}),
|
||||
);
|
||||
console.log("📋 [SaveModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName });
|
||||
|
||||
|
||||
// ✅ 저장 성공
|
||||
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
|
||||
|
||||
@@ -300,29 +297,29 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
const calculateDynamicSize = () => {
|
||||
if (!components.length) return { width: 800, height: 600 };
|
||||
|
||||
const maxX = Math.max(...components.map((c) => {
|
||||
const x = c.position?.x || 0;
|
||||
const width = typeof c.size?.width === 'number'
|
||||
? c.size.width
|
||||
: parseInt(String(c.size?.width || 200), 10);
|
||||
return x + width;
|
||||
}));
|
||||
|
||||
const maxY = Math.max(...components.map((c) => {
|
||||
const y = c.position?.y || 0;
|
||||
const height = typeof c.size?.height === 'number'
|
||||
? c.size.height
|
||||
: parseInt(String(c.size?.height || 40), 10);
|
||||
return y + height;
|
||||
}));
|
||||
const maxX = Math.max(
|
||||
...components.map((c) => {
|
||||
const x = c.position?.x || 0;
|
||||
const width = typeof c.size?.width === "number" ? c.size.width : parseInt(String(c.size?.width || 200), 10);
|
||||
return x + width;
|
||||
}),
|
||||
);
|
||||
|
||||
const maxY = Math.max(
|
||||
...components.map((c) => {
|
||||
const y = c.position?.y || 0;
|
||||
const height = typeof c.size?.height === "number" ? c.size.height : parseInt(String(c.size?.height || 40), 10);
|
||||
return y + height;
|
||||
}),
|
||||
);
|
||||
|
||||
// 컨텐츠 영역 크기 (화면관리 설정 크기)
|
||||
const contentWidth = Math.max(maxX, 400);
|
||||
const contentHeight = Math.max(maxY, 300);
|
||||
|
||||
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더
|
||||
const headerHeight = 60; // DialogHeader
|
||||
|
||||
|
||||
return {
|
||||
width: contentWidth,
|
||||
height: contentHeight + headerHeight, // 헤더 높이 포함
|
||||
@@ -340,9 +337,9 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
minWidth: "400px",
|
||||
minHeight: "300px",
|
||||
}}
|
||||
className="gap-0 p-0 max-w-none"
|
||||
className="max-w-none gap-0 p-0"
|
||||
>
|
||||
<DialogHeader className="border-b px-6 py-4 flex-shrink-0">
|
||||
<DialogHeader className="flex-shrink-0 border-b px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -366,7 +363,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="overflow-auto p-6 flex-1">
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
@@ -384,90 +381,92 @@ export const SaveModal: React.FC<SaveModalProps> = ({
|
||||
<div className="relative" style={{ width: `${dynamicSize.width}px`, height: `${dynamicSize.height}px` }}>
|
||||
{components.map((component, index) => {
|
||||
// ✅ 격자 시스템 잔재 제거: size의 픽셀 값만 사용
|
||||
const widthPx = typeof component.size?.width === 'number'
|
||||
? component.size.width
|
||||
: parseInt(String(component.size?.width || 200), 10);
|
||||
const heightPx = typeof component.size?.height === 'number'
|
||||
? component.size.height
|
||||
: parseInt(String(component.size?.height || 40), 10);
|
||||
const widthPx =
|
||||
typeof component.size?.width === "number"
|
||||
? component.size.width
|
||||
: parseInt(String(component.size?.width || 200), 10);
|
||||
const heightPx =
|
||||
typeof component.size?.height === "number"
|
||||
? component.size.height
|
||||
: parseInt(String(component.size?.height || 40), 10);
|
||||
|
||||
// 디버깅: 실제 크기 확인
|
||||
if (index === 0) {
|
||||
console.log('🔍 SaveModal 컴포넌트 크기:', {
|
||||
console.log("🔍 SaveModal 컴포넌트 크기:", {
|
||||
componentId: component.id,
|
||||
'size.width (원본)': component.size?.width,
|
||||
'size.width 타입': typeof component.size?.width,
|
||||
'widthPx (계산)': widthPx,
|
||||
'style.width': component.style?.width,
|
||||
"size.width (원본)": component.size?.width,
|
||||
"size.width 타입": typeof component.size?.width,
|
||||
"widthPx (계산)": widthPx,
|
||||
"style.width": component.style?.width,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: component.position?.y || 0,
|
||||
left: component.position?.x || 0,
|
||||
width: `${widthPx}px`, // ✅ 픽셀 단위 강제
|
||||
height: `${heightPx}px`, // ✅ 픽셀 단위 강제
|
||||
zIndex: component.position?.z || 1000 + index,
|
||||
}}
|
||||
>
|
||||
{component.type === "widget" ? (
|
||||
<InteractiveScreenViewer
|
||||
component={component}
|
||||
allComponents={components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
hideLabel={false}
|
||||
menuObjid={menuObjid}
|
||||
tableColumns={tableColumnsInfo as any}
|
||||
/>
|
||||
) : (
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: true,
|
||||
},
|
||||
}}
|
||||
screenId={screenId}
|
||||
tableName={screenData.tableName}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 SaveModal - formData 변경:", {
|
||||
fieldName,
|
||||
value,
|
||||
componentType: component.type,
|
||||
componentId: component.id,
|
||||
});
|
||||
setFormData((prev) => {
|
||||
const newData = {
|
||||
<div
|
||||
key={component.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: component.position?.y || 0,
|
||||
left: component.position?.x || 0,
|
||||
width: `${widthPx}px`, // ✅ 픽셀 단위 강제
|
||||
height: `${heightPx}px`, // ✅ 픽셀 단위 강제
|
||||
zIndex: component.position?.z || 1000 + index,
|
||||
}}
|
||||
>
|
||||
{component.type === "widget" ? (
|
||||
<InteractiveScreenViewer
|
||||
component={component}
|
||||
allComponents={components}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📦 새 formData:", newData);
|
||||
return newData;
|
||||
});
|
||||
}}
|
||||
mode="edit"
|
||||
isInModal={true}
|
||||
isInteractive={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}));
|
||||
}}
|
||||
hideLabel={false}
|
||||
menuObjid={menuObjid}
|
||||
tableColumns={tableColumnsInfo as any}
|
||||
/>
|
||||
) : (
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: true,
|
||||
},
|
||||
}}
|
||||
screenId={screenId}
|
||||
tableName={screenData.tableName}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
console.log("📝 SaveModal - formData 변경:", {
|
||||
fieldName,
|
||||
value,
|
||||
componentType: component.type,
|
||||
componentId: component.id,
|
||||
});
|
||||
setFormData((prev) => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("📦 새 formData:", newData);
|
||||
return newData;
|
||||
});
|
||||
}}
|
||||
mode="edit"
|
||||
isInModal={true}
|
||||
isInteractive={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -5475,7 +5475,6 @@ export default function ScreenDesigner({
|
||||
{ ctrl: true, key: "s" }, // 저장 (필요시 차단 해제)
|
||||
{ ctrl: true, key: "p" }, // 인쇄
|
||||
{ ctrl: true, key: "o" }, // 파일 열기
|
||||
{ ctrl: true, key: "v" }, // 붙여넣기 (브라우저 기본 동작 차단)
|
||||
|
||||
// 개발자 도구
|
||||
{ key: "F12" }, // 개발자 도구
|
||||
@@ -5500,7 +5499,20 @@ export default function ScreenDesigner({
|
||||
return ctrlMatch && shiftMatch && keyMatch;
|
||||
});
|
||||
|
||||
// 입력 필드(input, textarea 등)에 포커스 시 편집 단축키는 기본 동작 허용
|
||||
const _target = e.target as HTMLElement;
|
||||
const _activeEl = document.activeElement as HTMLElement;
|
||||
const _isEditable = (el: HTMLElement | null) =>
|
||||
el instanceof HTMLInputElement ||
|
||||
el instanceof HTMLTextAreaElement ||
|
||||
el instanceof HTMLSelectElement ||
|
||||
el?.isContentEditable;
|
||||
const isEditableFieldFocused = _isEditable(_target) || _isEditable(_activeEl);
|
||||
|
||||
if (isBrowserShortcut) {
|
||||
if (isEditableFieldFocused) {
|
||||
return;
|
||||
}
|
||||
// console.log("🚫 브라우저 기본 단축키 차단:", e.key);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -5509,6 +5521,11 @@ export default function ScreenDesigner({
|
||||
|
||||
// ✅ 애플리케이션 전용 단축키 처리
|
||||
|
||||
// 입력 필드 포커스 시 앱 단축키 무시 (텍스트 편집 우선)
|
||||
if (isEditableFieldFocused && (e.ctrlKey || e.metaKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 그룹 관련 단축키
|
||||
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "g" && !e.shiftKey) {
|
||||
// console.log("🔄 그룹 생성 단축키");
|
||||
@@ -5585,7 +5602,6 @@ export default function ScreenDesigner({
|
||||
|
||||
// 5. 붙여넣기 (컴포넌트 붙여넣기)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key?.toLowerCase() === "v") {
|
||||
// console.log("🔄 컴포넌트 붙여넣기");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { ButtonIconRenderer } from "@/lib/button-icon-map";
|
||||
|
||||
export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||
config,
|
||||
@@ -14,38 +15,34 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||
required,
|
||||
className,
|
||||
style,
|
||||
isDesignMode = false, // 디자인 모드 플래그
|
||||
isDesignMode = false,
|
||||
...restProps
|
||||
}) => {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// 디자인 모드에서는 아무것도 하지 않고 그냥 이벤트 전파
|
||||
if (isDesignMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 버튼 클릭 시 동작 (추후 버튼 액션 시스템과 연동)
|
||||
console.log("Button clicked:", config);
|
||||
|
||||
// onChange를 통해 클릭 이벤트 전달
|
||||
if (onChange) {
|
||||
onChange("clicked");
|
||||
}
|
||||
};
|
||||
|
||||
// 커스텀 색상 확인 (config 또는 style에서)
|
||||
const hasCustomBg = config?.backgroundColor || style?.backgroundColor;
|
||||
const hasCustomColor = config?.textColor || style?.color;
|
||||
const hasCustomColors = hasCustomBg || hasCustomColor;
|
||||
|
||||
// 실제 적용할 배경색과 글자색
|
||||
const bgColor = config?.backgroundColor || style?.backgroundColor;
|
||||
const textColor = config?.textColor || style?.color;
|
||||
|
||||
// 디자인 모드에서는 div로 렌더링하여 버튼 동작 완전 차단
|
||||
|
||||
const fallbackLabel = config?.label || config?.text || (value as string) || placeholder || "버튼";
|
||||
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick} // 클릭 핸들러 추가하여 이벤트 전파
|
||||
onClick={handleClick}
|
||||
className={`flex items-center justify-center rounded-md px-4 text-sm font-medium ${
|
||||
hasCustomColors ? '' : 'bg-blue-600 text-white'
|
||||
} ${className || ""}`}
|
||||
@@ -55,11 +52,10 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||
color: textColor,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
cursor: "pointer", // 선택 가능하도록 포인터 표시
|
||||
cursor: "pointer",
|
||||
}}
|
||||
title={config?.tooltip || placeholder}
|
||||
>
|
||||
{config?.label || config?.text || value || placeholder || "버튼"}
|
||||
<ButtonIconRenderer componentConfig={config} fallbackLabel={fallbackLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,9 +75,8 @@ export const ButtonWidget: React.FC<WebTypeComponentProps> = ({
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
title={config?.tooltip || placeholder}
|
||||
>
|
||||
{config?.label || config?.text || value || placeholder || "버튼"}
|
||||
<ButtonIconRenderer componentConfig={config} fallbackLabel={fallbackLabel} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, NumberTypeConfig } from "@/types/screen";
|
||||
import { formatNumber as formatNum, formatCurrency } from "@/lib/formatting";
|
||||
|
||||
export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value, onChange, readonly = false }) => {
|
||||
const widget = component as WidgetComponent;
|
||||
@@ -21,10 +22,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
|
||||
if (isNaN(numValue)) return "";
|
||||
|
||||
if (config?.format === "currency") {
|
||||
return new Intl.NumberFormat("ko-KR", {
|
||||
style: "currency",
|
||||
currency: "KRW",
|
||||
}).format(numValue);
|
||||
return formatCurrency(numValue);
|
||||
}
|
||||
|
||||
if (config?.format === "percentage") {
|
||||
@@ -32,7 +30,7 @@ export const NumberWidget: React.FC<WebTypeComponentProps> = ({ component, value
|
||||
}
|
||||
|
||||
if (config?.thousandSeparator) {
|
||||
return new Intl.NumberFormat("ko-KR").format(numValue);
|
||||
return formatNum(numValue);
|
||||
}
|
||||
|
||||
return numValue.toString();
|
||||
|
||||
@@ -2,11 +2,64 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { useModalPortal } from "@/lib/modalPortalRef";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
/**
|
||||
* 탭 시스템 스코프 여부를 하위 컴포넌트에 전달하는 내부 Context.
|
||||
* scoped=true 이면 AlertDialogPrimitive 대신 DialogPrimitive 사용.
|
||||
*/
|
||||
const ScopedAlertCtx = React.createContext(false);
|
||||
|
||||
const AlertDialog: React.FC<React.ComponentProps<typeof AlertDialogPrimitive.Root>> = ({
|
||||
open,
|
||||
children,
|
||||
onOpenChange,
|
||||
...props
|
||||
}) => {
|
||||
const autoContainer = useModalPortal();
|
||||
const scoped = !!autoContainer;
|
||||
const tabId = useTabId();
|
||||
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
|
||||
const isTabActive = !tabId || tabId === activeTabId;
|
||||
|
||||
const isTabActiveRef = React.useRef(isTabActive);
|
||||
isTabActiveRef.current = isTabActive;
|
||||
|
||||
const effectiveOpen = open != null ? open && isTabActive : undefined;
|
||||
|
||||
const guardedOnOpenChange = React.useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (scoped && !newOpen && !isTabActiveRef.current) return;
|
||||
onOpenChange?.(newOpen);
|
||||
},
|
||||
[scoped, onOpenChange],
|
||||
);
|
||||
|
||||
if (scoped) {
|
||||
return (
|
||||
<ScopedAlertCtx.Provider value={true}>
|
||||
<DialogPrimitive.Root open={effectiveOpen} onOpenChange={guardedOnOpenChange} modal={false}>
|
||||
{children}
|
||||
</DialogPrimitive.Root>
|
||||
</ScopedAlertCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScopedAlertCtx.Provider value={false}>
|
||||
<AlertDialogPrimitive.Root {...props} open={effectiveOpen} onOpenChange={guardedOnOpenChange}>
|
||||
{children}
|
||||
</AlertDialogPrimitive.Root>
|
||||
</ScopedAlertCtx.Provider>
|
||||
);
|
||||
};
|
||||
AlertDialog.displayName = "AlertDialog";
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
@@ -18,7 +71,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-[1050] bg-black/80",
|
||||
"fixed inset-0 z-1050 bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,22 +80,82 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface ScopedAlertDialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content> {
|
||||
container?: HTMLElement | null;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
ScopedAlertDialogContentProps
|
||||
>(({ className, container: explicitContainer, hidden: hiddenProp, style, ...props }, ref) => {
|
||||
const autoContainer = useModalPortal();
|
||||
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
|
||||
const scoped = React.useContext(ScopedAlertCtx);
|
||||
|
||||
const adjustedStyle = scoped && style
|
||||
? { ...style, maxHeight: undefined, maxWidth: undefined }
|
||||
: style;
|
||||
|
||||
const handleInteractOutside = React.useCallback(
|
||||
(e: any) => {
|
||||
if (scoped && container) {
|
||||
const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null;
|
||||
if (target && !container.contains(target)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
e.preventDefault();
|
||||
},
|
||||
[scoped, container],
|
||||
);
|
||||
|
||||
if (scoped) {
|
||||
return (
|
||||
<DialogPrimitive.Portal container={container ?? undefined}>
|
||||
<div
|
||||
className="absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4"
|
||||
style={hiddenProp ? { display: "none" } : undefined}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/80" />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
onInteractOutside={handleInteractOutside}
|
||||
onFocusOutside={(e: any) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"bg-background relative z-1 grid w-full max-w-lg max-h-full gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
style={adjustedStyle}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</DialogPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialogPortal container={container ?? undefined}>
|
||||
<div
|
||||
style={hiddenProp ? { display: "none" } : undefined}
|
||||
>
|
||||
<AlertDialogPrimitive.Overlay className="fixed inset-0 z-1050 bg-black/80" />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-1100 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
style={adjustedStyle}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</AlertDialogPortal>
|
||||
);
|
||||
});
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
@@ -58,37 +171,47 @@ AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
|
||||
));
|
||||
>(({ className, ...props }, ref) => {
|
||||
const scoped = React.useContext(ScopedAlertCtx);
|
||||
const Comp = scoped ? DialogPrimitive.Title : AlertDialogPrimitive.Title;
|
||||
return <Comp ref={ref} className={cn("text-lg font-semibold", className)} {...props} />;
|
||||
});
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
));
|
||||
>(({ className, ...props }, ref) => {
|
||||
const scoped = React.useContext(ScopedAlertCtx);
|
||||
const Comp = scoped ? DialogPrimitive.Description : AlertDialogPrimitive.Description;
|
||||
return <Comp ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />;
|
||||
});
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||
));
|
||||
>(({ className, ...props }, ref) => {
|
||||
const scoped = React.useContext(ScopedAlertCtx);
|
||||
const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Action;
|
||||
return <Comp ref={ref} className={cn(buttonVariants(), className)} {...props} />;
|
||||
});
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
>(({ className, ...props }, ref) => {
|
||||
const scoped = React.useContext(ScopedAlertCtx);
|
||||
const Comp = scoped ? DialogPrimitive.Close : AlertDialogPrimitive.Cancel;
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
|
||||
@@ -44,7 +44,14 @@ function Button({
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant || "default"}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -5,8 +5,46 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useModalPortal } from "@/lib/modalPortalRef";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { useDialogAutoValidation } from "@/lib/hooks/useDialogAutoValidation";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
// Dialog: 탭 시스템 내에서 자동으로 modal={false} + 비활성 탭이면 open={false} 처리
|
||||
const Dialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
|
||||
modal,
|
||||
open,
|
||||
onOpenChange,
|
||||
...props
|
||||
}) => {
|
||||
const autoContainer = useModalPortal();
|
||||
const scoped = !!autoContainer;
|
||||
const tabId = useTabId();
|
||||
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
|
||||
const isTabActive = !tabId || tabId === activeTabId;
|
||||
|
||||
// ref로 최신 isTabActive를 동기적으로 추적 (useEffect보다 빠르게 업데이트)
|
||||
const isTabActiveRef = React.useRef(isTabActive);
|
||||
isTabActiveRef.current = isTabActive;
|
||||
|
||||
const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false;
|
||||
const effectiveOpen = open != null ? open && isTabActive : undefined;
|
||||
|
||||
// 비활성 탭에서 발생하는 onOpenChange(false) 차단
|
||||
// (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지)
|
||||
const guardedOnOpenChange = React.useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (scoped && !newOpen && !isTabActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
onOpenChange?.(newOpen);
|
||||
},
|
||||
[scoped, onOpenChange, tabId],
|
||||
);
|
||||
|
||||
return <DialogPrimitive.Root {...props} open={effectiveOpen} onOpenChange={guardedOnOpenChange} modal={effectiveModal} />;
|
||||
};
|
||||
Dialog.displayName = "Dialog";
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
@@ -21,7 +59,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-[999] bg-black/60",
|
||||
"fixed inset-0 z-999 bg-black/60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -29,28 +67,100 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface ScopedDialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
/** 포탈 대상 컨테이너. 명시적으로 전달하면 해당 값 사용, 미전달 시 탭 시스템 자동 감지 */
|
||||
container?: HTMLElement | null;
|
||||
/** 탭 비활성 시 포탈 내용 숨김 */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
ScopedDialogContentProps
|
||||
>(({ className, children, container: explicitContainer, hidden: hiddenProp, onInteractOutside, onFocusOutside, style, ...props }, ref) => {
|
||||
const autoContainer = useModalPortal();
|
||||
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
|
||||
const scoped = !!container;
|
||||
|
||||
// state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
|
||||
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
|
||||
const mergedRef = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setContentNode(node);
|
||||
if (typeof ref === "function") ref(node);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
useDialogAutoValidation(contentNode);
|
||||
|
||||
const handleInteractOutside = React.useCallback(
|
||||
(e: any) => {
|
||||
if (scoped && container) {
|
||||
const target = (e.detail?.originalEvent?.target ?? e.target) as HTMLElement | null;
|
||||
if (target && !container.contains(target)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
onInteractOutside?.(e);
|
||||
},
|
||||
[scoped, container, onInteractOutside],
|
||||
);
|
||||
|
||||
// scoped 모드: content unmount 시 포커스 이동으로 인한 onOpenChange(false) 방지
|
||||
const handleFocusOutside = React.useCallback(
|
||||
(e: any) => {
|
||||
if (scoped) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onFocusOutside?.(e);
|
||||
},
|
||||
[scoped, onFocusOutside],
|
||||
);
|
||||
|
||||
// scoped 모드: 뷰포트 기반 maxHeight/maxWidth 제거 → className의 max-h-full이 컨테이너 기준으로 적용됨
|
||||
const adjustedStyle = scoped && style
|
||||
? { ...style, maxHeight: undefined, maxWidth: undefined }
|
||||
: style;
|
||||
|
||||
return (
|
||||
<DialogPortal container={container ?? undefined}>
|
||||
<div
|
||||
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
|
||||
style={hiddenProp ? { display: "none" } : undefined}
|
||||
>
|
||||
{scoped ? (
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
) : (
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-999 bg-black/60" />
|
||||
)}
|
||||
<DialogPrimitive.Content
|
||||
ref={mergedRef}
|
||||
onInteractOutside={handleInteractOutside}
|
||||
onFocusOutside={handleFocusOutside}
|
||||
className={cn(
|
||||
scoped
|
||||
? "bg-background relative z-1 flex w-full max-w-lg max-h-full flex-col gap-4 border p-6 shadow-lg sm:rounded-lg"
|
||||
: "bg-background fixed top-[50%] left-[50%] z-1000 flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
scoped && "max-h-full",
|
||||
)}
|
||||
style={adjustedStyle}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close data-dialog-close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</div>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
@@ -59,7 +169,7 @@ const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivEleme
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
|
||||
<div data-slot="dialog-footer" className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2 shrink-0", className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
|
||||
57
frontend/components/ui/tooltip.tsx
Normal file
57
frontend/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -196,7 +196,7 @@ const TextInput = forwardRef<
|
||||
const hasError = hasBlurred && !!validationError;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="relative h-full w-full">
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
@@ -214,7 +214,7 @@ const TextInput = forwardRef<
|
||||
style={inputStyle}
|
||||
/>
|
||||
{hasError && (
|
||||
<p className="text-destructive mt-1 text-[11px]">{validationError}</p>
|
||||
<p className="text-destructive absolute left-0 top-full mt-0.5 text-[11px]">{validationError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* - swap: 스왑 선택 (좌우 이동)
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -57,6 +57,9 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||
style,
|
||||
}, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const [hoverTooltip, setHoverTooltip] = useState(false);
|
||||
|
||||
// 현재 선택된 값 존재 여부
|
||||
const hasValue = useMemo(() => {
|
||||
@@ -124,11 +127,19 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||
}, [value]);
|
||||
|
||||
const selectedLabels = useMemo(() => {
|
||||
return selectedValues
|
||||
.map((v) => safeOptions.find((o) => o.value === v)?.label)
|
||||
return safeOptions
|
||||
.filter((o) => selectedValues.includes(o.value))
|
||||
.map((o) => o.label)
|
||||
.filter(Boolean) as string[];
|
||||
}, [selectedValues, safeOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (el) {
|
||||
setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||
}
|
||||
}, [selectedLabels]);
|
||||
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
if (multiple) {
|
||||
const newValues = selectedValues.includes(selectedValue)
|
||||
@@ -148,88 +159,109 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
||||
onChange?.(multiple ? [] : "");
|
||||
}, [multiple, onChange]);
|
||||
|
||||
const displayText = selectedLabels.length > 0
|
||||
? (multiple ? selectedLabels.join(", ") : selectedLabels[0])
|
||||
: placeholder;
|
||||
const isPlaceholder = selectedLabels.length === 0;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{/* Button에 style로 직접 height 전달 (Popover도 DOM 체인 끊김) */}
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"bg-transparent hover:bg-transparent", // 표준 Select와 동일한 투명 배경
|
||||
"border-input shadow-xs", // 표준 Select와 동일한 테두리
|
||||
"h-6 px-2 py-0 text-sm", // 표준 Select xs와 동일한 높이
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<span className="truncate flex-1 text-left">
|
||||
{selectedLabels.length > 0
|
||||
? multiple
|
||||
? `${selectedLabels.length}개 선택됨`
|
||||
: selectedLabels[0]
|
||||
: placeholder}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{allowClear && selectedValues.length > 0 && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleClear}
|
||||
onPointerDown={(e) => {
|
||||
// Radix Popover가 onPointerDown으로 팝오버를 여는 것을 방지
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
|
||||
</span>
|
||||
<div
|
||||
className="relative w-full"
|
||||
onMouseEnter={() => { if (isTruncated && multiple) setHoverTooltip(true); }}
|
||||
onMouseLeave={() => setHoverTooltip(false)}
|
||||
>
|
||||
<Popover open={open} onOpenChange={(isOpen) => {
|
||||
setOpen(isOpen);
|
||||
if (isOpen) setHoverTooltip(false);
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"bg-transparent hover:bg-transparent",
|
||||
"border-input shadow-xs",
|
||||
"h-6 px-2 py-0 text-sm",
|
||||
className,
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
style={style}
|
||||
>
|
||||
<span
|
||||
ref={textRef}
|
||||
className="truncate flex-1 text-left"
|
||||
{...(isPlaceholder ? { "data-placeholder": placeholder } : {})}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{allowClear && selectedValues.length > 0 && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleClear}
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 opacity-50 hover:opacity-100" />
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command
|
||||
filter={(itemValue, search) => {
|
||||
if (!search) return 1;
|
||||
const option = safeOptions.find((o) => o.value === itemValue);
|
||||
const label = (option?.label || option?.value || "").toLowerCase();
|
||||
if (label.includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{safeOptions.map((option) => {
|
||||
const displayLabel = option.label || option.value || "(빈 값)";
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{displayLabel}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{hoverTooltip && !open && (
|
||||
<div className="absolute bottom-full left-0 z-50 mb-1 rounded-md border bg-popover px-3 py-1.5 shadow-md animate-in fade-in-0 zoom-in-95">
|
||||
<div className="space-y-0.5 text-xs">
|
||||
{selectedLabels.map((label, i) => (
|
||||
<div key={i}>{label}</div>
|
||||
))}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command
|
||||
filter={(itemValue, search) => {
|
||||
if (!search) return 1;
|
||||
const option = safeOptions.find((o) => o.value === itemValue);
|
||||
const label = (option?.label || option?.value || "").toLowerCase();
|
||||
if (label.includes(search.toLowerCase())) return 1;
|
||||
return 0;
|
||||
}}
|
||||
>
|
||||
{searchable && <CommandInput placeholder="검색..." className="h-9" />}
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{safeOptions.map((option) => {
|
||||
const displayLabel = option.label || option.value || "(빈 값)";
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => handleSelect(option.value)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedValues.includes(option.value) ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{displayLabel}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
DropdownSelect.displayName = "DropdownSelect";
|
||||
|
||||
11
frontend/contexts/TabIdContext.tsx
Normal file
11
frontend/contexts/TabIdContext.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
const TabIdContext = createContext<string | null>(null);
|
||||
|
||||
export const TabIdProvider = TabIdContext.Provider;
|
||||
|
||||
export function useTabId(): string | null {
|
||||
return useContext(TabIdContext);
|
||||
}
|
||||
213
frontend/lib/button-icon-map.tsx
Normal file
213
frontend/lib/button-icon-map.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React from "react";
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import {
|
||||
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
|
||||
Trash2, Trash, XCircle, X, Eraser, CircleX,
|
||||
Pencil, PenLine, Pen, SquarePen, FilePen, PenTool,
|
||||
ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link,
|
||||
Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen,
|
||||
SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2,
|
||||
Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput,
|
||||
FileUp, FileInput,
|
||||
Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus,
|
||||
Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Settings2,
|
||||
ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus,
|
||||
Truck, Car, MapPin, Navigation2, Route, Bell,
|
||||
Send, Radio, Megaphone, Podcast, BellRing,
|
||||
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
|
||||
SquareMousePointer,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 아이콘 이름 → 컴포넌트 매핑 (추천 아이콘만 명시적 import)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const iconMap: Record<string, LucideIcon> = {
|
||||
Check, Save, CheckCircle, CircleCheck, FileCheck, ShieldCheck,
|
||||
Trash2, Trash, XCircle, X, Eraser, CircleX,
|
||||
Pencil, PenLine, Pen, SquarePen, FilePen, PenTool,
|
||||
ArrowRight, ExternalLink, MoveRight, Navigation, CornerUpRight, Link,
|
||||
Maximize2, PanelTop, AppWindow, LayoutGrid, Layers, FolderOpen,
|
||||
SendHorizontal, ArrowRightLeft, Repeat, PackageCheck, Upload, Share2,
|
||||
Download, FileDown, FileSpreadsheet, Sheet, Table, FileOutput,
|
||||
FileUp, FileInput,
|
||||
Zap, Plus, PlusCircle, SquarePlus, FilePlus, BadgePlus,
|
||||
Settings, SlidersHorizontal, ToggleLeft, Workflow, GitBranch, Settings2,
|
||||
ScanLine, QrCode, Camera, Scan, ScanBarcode, Focus,
|
||||
Truck, Car, MapPin, Navigation2, Route, Bell,
|
||||
Send, Radio, Megaphone, Podcast, BellRing,
|
||||
Copy, ClipboardCopy, Files, CopyPlus, ClipboardList, Clipboard,
|
||||
SquareMousePointer,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 버튼 액션 → 추천 아이콘 이름 매핑
|
||||
// ---------------------------------------------------------------------------
|
||||
export const actionIconMap: Record<string, string[]> = {
|
||||
save: ["Check", "Save", "CheckCircle", "CircleCheck", "FileCheck", "ShieldCheck"],
|
||||
delete: ["Trash2", "Trash", "XCircle", "X", "Eraser", "CircleX"],
|
||||
edit: ["Pencil", "PenLine", "Pen", "SquarePen", "FilePen", "PenTool"],
|
||||
navigate: ["ArrowRight", "ExternalLink", "MoveRight", "Navigation", "CornerUpRight", "Link"],
|
||||
modal: ["Maximize2", "PanelTop", "AppWindow", "LayoutGrid", "Layers", "FolderOpen"],
|
||||
transferData: ["SendHorizontal", "ArrowRightLeft", "Repeat", "PackageCheck", "Upload", "Share2"],
|
||||
excel_download: ["Download", "FileDown", "FileSpreadsheet", "Sheet", "Table", "FileOutput"],
|
||||
excel_upload: ["Upload", "FileUp", "FileSpreadsheet", "Sheet", "FileInput", "FileOutput"],
|
||||
quickInsert: ["Zap", "Plus", "PlusCircle", "SquarePlus", "FilePlus", "BadgePlus"],
|
||||
control: ["Settings", "SlidersHorizontal", "ToggleLeft", "Workflow", "GitBranch", "Settings2"],
|
||||
barcode_scan: ["ScanLine", "QrCode", "Camera", "Scan", "ScanBarcode", "Focus"],
|
||||
operation_control: ["Truck", "Car", "MapPin", "Navigation2", "Route", "Bell"],
|
||||
event: ["Send", "Bell", "Radio", "Megaphone", "Podcast", "BellRing"],
|
||||
copy: ["Copy", "ClipboardCopy", "Files", "CopyPlus", "ClipboardList", "Clipboard"],
|
||||
};
|
||||
|
||||
// 아이콘 추천이 불가능한 deprecated/숨김 액션
|
||||
export const noIconActions = new Set([
|
||||
"openRelatedModal",
|
||||
"openModalWithData",
|
||||
"view_table_history",
|
||||
"code_merge",
|
||||
"empty_vehicle",
|
||||
]);
|
||||
|
||||
export const NO_ICON_MESSAGE = "적절한 추천 아이콘이 없습니다. 텍스트 모드를 사용하거나 아래에서 아이콘을 직접 추가하세요.";
|
||||
|
||||
// 범용 폴백 아이콘 (추천 아이콘이 없는 액션용)
|
||||
export const FALLBACK_ICON_NAME = "SquareMousePointer";
|
||||
|
||||
/** 액션 타입에 대한 디폴트 아이콘(첫 번째 추천)을 반환. 없으면 범용 폴백. */
|
||||
export function getDefaultIconForAction(actionType?: string): { name: string; type: "lucide" } {
|
||||
if (actionType && actionIconMap[actionType]?.length) {
|
||||
return { name: actionIconMap[actionType][0], type: "lucide" };
|
||||
}
|
||||
return { name: FALLBACK_ICON_NAME, type: "lucide" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 아이콘 크기 (버튼 높이 대비 비율)
|
||||
// ---------------------------------------------------------------------------
|
||||
export const iconSizePresets: Record<string, number> = {
|
||||
"작게": 40,
|
||||
"보통": 55,
|
||||
"크게": 70,
|
||||
"매우 크게": 85,
|
||||
};
|
||||
|
||||
/** 프리셋 문자열 → 비율(%) 반환. 레거시 값은 55(보통)로 폴백 */
|
||||
export function getIconPercent(size: string | number): number {
|
||||
if (typeof size === "number") return size;
|
||||
return iconSizePresets[size] ?? 55;
|
||||
}
|
||||
|
||||
/** 아이콘 크기를 CSS로 변환 (버튼 높이 대비 비율, 정사각형 유지) */
|
||||
export function getIconSizeStyle(size: string | number): React.CSSProperties {
|
||||
const pct = getIconPercent(size);
|
||||
return { height: `${pct}%`, width: "auto", aspectRatio: "1 / 1" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 아이콘 조회 / 동적 등록
|
||||
// ---------------------------------------------------------------------------
|
||||
export function getLucideIcon(name: string): LucideIcon | undefined {
|
||||
return iconMap[name];
|
||||
}
|
||||
|
||||
export function addToIconMap(name: string, component: LucideIcon): void {
|
||||
iconMap[name] = component;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG 정화
|
||||
// ---------------------------------------------------------------------------
|
||||
export function sanitizeSvg(svgString: string): string {
|
||||
return DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 버튼 아이콘 렌더러 컴포넌트 (모든 뷰어/위젯에서 공용)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function ButtonIconRenderer({
|
||||
componentConfig,
|
||||
fallbackLabel,
|
||||
}: {
|
||||
componentConfig: any;
|
||||
fallbackLabel: string;
|
||||
}) {
|
||||
const cfg = componentConfig || {};
|
||||
const displayMode = cfg.displayMode || "text";
|
||||
|
||||
if (displayMode === "text" || !cfg.icon?.name) {
|
||||
return <>{cfg.text || fallbackLabel}</>;
|
||||
}
|
||||
|
||||
return <>{getButtonDisplayContent(cfg)}</>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 버튼 표시 콘텐츠 계산 (모든 렌더러 공용)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function getButtonDisplayContent(componentConfig: any): React.ReactNode {
|
||||
const displayMode = componentConfig?.displayMode || "text";
|
||||
const text = componentConfig?.text || componentConfig?.label || "버튼";
|
||||
const icon = componentConfig?.icon;
|
||||
|
||||
if (displayMode === "text" || !icon?.name) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// 아이콘 노드 생성
|
||||
const sizeStyle = getIconSizeStyle(icon.size || "보통");
|
||||
const colorStyle: React.CSSProperties = icon.color ? { color: icon.color } : {};
|
||||
let iconNode: React.ReactNode = null;
|
||||
|
||||
if (icon.type === "svg") {
|
||||
const svgIcon = componentConfig?.customSvgIcons?.find(
|
||||
(s: { name: string; svg: string }) => s.name === icon.name,
|
||||
);
|
||||
if (svgIcon) {
|
||||
const clean = sanitizeSvg(svgIcon.svg);
|
||||
iconNode = (
|
||||
<span
|
||||
className="inline-flex items-center justify-center [&>svg]:h-full [&>svg]:w-full"
|
||||
style={{ ...sizeStyle, ...colorStyle }}
|
||||
dangerouslySetInnerHTML={{ __html: clean }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const IconComponent = getLucideIcon(icon.name);
|
||||
if (IconComponent) {
|
||||
iconNode = (
|
||||
<span className="inline-flex items-center justify-center" style={sizeStyle}>
|
||||
<IconComponent className="h-full w-full" style={colorStyle} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!iconNode) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (displayMode === "icon") {
|
||||
return iconNode;
|
||||
}
|
||||
|
||||
// icon-text 모드
|
||||
const gap = componentConfig?.iconGap ?? 6;
|
||||
const textPos = componentConfig?.iconTextPosition || "right";
|
||||
const isVertical = textPos === "top" || textPos === "bottom";
|
||||
const textFirst = textPos === "left" || textPos === "top";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center"
|
||||
style={{
|
||||
gap: `${gap}px`,
|
||||
flexDirection: isVertical ? "column" : "row",
|
||||
}}
|
||||
>
|
||||
{textFirst ? <span>{text}</span> : iconNode}
|
||||
{textFirst ? iconNode : <span>{text}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
137
frontend/lib/formatting/index.ts
Normal file
137
frontend/lib/formatting/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 중앙 포맷팅 함수.
|
||||
* 모든 컴포넌트는 날짜/숫자/통화를 표시할 때 이 함수들만 호출한다.
|
||||
*
|
||||
* 사용법:
|
||||
* import { formatDate, formatNumber, formatCurrency } from "@/lib/formatting";
|
||||
* formatDate("2025-01-01") // "2025-01-01"
|
||||
* formatDate("2025-01-01T14:30:00Z", "datetime") // "2025-01-01 14:30:00"
|
||||
* formatNumber(1234567) // "1,234,567"
|
||||
* formatCurrency(50000) // "₩50,000"
|
||||
*/
|
||||
|
||||
export { getFormatRules, setFormatRules, DEFAULT_FORMAT_RULES } from "./rules";
|
||||
export type { FormatRules, DateFormatRules, NumberFormatRules, CurrencyFormatRules } from "./rules";
|
||||
|
||||
import { getFormatRules } from "./rules";
|
||||
|
||||
// --- 날짜 포맷 ---
|
||||
|
||||
type DateFormatType = "display" | "datetime" | "input" | "time";
|
||||
|
||||
/**
|
||||
* 날짜 값을 지정된 형식으로 포맷한다.
|
||||
* @param value - ISO 문자열, Date, 타임스탬프
|
||||
* @param type - "display" | "datetime" | "input" | "time"
|
||||
* @returns 포맷된 문자열 (파싱 실패 시 원본 반환)
|
||||
*/
|
||||
export function formatDate(value: unknown, type: DateFormatType = "display"): string {
|
||||
if (value == null || value === "") return "";
|
||||
|
||||
const rules = getFormatRules();
|
||||
const format = rules.date[type];
|
||||
|
||||
try {
|
||||
const date = value instanceof Date ? value : new Date(String(value));
|
||||
if (isNaN(date.getTime())) return String(value);
|
||||
|
||||
return applyDateFormat(date, format);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* YYYY-MM-DD HH:mm:ss 패턴을 Date 객체에 적용
|
||||
*/
|
||||
function applyDateFormat(date: Date, pattern: string): string {
|
||||
const y = date.getFullYear();
|
||||
const M = date.getMonth() + 1;
|
||||
const d = date.getDate();
|
||||
const H = date.getHours();
|
||||
const m = date.getMinutes();
|
||||
const s = date.getSeconds();
|
||||
|
||||
return pattern
|
||||
.replace("YYYY", String(y))
|
||||
.replace("MM", String(M).padStart(2, "0"))
|
||||
.replace("DD", String(d).padStart(2, "0"))
|
||||
.replace("HH", String(H).padStart(2, "0"))
|
||||
.replace("mm", String(m).padStart(2, "0"))
|
||||
.replace("ss", String(s).padStart(2, "0"));
|
||||
}
|
||||
|
||||
// --- 숫자 포맷 ---
|
||||
|
||||
/**
|
||||
* 숫자를 로케일 기반으로 포맷한다 (천단위 구분자 등).
|
||||
* @param value - 숫자 또는 숫자 문자열
|
||||
* @param decimals - 소수점 자릿수 (미지정 시 기본값 사용)
|
||||
* @returns 포맷된 문자열
|
||||
*/
|
||||
export function formatNumber(value: unknown, decimals?: number): string {
|
||||
if (value == null || value === "") return "";
|
||||
|
||||
const rules = getFormatRules();
|
||||
const num = typeof value === "number" ? value : parseFloat(String(value));
|
||||
if (isNaN(num)) return String(value);
|
||||
|
||||
const dec = decimals ?? rules.number.decimals;
|
||||
|
||||
return new Intl.NumberFormat(rules.number.locale, {
|
||||
minimumFractionDigits: dec,
|
||||
maximumFractionDigits: dec,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
// --- 통화 포맷 ---
|
||||
|
||||
/**
|
||||
* 금액을 통화 형식으로 포맷한다.
|
||||
* @param value - 숫자 또는 숫자 문자열
|
||||
* @param currencyCode - 통화 코드 (미지정 시 기본값 사용)
|
||||
* @returns 포맷된 문자열 (예: "₩50,000")
|
||||
*/
|
||||
export function formatCurrency(value: unknown, currencyCode?: string): string {
|
||||
if (value == null || value === "") return "";
|
||||
|
||||
const rules = getFormatRules();
|
||||
const num = typeof value === "number" ? value : parseFloat(String(value));
|
||||
if (isNaN(num)) return String(value);
|
||||
|
||||
const code = currencyCode ?? rules.currency.code;
|
||||
|
||||
return new Intl.NumberFormat(rules.currency.locale, {
|
||||
style: "currency",
|
||||
currency: code,
|
||||
maximumFractionDigits: code === "KRW" ? 0 : 2,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
// --- 범용 포맷 ---
|
||||
|
||||
/**
|
||||
* 데이터 타입에 따라 자동으로 적절한 포맷을 적용한다.
|
||||
* @param value - 포맷할 값
|
||||
* @param dataType - "date" | "datetime" | "number" | "currency" | "text"
|
||||
*/
|
||||
export function formatValue(value: unknown, dataType: string): string {
|
||||
switch (dataType) {
|
||||
case "date":
|
||||
return formatDate(value, "display");
|
||||
case "datetime":
|
||||
return formatDate(value, "datetime");
|
||||
case "time":
|
||||
return formatDate(value, "time");
|
||||
case "number":
|
||||
case "integer":
|
||||
case "float":
|
||||
case "decimal":
|
||||
return formatNumber(value);
|
||||
case "currency":
|
||||
case "money":
|
||||
return formatCurrency(value);
|
||||
default:
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
}
|
||||
71
frontend/lib/formatting/rules.ts
Normal file
71
frontend/lib/formatting/rules.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 중앙 포맷팅 규칙 정의.
|
||||
* 모든 날짜/숫자/통화 포맷은 이 파일의 규칙을 따른다.
|
||||
* 변경이 필요하면 이 파일만 수정하면 전체 적용된다.
|
||||
*/
|
||||
|
||||
export interface DateFormatRules {
|
||||
/** 날짜만 표시 (예: "2025-01-01") */
|
||||
display: string;
|
||||
/** 날짜+시간 표시 (예: "2025-01-01 14:30:00") */
|
||||
datetime: string;
|
||||
/** 입력 필드용 (예: "YYYY-MM-DD") */
|
||||
input: string;
|
||||
/** 시간만 표시 (예: "14:30") */
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface NumberFormatRules {
|
||||
/** 숫자 로케일 (천단위 구분자 등) */
|
||||
locale: string;
|
||||
/** 기본 소수점 자릿수 */
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
export interface CurrencyFormatRules {
|
||||
/** 통화 코드 (예: "KRW", "USD") */
|
||||
code: string;
|
||||
/** 통화 로케일 */
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export interface FormatRules {
|
||||
date: DateFormatRules;
|
||||
number: NumberFormatRules;
|
||||
currency: CurrencyFormatRules;
|
||||
}
|
||||
|
||||
/** 기본 포맷 규칙 (한국어 기준) */
|
||||
export const DEFAULT_FORMAT_RULES: FormatRules = {
|
||||
date: {
|
||||
display: "YYYY-MM-DD",
|
||||
datetime: "YYYY-MM-DD HH:mm:ss",
|
||||
input: "YYYY-MM-DD",
|
||||
time: "HH:mm",
|
||||
},
|
||||
number: {
|
||||
locale: "ko-KR",
|
||||
decimals: 0,
|
||||
},
|
||||
currency: {
|
||||
code: "KRW",
|
||||
locale: "ko-KR",
|
||||
},
|
||||
};
|
||||
|
||||
/** 현재 적용 중인 포맷 규칙 (런타임에 변경 가능) */
|
||||
let currentRules: FormatRules = { ...DEFAULT_FORMAT_RULES };
|
||||
|
||||
export function getFormatRules(): FormatRules {
|
||||
return currentRules;
|
||||
}
|
||||
|
||||
export function setFormatRules(rules: Partial<FormatRules>): void {
|
||||
currentRules = {
|
||||
...currentRules,
|
||||
...rules,
|
||||
date: { ...currentRules.date, ...rules.date },
|
||||
number: { ...currentRules.number, ...rules.number },
|
||||
currency: { ...currentRules.currency, ...rules.currency },
|
||||
};
|
||||
}
|
||||
228
frontend/lib/hooks/useDialogAutoValidation.ts
Normal file
228
frontend/lib/hooks/useDialogAutoValidation.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const HIGHLIGHT_ATTR = "data-validation-highlight";
|
||||
const ERROR_ATTR = "data-validation-error";
|
||||
const MSG_WRAPPER_CLASS = "validation-error-msg-wrapper";
|
||||
|
||||
type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;
|
||||
|
||||
/**
|
||||
* 모달 필수 입력 검증 훅 (클릭 인터셉트 방식)
|
||||
*
|
||||
* 저장/수정/확인 버튼 클릭 시 빈 필수 필드가 있으면:
|
||||
* 1. 클릭 이벤트 차단
|
||||
* 2. 첫 번째 빈 필드로 포커스 이동 + 하이라이트
|
||||
* 3. 빈 필드에 빨간 테두리 유지 (값 입력 시 해제)
|
||||
* 4. 토스트 알림 표시
|
||||
*
|
||||
* 설계: docs/ycshin-node/필수입력항목_자동검증_설계.md
|
||||
*/
|
||||
export function useDialogAutoValidation(contentEl: HTMLElement | null) {
|
||||
const mode = useTabStore((s) => s.mode);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "user") return;
|
||||
|
||||
const el = contentEl;
|
||||
if (!el) return;
|
||||
|
||||
const errorFields = new Set<TargetEl>();
|
||||
let activated = false; // 첫 저장 시도 이후 true
|
||||
|
||||
function findRequiredFields(): Map<TargetEl, string> {
|
||||
const fields = new Map<TargetEl, string>();
|
||||
if (!el) return fields;
|
||||
|
||||
el.querySelectorAll("label").forEach((label) => {
|
||||
const hasRequiredMark = Array.from(label.querySelectorAll("span")).some(
|
||||
(span) => span.textContent?.trim() === "*",
|
||||
);
|
||||
if (!hasRequiredMark) return;
|
||||
|
||||
const forId = label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
|
||||
let target: TargetEl | null = null;
|
||||
|
||||
if (forId) {
|
||||
try {
|
||||
const found = el!.querySelector(`#${CSS.escape(forId)}`);
|
||||
if (isFormElement(found)) {
|
||||
target = found;
|
||||
} else if (found) {
|
||||
const inner = found.querySelector("input, textarea, select");
|
||||
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
|
||||
target = inner;
|
||||
}
|
||||
// 숨겨진 Radix select이거나 폼 요소가 없으면 → 트리거 버튼 탐색
|
||||
if (!target) {
|
||||
const btn = found.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
|
||||
if (btn instanceof HTMLButtonElement) target = btn;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* invalid id */
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
const parent = label.closest('[class*="space-y"]') || label.parentElement;
|
||||
if (parent) {
|
||||
const inner = parent.querySelector("input, textarea, select");
|
||||
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
|
||||
target = inner;
|
||||
}
|
||||
if (!target) {
|
||||
const btn = parent.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
|
||||
if (btn instanceof HTMLButtonElement) target = btn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target) {
|
||||
const labelText = label.textContent?.replace(/\*/g, "").trim() || "";
|
||||
fields.set(target, labelText);
|
||||
}
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function isFormElement(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
|
||||
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
|
||||
}
|
||||
|
||||
function isHiddenRadixSelect(el: Element): boolean {
|
||||
return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden");
|
||||
}
|
||||
|
||||
function isEmpty(input: TargetEl): boolean {
|
||||
if (input instanceof HTMLButtonElement) {
|
||||
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
|
||||
return !!input.querySelector("[data-placeholder]");
|
||||
}
|
||||
return input.value.trim() === "";
|
||||
}
|
||||
|
||||
function isSaveButton(target: HTMLElement): boolean {
|
||||
const btn = target.closest("button");
|
||||
if (!btn) return false;
|
||||
|
||||
const actionType = btn.getAttribute("data-action-type");
|
||||
if (actionType === "save" || actionType === "submit") return true;
|
||||
|
||||
const variant = btn.getAttribute("data-variant");
|
||||
if (variant === "default") return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function markError(input: TargetEl) {
|
||||
input.setAttribute(ERROR_ATTR, "true");
|
||||
errorFields.add(input);
|
||||
showErrorMsg(input);
|
||||
}
|
||||
|
||||
function clearError(input: TargetEl) {
|
||||
input.removeAttribute(ERROR_ATTR);
|
||||
errorFields.delete(input);
|
||||
removeErrorMsg(input);
|
||||
}
|
||||
|
||||
// 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper)
|
||||
function showErrorMsg(input: TargetEl) {
|
||||
if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = MSG_WRAPPER_CLASS;
|
||||
|
||||
const msg = document.createElement("p");
|
||||
msg.textContent = "필수 입력 항목입니다";
|
||||
wrapper.appendChild(msg);
|
||||
|
||||
input.insertAdjacentElement("afterend", wrapper);
|
||||
}
|
||||
|
||||
function removeErrorMsg(input: TargetEl) {
|
||||
const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
|
||||
if (wrapper) wrapper.remove();
|
||||
}
|
||||
|
||||
function highlightField(input: TargetEl) {
|
||||
input.setAttribute(HIGHLIGHT_ATTR, "true");
|
||||
input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true });
|
||||
|
||||
if (input instanceof HTMLButtonElement) {
|
||||
input.click();
|
||||
} else {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// 첫 저장 시도 이후: 빈 필드 → 에러 유지/재적용, 값 있으면 해제
|
||||
function syncErrors() {
|
||||
if (!activated) return;
|
||||
const fields = findRequiredFields();
|
||||
for (const [input] of fields) {
|
||||
if (isEmpty(input)) {
|
||||
markError(input);
|
||||
} else {
|
||||
clearError(input);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!isSaveButton(target)) return;
|
||||
|
||||
const fields = findRequiredFields();
|
||||
if (fields.size === 0) return;
|
||||
|
||||
let firstEmpty: TargetEl | null = null;
|
||||
let firstEmptyLabel = "";
|
||||
|
||||
for (const [input, label] of fields) {
|
||||
if (isEmpty(input)) {
|
||||
markError(input);
|
||||
if (!firstEmpty) {
|
||||
firstEmpty = input;
|
||||
firstEmptyLabel = label;
|
||||
}
|
||||
} else {
|
||||
clearError(input);
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstEmpty) return;
|
||||
|
||||
activated = true;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
highlightField(firstEmpty);
|
||||
toast.error(`${firstEmptyLabel} 항목을 입력해주세요`);
|
||||
}
|
||||
|
||||
// V2Select는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화
|
||||
const observer = new MutationObserver(syncErrors);
|
||||
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-placeholder"] });
|
||||
|
||||
el.addEventListener("click", handleClick, true);
|
||||
el.addEventListener("input", syncErrors);
|
||||
el.addEventListener("change", syncErrors);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("click", handleClick, true);
|
||||
el.removeEventListener("input", syncErrors);
|
||||
el.removeEventListener("change", syncErrors);
|
||||
observer.disconnect();
|
||||
|
||||
el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR));
|
||||
el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR));
|
||||
el.querySelectorAll(`.${MSG_WRAPPER_CLASS}`).forEach((node) => node.remove());
|
||||
};
|
||||
}, [mode, contentEl]);
|
||||
}
|
||||
31
frontend/lib/modalPortalRef.ts
Normal file
31
frontend/lib/modalPortalRef.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* 모달 포탈 컨테이너 전역 레퍼런스.
|
||||
* TabContent가 마운트 시 registerModalPortal(el)로 등록하고,
|
||||
* 모달 컴포넌트들은 useModalPortal()로 컨테이너를 구독합니다.
|
||||
* React 컴포넌트 트리 위치에 무관하게 동작합니다.
|
||||
*/
|
||||
let _container: HTMLElement | null = null;
|
||||
const _subscribers = new Set<(el: HTMLElement | null) => void>();
|
||||
|
||||
export function registerModalPortal(el: HTMLElement | null) {
|
||||
_container = el;
|
||||
_subscribers.forEach((fn) => fn(el));
|
||||
}
|
||||
|
||||
export function useModalPortal(): HTMLElement | null {
|
||||
const [el, setEl] = useState<HTMLElement | null>(_container);
|
||||
|
||||
useEffect(() => {
|
||||
setEl(_container);
|
||||
_subscribers.add(setEl);
|
||||
return () => {
|
||||
_subscribers.delete(setEl);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return el;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
|
||||
import { formatNumber } from "@/lib/formatting";
|
||||
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
@@ -136,11 +137,11 @@ export function AggregationWidgetComponent({
|
||||
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
|
||||
|
||||
if (item.format === "currency") {
|
||||
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
|
||||
formattedValue = formatNumber(value);
|
||||
} else if (item.format === "percent") {
|
||||
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
|
||||
} else if (item.format === "number") {
|
||||
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
|
||||
formattedValue = formatNumber(value);
|
||||
}
|
||||
|
||||
if (item.prefix) {
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
config?: ButtonPrimaryConfig;
|
||||
// 추가 props
|
||||
@@ -1248,7 +1247,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
|
||||
const finalDisabled =
|
||||
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
||||
|
||||
|
||||
@@ -112,13 +112,13 @@ import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||
import "./v2-file-upload/V2FileUploadRenderer"; // V2 파일 업로드 컴포넌트
|
||||
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
||||
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
|
||||
import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
|
||||
import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
|
||||
import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
|
||||
import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드
|
||||
import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
|
||||
import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 다양한 집계 연산을 수행합니다.
|
||||
*/
|
||||
|
||||
import { getFormatRules } from "@/lib/formatting";
|
||||
|
||||
import { AggregationType, PivotFieldFormat } from "../types";
|
||||
|
||||
// ==================== 집계 함수 ====================
|
||||
@@ -102,16 +104,18 @@ export function formatNumber(
|
||||
|
||||
let formatted: string;
|
||||
|
||||
const locale = getFormatRules().number.locale;
|
||||
|
||||
switch (type) {
|
||||
case "currency":
|
||||
formatted = value.toLocaleString("ko-KR", {
|
||||
formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
break;
|
||||
|
||||
case "percent":
|
||||
formatted = (value * 100).toLocaleString("ko-KR", {
|
||||
formatted = (value * 100).toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
@@ -120,7 +124,7 @@ export function formatNumber(
|
||||
case "number":
|
||||
default:
|
||||
if (thousandSeparator) {
|
||||
formatted = value.toLocaleString("ko-KR", {
|
||||
formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
@@ -138,7 +142,7 @@ export function formatNumber(
|
||||
*/
|
||||
export function formatDate(
|
||||
value: Date | string | null | undefined,
|
||||
format: string = "YYYY-MM-DD"
|
||||
format: string = getFormatRules().date.display
|
||||
): string {
|
||||
if (!value) return "-";
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ function getFieldValue(
|
||||
const weekNum = getWeekNumber(date);
|
||||
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
||||
case "day":
|
||||
return formatDate(date, "YYYY-MM-DD");
|
||||
return formatDate(date);
|
||||
default:
|
||||
return String(rawValue);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AggregationWidgetConfig, AggregationItem, AggregationResult, Aggregatio
|
||||
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
import { formatNumber } from "@/lib/formatting";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||
|
||||
@@ -566,11 +567,11 @@ export function AggregationWidgetComponent({
|
||||
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
|
||||
|
||||
if (item.format === "currency") {
|
||||
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
|
||||
formattedValue = formatNumber(value);
|
||||
} else if (item.format === "percent") {
|
||||
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
|
||||
} else if (item.format === "number") {
|
||||
formattedValue = new Intl.NumberFormat("ko-KR").format(value);
|
||||
formattedValue = formatNumber(value);
|
||||
}
|
||||
|
||||
if (item.prefix) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { ButtonIconRenderer } from "@/lib/button-icon-map";
|
||||
import { useCurrentFlowStep } from "@/stores/flowStepStore";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
@@ -29,7 +30,6 @@ import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelC
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { V2ErrorBoundary, v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
config?: ButtonPrimaryConfig;
|
||||
// 추가 props
|
||||
@@ -556,13 +556,23 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
}
|
||||
|
||||
// 스타일 계산
|
||||
// 🔧 사용자가 설정한 크기가 있으면 그대로 사용
|
||||
const componentStyle: React.CSSProperties = {
|
||||
// 외부 wrapper는 부모 컨테이너(RealtimePreviewDynamic)에 맞춰 100% 채움
|
||||
// border는 내부 버튼에서만 적용 (wrapper에 적용되면 이중 테두리 발생)
|
||||
const {
|
||||
border: _border, borderWidth: _bw, borderStyle: _bs, borderColor: _bc, borderRadius: _br,
|
||||
...restComponentStyle
|
||||
} = {
|
||||
...component.style,
|
||||
...style,
|
||||
} as React.CSSProperties & Record<string, any>;
|
||||
|
||||
const componentStyle: React.CSSProperties = {
|
||||
...restComponentStyle,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.borderWidth = "1px";
|
||||
componentStyle.borderStyle = "dashed";
|
||||
@@ -1217,15 +1227,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
effectiveFormData = { ...splitPanelParentData };
|
||||
}
|
||||
|
||||
console.log("🔴 [ButtonPrimary] 저장 시 formData 디버그:", {
|
||||
propsFormDataKeys: Object.keys(propsFormData),
|
||||
screenContextFormDataKeys: Object.keys(screenContextFormData),
|
||||
effectiveFormDataKeys: Object.keys(effectiveFormData),
|
||||
process_code: effectiveFormData.process_code,
|
||||
equipment_code: effectiveFormData.equipment_code,
|
||||
fullData: JSON.stringify(effectiveFormData),
|
||||
});
|
||||
|
||||
const context: ButtonActionContext = {
|
||||
formData: effectiveFormData,
|
||||
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
|
||||
@@ -1382,31 +1383,29 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 최종 비활성화 상태 (설정 + 조건부 비활성화 + 행 선택 필수)
|
||||
const finalDisabled =
|
||||
componentConfig.disabled || isOperationButtonDisabled || isRowSelectionDisabled || statusLoading;
|
||||
|
||||
// 공통 버튼 스타일
|
||||
// 🔧 component.style에서 background/backgroundColor 충돌 방지 (width/height는 허용)
|
||||
// 크기는 부모 컨테이너(RealtimePreviewDynamic)에서 관리하므로 width/height 제외
|
||||
const userStyle = component.style
|
||||
? Object.fromEntries(
|
||||
Object.entries(component.style).filter(([key]) => !["background", "backgroundColor"].includes(key)),
|
||||
Object.entries(component.style).filter(([key]) => !["background", "backgroundColor", "width", "height"].includes(key)),
|
||||
)
|
||||
: {};
|
||||
|
||||
// 🔧 사용자가 설정한 크기 우선 사용, 없으면 100%
|
||||
const buttonWidth = component.size?.width ? `${component.size.width}px` : style?.width || "100%";
|
||||
const buttonHeight = component.size?.height ? `${component.size.height}px` : style?.height || "100%";
|
||||
// 버튼은 부모 컨테이너를 꽉 채움 (크기는 RealtimePreviewDynamic에서 관리)
|
||||
const buttonWidth = "100%";
|
||||
const buttonHeight = "100%";
|
||||
|
||||
const buttonElementStyle: React.CSSProperties = {
|
||||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
|
||||
// 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선)
|
||||
border: style?.border || (style?.borderWidth ? undefined : "none"),
|
||||
borderWidth: style?.borderWidth || undefined,
|
||||
borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined,
|
||||
borderColor: style?.borderColor || undefined,
|
||||
// 커스텀 테두리 스타일 (StyleEditor 설정 우선, shorthand 사용 안 함)
|
||||
borderWidth: style?.borderWidth || "0",
|
||||
borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || (style?.borderWidth ? "solid" : "none"),
|
||||
borderColor: style?.borderColor || "transparent",
|
||||
borderRadius: style?.borderRadius || "0.5rem",
|
||||
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
|
||||
color: finalDisabled ? "#9ca3af" : (style?.color || buttonTextColor), // 🔧 StyleEditor 텍스트 색상도 지원
|
||||
@@ -1444,7 +1443,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
cancel: "취소",
|
||||
};
|
||||
|
||||
const buttonContent =
|
||||
const buttonTextContent =
|
||||
processedConfig.text ||
|
||||
component.webTypeConfig?.text ||
|
||||
component.componentConfig?.text ||
|
||||
@@ -1458,16 +1457,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
<>
|
||||
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||
{isDesignMode ? (
|
||||
// 디자인 모드: div로 렌더링하여 선택 가능하게 함
|
||||
<div
|
||||
className="transition-colors duration-150 hover:opacity-90"
|
||||
style={buttonElementStyle}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{buttonContent}
|
||||
<ButtonIconRenderer
|
||||
componentConfig={componentConfig}
|
||||
fallbackLabel={buttonTextContent as string}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// 일반 모드: button으로 렌더링
|
||||
<button
|
||||
type={componentConfig.actionType || "button"}
|
||||
disabled={finalDisabled}
|
||||
@@ -1476,8 +1476,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...(actionType ? { "data-action-type": actionType } : {})}
|
||||
>
|
||||
{buttonContent}
|
||||
<ButtonIconRenderer
|
||||
componentConfig={componentConfig}
|
||||
fallbackLabel={buttonTextContent as string}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponent
|
||||
import { V2DateDefinition } from "./index";
|
||||
import { V2Date } from "@/components/v2/V2Date";
|
||||
import { isColumnRequiredByMeta } from "../../DynamicComponentRenderer";
|
||||
import { getFormatRules } from "@/lib/formatting";
|
||||
|
||||
/**
|
||||
* V2Date 렌더러
|
||||
@@ -34,7 +35,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
|
||||
// 라벨: style.labelText 우선, 없으면 component.label 사용
|
||||
// style.labelDisplay가 false면 라벨 숨김
|
||||
const style = component.style || {};
|
||||
const effectiveLabel = style.labelDisplay === false ? undefined : (style.labelText || component.label);
|
||||
const effectiveLabel = style.labelDisplay === false ? undefined : style.labelText || component.label;
|
||||
|
||||
return (
|
||||
<V2Date
|
||||
@@ -43,7 +44,7 @@ export class V2DateRenderer extends AutoRegisteringComponentRenderer {
|
||||
onChange={handleChange}
|
||||
config={{
|
||||
dateType: config.dateType || config.webType || "date",
|
||||
format: config.format || "YYYY-MM-DD",
|
||||
format: config.format || getFormatRules().date.display,
|
||||
placeholder: config.placeholder || style.placeholder || "날짜 선택",
|
||||
showTime: config.showTime || false,
|
||||
use24Hours: config.use24Hours ?? true,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* 다양한 집계 연산을 수행합니다.
|
||||
*/
|
||||
|
||||
import { getFormatRules } from "@/lib/formatting";
|
||||
|
||||
import { AggregationType, PivotFieldFormat } from "../types";
|
||||
|
||||
// ==================== 집계 함수 ====================
|
||||
@@ -102,16 +104,18 @@ export function formatNumber(
|
||||
|
||||
let formatted: string;
|
||||
|
||||
const locale = getFormatRules().number.locale;
|
||||
|
||||
switch (type) {
|
||||
case "currency":
|
||||
formatted = value.toLocaleString("ko-KR", {
|
||||
formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
break;
|
||||
|
||||
case "percent":
|
||||
formatted = (value * 100).toLocaleString("ko-KR", {
|
||||
formatted = (value * 100).toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
@@ -120,7 +124,7 @@ export function formatNumber(
|
||||
case "number":
|
||||
default:
|
||||
if (thousandSeparator) {
|
||||
formatted = value.toLocaleString("ko-KR", {
|
||||
formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: precision,
|
||||
maximumFractionDigits: precision,
|
||||
});
|
||||
@@ -138,7 +142,7 @@ export function formatNumber(
|
||||
*/
|
||||
export function formatDate(
|
||||
value: Date | string | null | undefined,
|
||||
format: string = "YYYY-MM-DD"
|
||||
format: string = getFormatRules().date.display
|
||||
): string {
|
||||
if (!value) return "-";
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ function getFieldValue(
|
||||
const weekNum = getWeekNumber(date);
|
||||
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
||||
case "day":
|
||||
return formatDate(date, "YYYY-MM-DD");
|
||||
return formatDate(date);
|
||||
default:
|
||||
return String(rawValue);
|
||||
}
|
||||
|
||||
@@ -468,7 +468,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||
}
|
||||
|
||||
if (!cancelled && hasNewOptions) {
|
||||
setSelectOptions((prev) => ({ ...prev, ...loadedOptions }));
|
||||
setSelectOptions((prev) => {
|
||||
// 새로 로드된 옵션으로 항상 갱신 (카테고리 label 정보가 나중에 로드될 수 있으므로)
|
||||
// 로드 실패한 컬럼의 기존 옵션은 유지
|
||||
return { ...prev, ...loadedOptions };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
428
frontend/lib/tabStateCache.ts
Normal file
428
frontend/lib/tabStateCache.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* 탭별 상태를 sessionStorage에 캐싱/복원하는 엔진.
|
||||
* F5 새로고침 시 비활성 탭의 데이터를 보존한다.
|
||||
*
|
||||
* 캐싱 키 구조: `tab-cache-{tabId}`
|
||||
* 값: JSON 직렬화된 TabCacheData
|
||||
*/
|
||||
|
||||
const CACHE_PREFIX = "tab-cache-";
|
||||
|
||||
// --- 캐싱할 상태 구조 ---
|
||||
|
||||
export interface FormFieldSnapshot {
|
||||
idx: number;
|
||||
tag: string;
|
||||
type: string;
|
||||
name: string;
|
||||
id: string;
|
||||
value?: string;
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
/** 개별 스크롤 요소의 위치 스냅샷 (DOM 경로 기반) */
|
||||
export interface ScrollSnapshot {
|
||||
/** 탭 컨테이너 기준 자식 인덱스 경로 (예: "0/2/1/3") */
|
||||
path: string;
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export interface TabCacheData {
|
||||
/** DOM 폼 필드 스냅샷 (F5 복원용) */
|
||||
domFormFields?: FormFieldSnapshot[];
|
||||
|
||||
/** 다중 스크롤 위치 (split panel 등 여러 스크롤 영역 지원) */
|
||||
scrollPositions?: ScrollSnapshot[];
|
||||
|
||||
/** 캐싱 시각 */
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
// --- 공개 API ---
|
||||
|
||||
/**
|
||||
* 탭 상태를 sessionStorage에 즉시 저장
|
||||
*/
|
||||
export function saveTabCacheImmediate(tabId: string, data: Partial<Omit<TabCacheData, "cachedAt">>): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const key = CACHE_PREFIX + tabId;
|
||||
const current = loadTabCache(tabId);
|
||||
const merged: TabCacheData = {
|
||||
...current,
|
||||
...data,
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
sessionStorage.setItem(key, JSON.stringify(merged));
|
||||
} catch (e) {
|
||||
console.warn("[TabCache] 저장 실패:", tabId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 탭 상태를 sessionStorage에서 로드
|
||||
*/
|
||||
export function loadTabCache(tabId: string): TabCacheData | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
try {
|
||||
const key = CACHE_PREFIX + tabId;
|
||||
const raw = sessionStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as TabCacheData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 탭의 캐시 삭제
|
||||
*/
|
||||
export function clearTabCache(tabId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
sessionStorage.removeItem(CACHE_PREFIX + tabId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 탭 캐시 삭제
|
||||
*/
|
||||
export function clearAllTabCaches(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
try {
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key?.startsWith(CACHE_PREFIX)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOM 폼 상태 캡처/복원
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 컨테이너 내의 모든 폼 요소 상태를 스냅샷으로 캡처
|
||||
*/
|
||||
export function captureFormState(container: HTMLElement | null): FormFieldSnapshot[] | null {
|
||||
if (!container) return null;
|
||||
|
||||
const fields: FormFieldSnapshot[] = [];
|
||||
const elements = container.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||
"input, textarea, select",
|
||||
);
|
||||
|
||||
elements.forEach((el, idx) => {
|
||||
const field: FormFieldSnapshot = {
|
||||
idx,
|
||||
tag: el.tagName.toLowerCase(),
|
||||
type: (el as HTMLInputElement).type || "",
|
||||
name: el.name || "",
|
||||
id: el.id || "",
|
||||
};
|
||||
|
||||
if (el instanceof HTMLInputElement) {
|
||||
if (el.type === "checkbox" || el.type === "radio") {
|
||||
field.checked = el.checked;
|
||||
} else if (el.type !== "file" && el.type !== "password") {
|
||||
field.value = el.value;
|
||||
}
|
||||
} else if (el instanceof HTMLTextAreaElement) {
|
||||
field.value = el.value;
|
||||
} else if (el instanceof HTMLSelectElement) {
|
||||
field.value = el.value;
|
||||
}
|
||||
|
||||
fields.push(field);
|
||||
});
|
||||
|
||||
return fields.length > 0 ? fields : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 폼 필드에 값을 복원하고 React onChange를 트리거
|
||||
*/
|
||||
function applyFieldValue(el: Element, field: FormFieldSnapshot): void {
|
||||
if (el instanceof HTMLInputElement) {
|
||||
if (field.type === "checkbox" || field.type === "radio") {
|
||||
if (el.checked !== field.checked) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "checked")?.set;
|
||||
setter?.call(el, field.checked);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
} else if (field.value !== undefined && el.value !== field.value) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(el, field.value);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
} else if (el instanceof HTMLTextAreaElement) {
|
||||
if (field.value !== undefined && el.value !== field.value) {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
|
||||
setter?.call(el, field.value);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
} else if (el instanceof HTMLSelectElement) {
|
||||
if (field.value !== undefined && el.value !== field.value) {
|
||||
el.value = field.value;
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨테이너에서 스냅샷 필드에 해당하는 DOM 요소를 찾는다
|
||||
*/
|
||||
function findFieldElement(
|
||||
container: HTMLElement,
|
||||
field: FormFieldSnapshot,
|
||||
allElements: NodeListOf<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
|
||||
): Element | null {
|
||||
// 1순위: id로 검색
|
||||
if (field.id) {
|
||||
try {
|
||||
const el = container.querySelector(`#${CSS.escape(field.id)}`);
|
||||
if (el) return el;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// 2순위: name으로 검색 (유일한 경우)
|
||||
if (field.name) {
|
||||
try {
|
||||
const candidates = container.querySelectorAll(`[name="${CSS.escape(field.name)}"]`);
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// 3순위: 인덱스 + tag/type 일치 검증
|
||||
if (field.idx < allElements.length) {
|
||||
const candidate = allElements[field.idx];
|
||||
if (candidate.tagName.toLowerCase() === field.tag && ((candidate as HTMLInputElement).type || "") === field.type) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 캡처한 폼 스냅샷을 DOM에 복원하고 React onChange를 트리거.
|
||||
* 폼 필드가 아직 DOM에 없으면 폴링으로 대기한다.
|
||||
* 반환된 cleanup 함수를 호출하면 대기를 취소할 수 있다.
|
||||
*/
|
||||
export function restoreFormState(
|
||||
container: HTMLElement | null,
|
||||
fields: FormFieldSnapshot[] | null,
|
||||
): (() => void) | undefined {
|
||||
if (!container || !fields || fields.length === 0) return undefined;
|
||||
|
||||
let cleaned = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
clearInterval(pollId);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
const tryRestore = (): boolean => {
|
||||
const elements = container.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||
"input, textarea, select",
|
||||
);
|
||||
if (elements.length === 0) return false;
|
||||
|
||||
let restoredCount = 0;
|
||||
for (const field of fields) {
|
||||
const el = findFieldElement(container, field, elements);
|
||||
if (el) {
|
||||
applyFieldValue(el, field);
|
||||
restoredCount++;
|
||||
}
|
||||
}
|
||||
return restoredCount > 0;
|
||||
};
|
||||
|
||||
// 즉시 시도
|
||||
if (tryRestore()) return undefined;
|
||||
|
||||
// 다음 프레임에서 재시도
|
||||
requestAnimationFrame(() => {
|
||||
if (cleaned) return;
|
||||
if (tryRestore()) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// 폼 필드가 DOM에 나타날 때까지 폴링 (API 데이터 로드 대기)
|
||||
const pollId = setInterval(() => {
|
||||
if (tryRestore()) cleanup();
|
||||
}, 100);
|
||||
|
||||
// 최대 5초 대기 후 포기
|
||||
const timeoutId = setTimeout(() => {
|
||||
tryRestore();
|
||||
cleanup();
|
||||
}, 5000);
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DOM 경로 기반 스크롤 위치 캡처/복원 (다중 스크롤 영역 지원)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 컨테이너 기준 자식 인덱스 경로를 생성한다.
|
||||
* 예: container > div(2번째) > div(1번째) > div(3번째) → "2/1/3"
|
||||
*/
|
||||
export function getElementPath(element: HTMLElement, container: HTMLElement): string | null {
|
||||
const indices: number[] = [];
|
||||
let current: HTMLElement | null = element;
|
||||
|
||||
while (current && current !== container) {
|
||||
const parent: HTMLElement | null = current.parentElement;
|
||||
if (!parent) return null;
|
||||
|
||||
const children = parent.children;
|
||||
let idx = -1;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i] === current) {
|
||||
idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (idx === -1) return null;
|
||||
|
||||
indices.unshift(idx);
|
||||
current = parent;
|
||||
}
|
||||
|
||||
if (current !== container) return null;
|
||||
return indices.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로 문자열로 컨테이너 내의 요소를 찾는다.
|
||||
*/
|
||||
function findElementByPath(container: HTMLElement, path: string): HTMLElement | null {
|
||||
if (!path) return container;
|
||||
|
||||
const indices = path.split("/").map(Number);
|
||||
let current: HTMLElement = container;
|
||||
|
||||
for (const idx of indices) {
|
||||
if (!current.children || idx >= current.children.length) return null;
|
||||
const child = current.children[idx];
|
||||
if (!(child instanceof HTMLElement)) return null;
|
||||
current = child;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨테이너 하위의 모든 스크롤된 요소를 찾아 경로와 함께 캡처한다.
|
||||
* F5 직전 (beforeunload)에 호출 - 활성 탭은 display:block이므로 DOM 값이 정확하다.
|
||||
*/
|
||||
export function captureAllScrollPositions(container: HTMLElement | null): ScrollSnapshot[] | undefined {
|
||||
if (!container) return undefined;
|
||||
|
||||
const snapshots: ScrollSnapshot[] = [];
|
||||
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT);
|
||||
let node: Node | null;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.scrollTop > 0 || el.scrollLeft > 0) {
|
||||
const path = getElementPath(el, container);
|
||||
if (path) {
|
||||
snapshots.push({ path, top: el.scrollTop, left: el.scrollLeft });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return snapshots.length > 0 ? snapshots : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 스크롤 위치를 DOM 경로 기반으로 복원한다.
|
||||
* 컨텐츠가 아직 로드되지 않았을 수 있으므로 폴링으로 대기한다.
|
||||
*/
|
||||
export function restoreAllScrollPositions(
|
||||
container: HTMLElement | null,
|
||||
positions?: ScrollSnapshot[],
|
||||
): (() => void) | undefined {
|
||||
if (!container || !positions || positions.length === 0) return undefined;
|
||||
|
||||
let cleaned = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
clearInterval(pollId);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
const tryRestore = (): boolean => {
|
||||
let restoredCount = 0;
|
||||
|
||||
for (const pos of positions) {
|
||||
const el = findElementByPath(container, pos.path);
|
||||
if (!el) continue;
|
||||
|
||||
if (el.scrollHeight >= pos.top + el.clientHeight) {
|
||||
el.scrollTop = pos.top;
|
||||
el.scrollLeft = pos.left;
|
||||
restoredCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return restoredCount === positions.length;
|
||||
};
|
||||
|
||||
if (tryRestore()) return undefined;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (cleaned) return;
|
||||
if (tryRestore()) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const pollId = setInterval(() => {
|
||||
if (tryRestore()) cleanup();
|
||||
}, 50);
|
||||
|
||||
// 최대 5초 대기 후 강제 복원
|
||||
const timeoutId = setTimeout(() => {
|
||||
for (const pos of positions) {
|
||||
const el = findElementByPath(container, pos.path);
|
||||
if (el) {
|
||||
el.scrollTop = pos.top;
|
||||
el.scrollLeft = pos.left;
|
||||
}
|
||||
}
|
||||
cleanup();
|
||||
}, 5000);
|
||||
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
@@ -665,3 +665,4 @@ const calculateStringSimilarity = (str1: string, str2: string): number => {
|
||||
|
||||
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen;
|
||||
};
|
||||
|
||||
|
||||
472
frontend/package-lock.json
generated
472
frontend/package-lock.json
generated
@@ -72,6 +72,7 @@
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "^15.4.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
@@ -94,7 +95,8 @@
|
||||
"three": "^0.180.0",
|
||||
"uuid": "^13.0.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.5"
|
||||
"zod": "^4.1.5",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -1491,6 +1493,29 @@
|
||||
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-accessible-icon": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz",
|
||||
"integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-accordion": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
|
||||
@@ -1573,6 +1598,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-aspect-ratio": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz",
|
||||
"integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-avatar": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz",
|
||||
@@ -1891,6 +1939,65 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-form": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz",
|
||||
"integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-label": "2.1.7",
|
||||
"@radix-ui/react-primitive": "2.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-hover-card": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz",
|
||||
"integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-id": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||
@@ -1972,6 +2079,38 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menubar": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz",
|
||||
"integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-menu": "2.1.16",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-navigation-menu": {
|
||||
"version": "1.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",
|
||||
@@ -2008,6 +2147,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-one-time-password-field": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz",
|
||||
"integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-is-hydrated": "0.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-password-toggle-field": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz",
|
||||
"integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-is-hydrated": "0.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz",
|
||||
@@ -2332,6 +2535,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
@@ -2409,6 +2645,157 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toast": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz",
|
||||
"integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle": {
|
||||
"version": "1.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||
"integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toggle-group": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz",
|
||||
"integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toolbar": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz",
|
||||
"integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-toggle-group": "1.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -13126,6 +13513,83 @@
|
||||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/radix-ui": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||
"integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-accessible-icon": "1.1.7",
|
||||
"@radix-ui/react-accordion": "1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "1.1.15",
|
||||
"@radix-ui/react-arrow": "1.1.7",
|
||||
"@radix-ui/react-aspect-ratio": "1.1.7",
|
||||
"@radix-ui/react-avatar": "1.1.10",
|
||||
"@radix-ui/react-checkbox": "1.3.3",
|
||||
"@radix-ui/react-collapsible": "1.1.12",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-context-menu": "2.2.16",
|
||||
"@radix-ui/react-dialog": "1.1.15",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.16",
|
||||
"@radix-ui/react-focus-guards": "1.1.3",
|
||||
"@radix-ui/react-focus-scope": "1.1.7",
|
||||
"@radix-ui/react-form": "0.1.8",
|
||||
"@radix-ui/react-hover-card": "1.1.15",
|
||||
"@radix-ui/react-label": "2.1.7",
|
||||
"@radix-ui/react-menu": "2.1.16",
|
||||
"@radix-ui/react-menubar": "1.1.16",
|
||||
"@radix-ui/react-navigation-menu": "1.2.14",
|
||||
"@radix-ui/react-one-time-password-field": "0.1.8",
|
||||
"@radix-ui/react-password-toggle-field": "0.1.3",
|
||||
"@radix-ui/react-popover": "1.1.15",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-progress": "1.1.7",
|
||||
"@radix-ui/react-radio-group": "1.3.8",
|
||||
"@radix-ui/react-roving-focus": "1.1.11",
|
||||
"@radix-ui/react-scroll-area": "1.2.10",
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slider": "1.3.6",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-switch": "1.2.6",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-toast": "1.2.15",
|
||||
"@radix-ui/react-toggle": "1.1.10",
|
||||
"@radix-ui/react-toggle-group": "1.1.11",
|
||||
"@radix-ui/react-toolbar": "1.1.11",
|
||||
"@radix-ui/react-tooltip": "1.2.8",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.1",
|
||||
"@radix-ui/react-use-is-hydrated": "0.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
@@ -15843,9 +16307,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
|
||||
@@ -81,6 +81,7 @@
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "^15.4.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
@@ -103,7 +104,8 @@
|
||||
"three": "^0.180.0",
|
||||
"uuid": "^13.0.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^4.1.5"
|
||||
"zod": "^4.1.5",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
224
frontend/stores/tabStore.ts
Normal file
224
frontend/stores/tabStore.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { devtools, persist } from "zustand/middleware";
|
||||
import { clearTabCache } from "@/lib/tabStateCache";
|
||||
|
||||
// --- 타입 정의 ---
|
||||
|
||||
export type AppMode = "user" | "admin";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
type: "screen" | "admin";
|
||||
title: string;
|
||||
screenId?: number;
|
||||
menuObjid?: number;
|
||||
adminUrl?: string;
|
||||
}
|
||||
|
||||
interface ModeTabData {
|
||||
tabs: Tab[];
|
||||
activeTabId: string | null;
|
||||
}
|
||||
|
||||
interface TabState {
|
||||
mode: AppMode;
|
||||
user: ModeTabData;
|
||||
admin: ModeTabData;
|
||||
refreshKeys: Record<string, number>;
|
||||
|
||||
setMode: (mode: AppMode) => void;
|
||||
|
||||
openTab: (tab: Omit<Tab, "id">, insertIndex?: number) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
switchTab: (tabId: string) => void;
|
||||
refreshTab: (tabId: string) => void;
|
||||
|
||||
closeOtherTabs: (tabId: string) => void;
|
||||
closeTabsToLeft: (tabId: string) => void;
|
||||
closeTabsToRight: (tabId: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
|
||||
updateTabOrder: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
// --- 헬퍼 함수 ---
|
||||
|
||||
function generateTabId(tab: Omit<Tab, "id">): string {
|
||||
if (tab.type === "screen" && tab.screenId != null) {
|
||||
return `tab-screen-${tab.screenId}-${tab.menuObjid ?? 0}`;
|
||||
}
|
||||
if (tab.type === "admin" && tab.adminUrl) {
|
||||
return `tab-admin-${tab.adminUrl.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
||||
}
|
||||
return `tab-${Date.now()}`;
|
||||
}
|
||||
|
||||
function findDuplicateTab(tabs: Tab[], newTab: Omit<Tab, "id">): Tab | undefined {
|
||||
if (newTab.type === "screen" && newTab.screenId != null) {
|
||||
return tabs.find(
|
||||
(t) => t.type === "screen" && t.screenId === newTab.screenId && t.menuObjid === newTab.menuObjid,
|
||||
);
|
||||
}
|
||||
if (newTab.type === "admin" && newTab.adminUrl) {
|
||||
return tabs.find((t) => t.type === "admin" && t.adminUrl === newTab.adminUrl);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getNextActiveTabId(tabs: Tab[], closedTabId: string, currentActiveId: string | null): string | null {
|
||||
if (currentActiveId !== closedTabId) return currentActiveId;
|
||||
const idx = tabs.findIndex((t) => t.id === closedTabId);
|
||||
if (idx === -1) return null;
|
||||
const remaining = tabs.filter((t) => t.id !== closedTabId);
|
||||
if (remaining.length === 0) return null;
|
||||
if (idx > 0) return remaining[Math.min(idx - 1, remaining.length - 1)].id;
|
||||
return remaining[0].id;
|
||||
}
|
||||
|
||||
// 현재 모드의 데이터 키 반환
|
||||
function modeKey(state: TabState): AppMode {
|
||||
return state.mode;
|
||||
}
|
||||
|
||||
// --- 셀렉터 (컴포넌트에서 사용) ---
|
||||
|
||||
export function selectTabs(state: TabState): Tab[] {
|
||||
return state[state.mode].tabs;
|
||||
}
|
||||
|
||||
export function selectActiveTabId(state: TabState): string | null {
|
||||
return state[state.mode].activeTabId;
|
||||
}
|
||||
|
||||
// --- Store ---
|
||||
|
||||
const EMPTY_MODE: ModeTabData = { tabs: [], activeTabId: null };
|
||||
|
||||
export const useTabStore = create<TabState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
mode: "user" as AppMode,
|
||||
user: { ...EMPTY_MODE },
|
||||
admin: { ...EMPTY_MODE },
|
||||
refreshKeys: {},
|
||||
|
||||
setMode: (mode) => {
|
||||
set({ mode });
|
||||
},
|
||||
|
||||
openTab: (tabData, insertIndex) => {
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
const existing = findDuplicateTab(modeData.tabs, tabData);
|
||||
|
||||
if (existing) {
|
||||
set({ [mk]: { ...modeData, activeTabId: existing.id } });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = generateTabId(tabData);
|
||||
const newTab: Tab = { ...tabData, id };
|
||||
const newTabs = [...modeData.tabs];
|
||||
|
||||
if (insertIndex != null && insertIndex >= 0 && insertIndex <= newTabs.length) {
|
||||
newTabs.splice(insertIndex, 0, newTab);
|
||||
} else {
|
||||
newTabs.push(newTab);
|
||||
}
|
||||
|
||||
set({ [mk]: { tabs: newTabs, activeTabId: id } });
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
clearTabCache(tabId);
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
const nextActive = getNextActiveTabId(modeData.tabs, tabId, modeData.activeTabId);
|
||||
const newTabs = modeData.tabs.filter((t) => t.id !== tabId);
|
||||
const { [tabId]: _, ...restKeys } = get().refreshKeys;
|
||||
set({ [mk]: { tabs: newTabs, activeTabId: nextActive }, refreshKeys: restKeys });
|
||||
},
|
||||
|
||||
switchTab: (tabId) => {
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
set({ [mk]: { ...modeData, activeTabId: tabId } });
|
||||
},
|
||||
|
||||
refreshTab: (tabId) => {
|
||||
set((state) => ({
|
||||
refreshKeys: { ...state.refreshKeys, [tabId]: (state.refreshKeys[tabId] || 0) + 1 },
|
||||
}));
|
||||
},
|
||||
|
||||
closeOtherTabs: (tabId) => {
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
modeData.tabs.filter((t) => t.id !== tabId).forEach((t) => clearTabCache(t.id));
|
||||
set({ [mk]: { tabs: modeData.tabs.filter((t) => t.id === tabId), activeTabId: tabId } });
|
||||
},
|
||||
|
||||
closeTabsToLeft: (tabId) => {
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
const idx = modeData.tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
modeData.tabs.slice(0, idx).forEach((t) => clearTabCache(t.id));
|
||||
set({ [mk]: { tabs: modeData.tabs.slice(idx), activeTabId: tabId } });
|
||||
},
|
||||
|
||||
closeTabsToRight: (tabId) => {
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
const idx = modeData.tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
modeData.tabs.slice(idx + 1).forEach((t) => clearTabCache(t.id));
|
||||
set({ [mk]: { tabs: modeData.tabs.slice(0, idx + 1), activeTabId: tabId } });
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
modeData.tabs.forEach((t) => clearTabCache(t.id));
|
||||
set({ [mk]: { tabs: [], activeTabId: null } });
|
||||
},
|
||||
|
||||
updateTabOrder: (fromIndex, toIndex) => {
|
||||
const mk = modeKey(get());
|
||||
const modeData = get()[mk];
|
||||
const newTabs = [...modeData.tabs];
|
||||
const [moved] = newTabs.splice(fromIndex, 1);
|
||||
newTabs.splice(toIndex, 0, moved);
|
||||
set({ [mk]: { ...modeData, tabs: newTabs } });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "erp-tab-store",
|
||||
storage: {
|
||||
getItem: (name) => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = sessionStorage.getItem(name);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
},
|
||||
setItem: (name, value) => {
|
||||
if (typeof window === "undefined") return;
|
||||
sessionStorage.setItem(name, JSON.stringify(value));
|
||||
},
|
||||
removeItem: (name) => {
|
||||
if (typeof window === "undefined") return;
|
||||
sessionStorage.removeItem(name);
|
||||
},
|
||||
},
|
||||
partialize: (state) => ({
|
||||
mode: state.mode,
|
||||
user: state.user,
|
||||
admin: state.admin,
|
||||
}) as unknown as TabState,
|
||||
},
|
||||
),
|
||||
{ name: "TabStore" },
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user