"use client"; /** * pop-card-list 런타임 컴포넌트 * * 테이블의 각 행이 하나의 카드로 표시됩니다. * 카드 구조: * - 헤더: 코드 + 제목 * - 본문: 이미지(왼쪽) + 라벨-값 목록(오른쪽) */ import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useRouter } from "next/navigation"; import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import type { PopCardListConfig, CardTemplateConfig, CardFieldBinding, CardInputFieldConfig, CardCartActionConfig, CardPackageConfig, CardPresetSpec, CartItem, PackageEntry, } from "../types"; import { DEFAULT_CARD_IMAGE, CARD_PRESET_SPECS, } from "../types"; import { dataApi } from "@/lib/api/data"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { NumberInputModal } from "./NumberInputModal"; const LUCIDE_ICON_MAP: Record = { ShoppingCart, Package, Truck, Box, Archive, Heart, Star, }; function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { if (!name) return ; const IconComp = LUCIDE_ICON_MAP[name]; if (!IconComp) return ; return ; } // 마퀴 애니메이션 keyframes (한 번만 삽입) const MARQUEE_STYLE_ID = "pop-card-marquee-style"; if (typeof document !== "undefined" && !document.getElementById(MARQUEE_STYLE_ID)) { const style = document.createElement("style"); style.id = MARQUEE_STYLE_ID; style.textContent = ` @keyframes pop-marquee { 0%, 15% { transform: translateX(0); } 85%, 100% { transform: translateX(var(--marquee-offset)); } } `; document.head.appendChild(style); } // 텍스트가 컨테이너보다 넓을 때 자동 슬라이딩하는 컴포넌트 function MarqueeText({ children, className, style, }: { children: React.ReactNode; className?: string; style?: React.CSSProperties; }) { const containerRef = useRef(null); const textRef = useRef(null); const [overflowPx, setOverflowPx] = useState(0); const measure = useCallback(() => { const container = containerRef.current; const text = textRef.current; if (!container || !text) return; const diff = text.scrollWidth - container.clientWidth; setOverflowPx(diff > 1 ? diff : 0); }, []); useEffect(() => { measure(); }, [children, measure]); useEffect(() => { const container = containerRef.current; if (!container) return; const ro = new ResizeObserver(() => measure()); ro.observe(container); return () => ro.disconnect(); }, [measure]); return (
0 ? { ["--marquee-offset" as string]: `-${overflowPx}px`, animation: "pop-marquee 5s ease-in-out infinite alternate", } : undefined } > {children}
); } interface PopCardListComponentProps { config?: PopCardListConfig; className?: string; screenId?: string; // 동적 크기 변경을 위한 props (PopRenderer에서 전달) componentId?: string; currentRowSpan?: number; currentColSpan?: number; onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; } // 테이블 행 데이터 타입 type RowData = Record; // 카드 내부 스타일 규격 (프리셋에서 매핑) interface ScaledConfig { cardHeight: number; cardWidth: number; imageSize: number; padding: number; gap: number; headerPaddingX: number; headerPaddingY: number; codeTextSize: number; titleTextSize: number; bodyTextSize: number; } export function PopCardListComponent({ config, className, screenId, componentId, currentRowSpan, currentColSpan, onRequestResize, }: PopCardListComponentProps) { const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal"; const maxGridColumns = config?.gridColumns || 2; const configGridRows = config?.gridRows || 3; const dataSource = config?.dataSource; const template = config?.cardTemplate; const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default"); const router = useRouter(); // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합) const [externalFilters, setExternalFilters] = useState< Map >(new Map()); useEffect(() => { if (!componentId) return; const unsub = subscribe( `__comp_input__${componentId}__filter_condition`, (payload: unknown) => { const data = payload as { value?: { fieldName?: string; value?: unknown }; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string }; _connectionId?: string; }; const connId = data?._connectionId || "default"; setExternalFilters(prev => { const next = new Map(prev); if (data?.value?.value) { next.set(connId, { fieldName: data.value.fieldName || "", value: data.value.value, filterConfig: data.filterConfig, }); } else { next.delete(connId); } return next; }); } ); return unsub; }, [componentId, subscribe]); // 카드 선택 시 selected_row 이벤트 발행 const handleCardSelect = useCallback((row: RowData) => { if (!componentId) return; publish(`__comp_output__${componentId}__selected_row`, row); }, [componentId, publish]); // 확장/페이지네이션 상태 const [isExpanded, setIsExpanded] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [originalRowSpan, setOriginalRowSpan] = useState(null); // 컨테이너 ref + 크기 측정 const containerRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const baseContainerHeight = useRef(0); useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; if (width > 0) setContainerWidth(width); if (height > 0) setContainerHeight(height); }); observer.observe(containerRef.current); return () => observer.disconnect(); }, []); // 이미지 URL 없는 항목 카운트 (toast 중복 방지용) const missingImageCountRef = useRef(0); const toastShownRef = useRef(false); const cardSizeKey = config?.cardSize || "large"; const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열 const maxAllowedColumns = useMemo(() => { if (!currentColSpan) return maxGridColumns; if (currentColSpan >= 8) return maxGridColumns; return 1; }, [currentColSpan, maxGridColumns]); // 카드 최소 너비 기준으로 컨테이너에 들어갈 수 있는 열 개수 자동 계산 const minCardWidth = Math.round(spec.height * 1.6); const autoColumns = containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap))) : maxGridColumns; const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns); // 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험) const gridRows = configGridRows; // 카드 크기: 높이는 프리셋 고정, 너비만 컨테이너 기반 동적 계산 // (높이를 containerHeight에 연동하면 뷰어 모드의 minmax(auto) 그리드와 // ResizeObserver 사이에서 피드백 루프가 발생해 무한 성장함) const scaled = useMemo((): ScaledConfig => { const gap = spec.gap; const cardHeight = spec.height; const minCardWidth = Math.round(spec.height * 1.6); const cardWidth = containerWidth > 0 ? Math.max(minCardWidth, Math.floor((containerWidth - gap * (gridColumns - 1)) / gridColumns)) : minCardWidth; return { cardHeight, cardWidth, imageSize: spec.imageSize, padding: spec.padding, gap, headerPaddingX: spec.headerPadX, headerPaddingY: spec.headerPadY, codeTextSize: spec.codeText, titleTextSize: spec.titleText, bodyTextSize: spec.bodyText, }; }, [spec, containerWidth, gridColumns]); // 외부 필터 적용 (복수 필터 AND 결합) const filteredRows = useMemo(() => { if (externalFilters.size === 0) return rows; const matchSingleFilter = ( row: RowData, filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } ): boolean => { const searchValue = String(filter.value).toLowerCase(); if (!searchValue) return true; const fc = filter.filterConfig; const columns: string[] = fc?.targetColumns?.length ? fc.targetColumns : fc?.targetColumn ? [fc.targetColumn] : filter.fieldName ? [filter.fieldName] : []; if (columns.length === 0) return true; const mode = fc?.filterMode || "contains"; const matchCell = (cellValue: string) => { switch (mode) { case "equals": return cellValue === searchValue; case "starts_with": return cellValue.startsWith(searchValue); case "contains": default: return cellValue.includes(searchValue); } }; return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase())); }; const allFilters = [...externalFilters.values()]; return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f))); }, [rows, externalFilters]); // 기본 상태에서 표시할 카드 수 const visibleCardCount = useMemo(() => { return gridColumns * gridRows; }, [gridColumns, gridRows]); // 더보기 버튼 표시 여부 const hasMoreCards = filteredRows.length > visibleCardCount; // 확장 상태에서 표시할 카드 수 계산 const expandedCardsPerPage = useMemo(() => { // 가로/세로 모두: 기본 표시 수의 2배 + 스크롤 유도를 위해 1줄 추가 // 가로: 컴포넌트 크기 변경 없이 카드 2배 → 가로 스크롤로 탐색 // 세로: rowSpan 2배 → 2배 영역에 카드 채움 + 세로 스크롤 return Math.max(1, visibleCardCount * 2 + gridColumns); }, [visibleCardCount, gridColumns]); // 스크롤 영역 ref const scrollAreaRef = useRef(null); // 현재 표시할 카드 결정 const displayCards = useMemo(() => { if (!isExpanded) { return filteredRows.slice(0, visibleCardCount); } else { const start = (currentPage - 1) * expandedCardsPerPage; const end = start + expandedCardsPerPage; return filteredRows.slice(start, end); } }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); // 총 페이지 수 const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; // 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때 const needsPagination = isExpanded && totalPages > 1; // 페이지 변경 핸들러 const handlePrevPage = () => { if (currentPage > 1) { setCurrentPage(currentPage - 1); } }; const handleNextPage = () => { if (currentPage < totalPages) { setCurrentPage(currentPage + 1); } }; // 확장/접기 토글: 세로 모드에서만 rowSpan 2배 확장, 가로 모드에서는 크기 변경 없이 카드만 추가 표시 const toggleExpand = () => { if (isExpanded) { if (!isHorizontalMode && originalRowSpan !== null && componentId && onRequestResize) { onRequestResize(componentId, originalRowSpan); } setCurrentPage(1); setOriginalRowSpan(null); baseContainerHeight.current = 0; setIsExpanded(false); } else { baseContainerHeight.current = containerHeight; if (!isHorizontalMode && componentId && onRequestResize && currentRowSpan !== undefined) { setOriginalRowSpan(currentRowSpan); onRequestResize(componentId, currentRowSpan * 2); } setIsExpanded(true); } }; // 페이지 변경 시 스크롤 위치 초기화 (가로/세로 모두) useEffect(() => { if (scrollAreaRef.current && isExpanded) { scrollAreaRef.current.scrollTop = 0; scrollAreaRef.current.scrollLeft = 0; } }, [currentPage, isExpanded]); // dataSource를 직렬화해서 의존성 안정화 (객체 참조 변경에 의한 불필요한 재호출 방지) const dataSourceKey = useMemo( () => JSON.stringify(dataSource || null), [dataSource] ); useEffect(() => { if (!dataSource?.tableName) { setLoading(false); setRows([]); return; } const fetchData = async () => { setLoading(true); setError(null); missingImageCountRef.current = 0; toastShownRef.current = false; try { const filters: Record = {}; if (dataSource.filters && dataSource.filters.length > 0) { dataSource.filters.forEach((f) => { if (f.column && f.value) { filters[f.column] = f.value; } }); } // 다중 정렬: 첫 번째 기준을 서버 정렬로 전달 (하위 호환: 단일 객체도 처리) const sortArray = Array.isArray(dataSource.sort) ? dataSource.sort : dataSource.sort && typeof dataSource.sort === "object" ? [dataSource.sort as { column: string; direction: "asc" | "desc" }] : []; const primarySort = sortArray.length > 0 ? sortArray[0] : undefined; const sortBy = primarySort?.column; const sortOrder = primarySort?.direction; const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100; const result = await dataApi.getTableData(dataSource.tableName, { page: 1, size, sortBy: sortBy || undefined, sortOrder, filters: Object.keys(filters).length > 0 ? filters : undefined, }); setRows(result.data || []); } catch (err) { const message = err instanceof Error ? err.message : "데이터 조회 실패"; setError(message); setRows([]); } finally { setLoading(false); } }; fetchData(); }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps // 이미지 URL 없는 항목 체크 및 toast 표시 useEffect(() => { if ( !loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn && !toastShownRef.current ) { const imageColumn = template.image.imageColumn; const missingCount = rows.filter((row) => !row[imageColumn]).length; if (missingCount > 0) { missingImageCountRef.current = missingCount; toastShownRef.current = true; toast.warning( `${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다` ); } } }, [loading, rows, template?.image]); // 카드 영역 스타일 const cardAreaStyle: React.CSSProperties = { gap: `${scaled.gap}px`, ...(isHorizontalMode ? { gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`, gridAutoFlow: "column", gridAutoColumns: `${scaled.cardWidth}px`, } : { // 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지 gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, gridAutoRows: `${scaled.cardHeight}px`, }), }; // 세로 모드 스크롤 클래스: 비확장 시 overflow hidden, 확장 시에만 세로 스크롤 허용 const scrollClassName = isHorizontalMode ? "overflow-x-auto overflow-y-hidden" : isExpanded ? "overflow-y-auto overflow-x-hidden" : "overflow-hidden"; return (
{!dataSource?.tableName ? (

데이터 소스를 설정해주세요.

) : loading ? (
) : error ? (

{error}

) : rows.length === 0 ? (

데이터가 없습니다.

) : ( <> {/* 카드 영역 (스크롤 가능) */}
{displayCards.map((row, index) => { const rowKey = template?.header?.codeField && row[template.header.codeField] ? String(row[template.header.codeField]) : `card-${index}`; return ( ); })}
{/* 하단 컨트롤 영역 */} {hasMoreCards && (
{filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""}
{isExpanded && needsPagination && (
{currentPage} / {totalPages}
)}
)} )}
); } // ===== 카드 컴포넌트 ===== function Card({ row, template, scaled, inputField, packageConfig, cartAction, publish, getSharedData, setSharedData, router, onSelect, }: { row: RowData; template?: CardTemplateConfig; scaled: ScaledConfig; inputField?: CardInputFieldConfig; packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; publish: (eventName: string, payload?: unknown) => void; getSharedData: (key: string) => T | undefined; setSharedData: (key: string, value: unknown) => void; router: ReturnType; onSelect?: (row: RowData) => void; }) { const header = template?.header; const image = template?.image; const body = template?.body; const [inputValue, setInputValue] = useState(0); const [packageUnit, setPackageUnit] = useState(undefined); const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const [isCarted, setIsCarted] = useState(false); const codeValue = header?.codeField ? row[header.codeField] : null; const titleValue = header?.titleField ? row[header.titleField] : null; const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] ? String(row[image.imageColumn]) : image?.defaultImage || DEFAULT_CARD_IMAGE; // limitColumn 우선, 하위 호환으로 maxColumn 폴백 const limitCol = inputField?.limitColumn || inputField?.maxColumn; const effectiveMax = useMemo(() => { if (limitCol) { const colVal = Number(row[limitCol]); if (!isNaN(colVal) && colVal > 0) return colVal; } return 999999; }, [limitCol, row]); // 제한 컬럼이 있으면 최대값으로 자동 초기화 useEffect(() => { if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { setInputValue(effectiveMax); } }, [effectiveMax, inputField?.enabled, limitCol]); const cardStyle: React.CSSProperties = { height: `${scaled.cardHeight}px`, overflow: "hidden", }; const headerStyle: React.CSSProperties = { padding: `${scaled.headerPaddingY}px ${scaled.headerPaddingX}px`, }; const bodyStyle: React.CSSProperties = { padding: `${scaled.padding}px`, gap: `${scaled.gap}px`, }; const imageContainerStyle: React.CSSProperties = { width: `${scaled.imageSize}px`, height: `${scaled.imageSize}px`, }; const handleInputClick = (e: React.MouseEvent) => { e.stopPropagation(); setIsModalOpen(true); }; const handleInputConfirm = (value: number, unit?: string, entries?: PackageEntry[]) => { setInputValue(value); setPackageUnit(unit); setPackageEntries(entries || []); }; // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 const handleCartAdd = () => { const cartItem: CartItem = { row, quantity: inputValue, packageUnit: packageUnit || undefined, packageEntries: packageEntries.length > 0 ? packageEntries : undefined, }; const existing = getSharedData("cart_items") || []; setSharedData("cart_items", [...existing, cartItem]); publish("cart_item_added", cartItem); setIsCarted(true); toast.success("장바구니에 담겼습니다."); if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) { router.push(`/pop/screens/${cartAction.targetScreenId}`); } }; // 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원 const handleCartCancel = () => { const existing = getSharedData("cart_items") || []; const rowKey = JSON.stringify(row); const filtered = existing.filter( (item) => JSON.stringify(item.row) !== rowKey ); setSharedData("cart_items", filtered); publish("cart_item_removed", { row }); setIsCarted(false); toast.info("장바구니에서 제거되었습니다."); }; // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11))); const cartLabel = cartAction?.label || "담기"; const cancelLabel = cartAction?.cancelLabel || "취소"; const handleCardClick = () => { onSelect?.(row); }; return (
{ if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > {/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && (
{codeValue !== null && ( {formatValue(codeValue)} )} {titleValue !== null && ( {formatValue(titleValue)} )}
)} {/* 본문 영역 */}
{/* 이미지 (왼쪽) */} {image?.enabled && (
{ const target = e.target as HTMLImageElement; if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE; }} />
)} {/* 필드 목록 (중간, flex-1) */}
{body?.fields && body.fields.length > 0 ? ( body.fields.map((field) => ( )) ) : (
본문 필드를 추가하세요
)}
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */} {(inputField?.enabled || cartAction) && (
{/* 수량 버튼 (입력 필드 ON일 때만) */} {inputField?.enabled && ( )} {/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */} {cartAction && ( <> {isCarted ? ( ) : ( )} )}
)}
{inputField?.enabled && ( )}
); } // ===== 필드 행 컴포넌트 ===== function FieldRow({ field, row, scaled, inputValue, }: { field: CardFieldBinding; row: RowData; scaled: ScaledConfig; inputValue?: number; }) { const valueType = field.valueType || "column"; const displayValue = useMemo(() => { if (valueType !== "formula") { return formatValue(field.columnName ? row[field.columnName] : undefined); } // 구조화된 수식 우선 if (field.formulaLeft && field.formulaOperator) { const rightVal = field.formulaRightType === "input" ? (inputValue ?? 0) : Number(row[field.formulaRight || ""] ?? 0); const leftVal = Number(row[field.formulaLeft] ?? 0); let result: number | null = null; switch (field.formulaOperator) { case "+": result = leftVal + rightVal; break; case "-": result = leftVal - rightVal; break; case "*": result = leftVal * rightVal; break; case "/": result = rightVal !== 0 ? leftVal / rightVal : null; break; } if (result !== null && isFinite(result)) { const formatted = Math.round(result * 100) / 100; return field.unit ? `${formatted.toLocaleString()} ${field.unit}` : formatted.toLocaleString(); } return "-"; } // 하위 호환: 레거시 formula 문자열 if (field.formula) { const result = evaluateFormula(field.formula, row, inputValue ?? 0); if (result !== null) { const formatted = result.toLocaleString(); return field.unit ? `${formatted} ${field.unit}` : formatted; } } return "-"; }, [valueType, field, row, inputValue]); const isFormula = valueType === "formula"; const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12)); return (
{field.label} {displayValue}
); } // ===== 값 포맷팅 ===== function formatValue(value: unknown): string { if (value === null || value === undefined) { return "-"; } if (typeof value === "number") { return value.toLocaleString(); } if (typeof value === "boolean") { return value ? "예" : "아니오"; } if (value instanceof Date) { return value.toLocaleDateString(); } // ISO 날짜 문자열 감지 및 포맷 if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { const date = new Date(value); if (!isNaN(date.getTime())) { // MM-DD 형식으로 표시 return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } } return String(value); } // ===== 계산식 평가 ===== /** * 간단한 계산식을 평가합니다. * 지원 연산: +, -, *, / * 특수 변수: $input (입력 필드 값) * * @param formula 계산식 (예: "order_qty - inbound_qty", "$input - received_qty") * @param row 데이터 행 * @param inputValue 입력 필드 값 * @returns 계산 결과 또는 null (계산 실패 시) */ function evaluateFormula( formula: string, row: RowData, inputValue: number ): number | null { try { // 수식에서 컬럼명과 $input을 실제 값으로 치환 let expression = formula; // $input을 입력값으로 치환 expression = expression.replace(/\$input/g, String(inputValue)); // 컬럼명을 값으로 치환 (알파벳, 숫자, 언더스코어로 구성된 식별자) const columnPattern = /[a-zA-Z_][a-zA-Z0-9_]*/g; expression = expression.replace(columnPattern, (match) => { // 이미 숫자로 치환된 경우 스킵 if (/^\d+$/.test(match)) return match; const value = row[match]; if (value === null || value === undefined) return "0"; if (typeof value === "number") return String(value); const parsed = parseFloat(String(value)); return isNaN(parsed) ? "0" : String(parsed); }); // 안전한 계산 (기본 산술 연산만 허용) // 허용: 숫자, +, -, *, /, (, ), 공백, 소수점 if (!/^[\d\s+\-*/().]+$/.test(expression)) { return null; } // eval 대신 Function 사용 (더 안전) const result = new Function(`return (${expression})`)(); if (typeof result !== "number" || isNaN(result) || !isFinite(result)) { return null; } return Math.round(result * 100) / 100; // 소수점 2자리까지 } catch (error) { // 수식 평가 실패 시 null 반환 return null; } }