Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node

This commit is contained in:
kmh
2026-03-10 16:20:57 +09:00
63 changed files with 6657 additions and 771 deletions

View 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>
);
}

View 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);
}
}

View 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 },
};
}

View 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]);
}

View 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;
}

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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"; // 품목별 라우팅
/**
* 컴포넌트 초기화 함수

View File

@@ -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 "-";

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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,

View File

@@ -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 "-";

View File

@@ -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);
}

View File

@@ -468,7 +468,11 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
}
if (!cancelled && hasNewOptions) {
setSelectOptions((prev) => ({ ...prev, ...loadedOptions }));
setSelectOptions((prev) => {
// 새로 로드된 옵션으로 항상 갱신 (카테고리 label 정보가 나중에 로드될 수 있으므로)
// 로드 실패한 컬럼의 기존 옵션은 유지
return { ...prev, ...loadedOptions };
});
}
};

View 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;
}

View File

@@ -665,3 +665,4 @@ const calculateStringSimilarity = (str1: string, str2: string): number => {
return maxLen === 0 ? 1 : (maxLen - distance) / maxLen;
};