Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user