"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, Trash2, 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, CartListModeConfig, } from "../types"; import { DEFAULT_CARD_IMAGE, CARD_PRESET_SPECS, } from "../types"; import { dataApi } from "@/lib/api/data"; import { screenApi } from "@/lib/api/screen"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; 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}
); } // cart_items 행의 row_data JSON을 풀어서 __cart_ 접두사 메타데이터와 병합 function parseCartRow(dbRow: Record): Record { let rowData: Record = {}; try { const raw = dbRow.row_data; if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw); else if (typeof raw === "object" && raw !== null) rowData = raw as Record; } catch { rowData = {}; } return { ...rowData, __cart_id: dbRow.id, __cart_quantity: Number(dbRow.quantity) || 0, __cart_package_unit: dbRow.package_unit || "", __cart_package_entries: dbRow.package_entries, __cart_status: dbRow.status || "in_cart", __cart_memo: dbRow.memo || "", __cart_row_key: dbRow.row_key || "", __cart_modified: false, }; } 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 } = usePopEvent(screenId || "default"); const router = useRouter(); // 장바구니 DB 동기화 const sourceTableName = dataSource?.tableName || ""; const cartType = config?.cartAction?.cartType; const cart = useCartSync(screenId || "", sourceTableName, cartType); // 장바구니 목록 모드 플래그 및 상태 const isCartListMode = config?.cartListMode?.enabled === true; const [inheritedTemplate, setInheritedTemplate] = useState(null); const [selectedKeys, setSelectedKeys] = useState>(new Set()); // 장바구니 목록 모드에서는 원본 화면의 템플릿을 사용, 폴백으로 자체 설정 const effectiveTemplate = (isCartListMode && inheritedTemplate) ? inheritedTemplate : template; // 데이터 상태 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]); // cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용 const cartRef = useRef(cart); cartRef.current = cart; // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 (장바구니 목록 모드 제외) useEffect(() => { if (!componentId || isCartListMode) return; const unsub = subscribe( `__comp_input__${componentId}__cart_save_trigger`, async (payload: unknown) => { const data = payload as { value?: { selectedColumns?: string[] } } | undefined; const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns); publish(`__comp_output__${componentId}__cart_save_completed`, { success: ok, }); } ); return unsub; }, [componentId, subscribe, publish, isCartListMode]); // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 (장바구니 목록 모드 제외) useEffect(() => { if (!componentId || cart.loading || isCartListMode) return; publish(`__comp_output__${componentId}__cart_updated`, { count: cart.cartCount, isDirty: cart.isDirty, }); }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]); // 카드 선택 시 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 entry = entries[0]; if (!entry) return; const { width, height } = entry.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 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.max(1, 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] ); // 장바구니 목록 모드 설정을 직렬화 (의존성 안정화) const cartListModeKey = useMemo( () => JSON.stringify(config?.cartListMode || null), [config?.cartListMode] ); useEffect(() => { // 장바구니 목록 모드: cart_items에서 직접 조회 if (isCartListMode) { const cartListMode = config!.cartListMode!; // 설정 미완료 시 데이터 조회하지 않음 if (!cartListMode.sourceScreenId || !cartListMode.cartType) { setLoading(false); setRows([]); return; } const fetchCartData = async () => { setLoading(true); setError(null); try { // 원본 화면 레이아웃에서 cardTemplate 상속 if (cartListMode.sourceScreenId) { try { const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); const components = layoutJson?.components || []; const matched = components.find( (c: any) => c.type === "pop-card-list" && c.props?.cartAction?.cartType === cartListMode.cartType ); if (matched?.props?.cardTemplate) { setInheritedTemplate(matched.props.cardTemplate); } } catch { // 레이아웃 로드 실패 시 config.cardTemplate 폴백 } } // cart_items 조회 const result = await dataApi.getTableData("cart_items", { size: 500, filters: { cart_type: cartListMode.cartType, status: cartListMode.statusFilter || "in_cart", }, }); const parsed = (result.data || []).map(parseCartRow); setRows(parsed); } catch (err) { const message = err instanceof Error ? err.message : "장바구니 데이터 조회 실패"; setError(message); setRows([]); } finally { setLoading(false); } }; fetchCartData(); return; } // 기본 모드: 데이터 소스에서 조회 if (!dataSource?.tableName) { setLoading(false); setRows([]); return; } const fetchData = async () => { setLoading(true); setError(null); missingImageCountRef.current = 0; 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, isCartListMode, cartListModeKey]); // eslint-disable-line react-hooks/exhaustive-deps // 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시) useEffect(() => { if (!loading && rows.length > 0 && effectiveTemplate?.image?.enabled && effectiveTemplate?.image?.imageColumn) { const imageColumn = effectiveTemplate.image.imageColumn; missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length; } }, [loading, rows, effectiveTemplate?.image]); // 장바구니 목록 모드: 항목 삭제 콜백 const handleDeleteItem = useCallback((cartId: string) => { setRows(prev => prev.filter(r => String(r.__cart_id) !== cartId)); setSelectedKeys(prev => { const next = new Set(prev); next.delete(cartId); return next; }); }, []); // 장바구니 목록 모드: 수량 수정 콜백 (로컬만 업데이트, DB 미반영) const handleUpdateQuantity = useCallback(( cartId: string, quantity: number, unit?: string, entries?: PackageEntry[], ) => { setRows(prev => prev.map(r => { if (String(r.__cart_id) !== cartId) return r; return { ...r, __cart_quantity: quantity, __cart_package_unit: unit || r.__cart_package_unit, __cart_package_entries: entries || r.__cart_package_entries, __cart_modified: true, }; })); }, []); // 장바구니 목록 모드: 선택 항목 이벤트 발행 useEffect(() => { if (!componentId || !isCartListMode) return; const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id))); publish(`__comp_output__${componentId}__selected_items`, selectedItems); }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); // 카드 영역 스타일 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 (
{isCartListMode && (!config?.cartListMode?.sourceScreenId || !config?.cartListMode?.cartType) ? (

원본 화면과 장바구니 구분값을 설정해주세요.

) : !isCartListMode && !dataSource?.tableName ? (

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

) : loading ? (
) : error ? (

{error}

) : rows.length === 0 ? (

데이터가 없습니다.

) : ( <> {/* 장바구니 목록 모드: 선택 바 */} {isCartListMode && (
0} onChange={(e) => { if (e.target.checked) { setSelectedKeys(new Set(displayCards.map(r => String(r.__cart_id)))); } else { setSelectedKeys(new Set()); } }} className="h-4 w-4 rounded border-gray-300" /> {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"}
)} {/* 카드 영역 (스크롤 가능) */}
{displayCards.map((row, index) => { const codeValue = effectiveTemplate?.header?.codeField && row[effectiveTemplate.header.codeField] ? String(row[effectiveTemplate.header.codeField]) : null; const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`; return ( { const cartId = String(row.__cart_id); setSelectedKeys(prev => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); }} onDeleteItem={handleDeleteItem} onUpdateQuantity={handleUpdateQuantity} /> ); })}
{/* 하단 컨트롤 영역 */} {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, router, onSelect, cart, codeFieldName, parentComponentId, isCartListMode, isSelected, onToggleSelect, onDeleteItem, onUpdateQuantity, }: { row: RowData; template?: CardTemplateConfig; scaled: ScaledConfig; inputField?: CardInputFieldConfig; packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; publish: (eventName: string, payload?: unknown) => void; router: ReturnType; onSelect?: (row: RowData) => void; cart: ReturnType; codeFieldName?: string; parentComponentId?: string; isCartListMode?: boolean; isSelected?: boolean; onToggleSelect?: () => void; onDeleteItem?: (cartId: string) => void; onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => 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 codeValue = header?.codeField ? row[header.codeField] : null; const titleValue = header?.titleField ? row[header.titleField] : null; // 장바구니 상태: codeField 값을 rowKey로 사용 const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : ""; const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); // DB에서 로드된 장바구니 품목이면 입력값 복원 (기본 모드) useEffect(() => { if (isCartListMode) return; if (existingCartItem && existingCartItem._origin === "db") { setInputValue(existingCartItem.quantity); setPackageUnit(existingCartItem.packageUnit); setPackageEntries(existingCartItem.packageEntries || []); } }, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); // 장바구니 목록 모드: __cart_quantity에서 초기값 복원 useEffect(() => { if (!isCartListMode) return; const cartQty = Number(row.__cart_quantity) || 0; setInputValue(cartQty); const cartUnit = row.__cart_package_unit ? String(row.__cart_package_unit) : undefined; setPackageUnit(cartUnit); }, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]); 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 || []); if (isCartListMode) { onUpdateQuantity?.(String(row.__cart_id), value, unit, entries); } }; // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달 const handleCartAdd = () => { if (!rowKey) return; const cartItem: CartItem = { row, quantity: inputValue, packageUnit: packageUnit || undefined, packageEntries: packageEntries.length > 0 ? packageEntries : undefined, }; cart.addItem(cartItem, rowKey); if (parentComponentId) { publish(`__comp_output__${parentComponentId}__cart_updated`, { count: cart.cartCount + 1, isDirty: true, }); } }; // 취소: 로컬 상태에서만 제거 + 연결 시스템으로 카운트 전달 const handleCartCancel = () => { if (!rowKey) return; cart.removeItem(rowKey); if (parentComponentId) { publish(`__comp_output__${parentComponentId}__cart_updated`, { count: Math.max(0, cart.cartCount - 1), isDirty: true, }); } }; // 장바구니 목록 모드: 개별 삭제 const handleCartDelete = async (e: React.MouseEvent) => { e.stopPropagation(); const cartId = String(row.__cart_id); if (!cartId) return; const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?"); if (!ok) return; try { await dataApi.deleteRecord("cart_items", cartId); onDeleteItem?.(cartId); } catch { toast.error("삭제에 실패했습니다."); } }; // 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); }; // 카드 테두리: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준 const borderClass = isCartListMode ? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500" : isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500"; // 카드 헤더 배경: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준 const headerBgClass = isCartListMode ? isSelected ? "bg-primary/10 dark:bg-primary/20" : "bg-muted/30" : isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"; return (
{ if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > {/* 장바구니 목록 모드: 체크박스 */} {isCartListMode && ( { e.stopPropagation(); onToggleSelect?.(); }} onClick={(e) => e.stopPropagation()} className="absolute left-2 top-2 z-10 h-4 w-4 rounded border-gray-300" /> )} {/* 헤더 영역 */} {(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) => ( )) ) : (
본문 필드를 추가하세요
)}
{/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */} {(inputField?.enabled || cartAction || isCartListMode) && (
{/* 수량 버튼 (입력 필드 ON일 때만) */} {inputField?.enabled && ( )} {/* 장바구니 목록 모드: 삭제 버튼 */} {isCartListMode && ( )} {/* 기본 모드: 담기/취소 버튼 (cartAction 존재 시) */} {!isCartListMode && 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; } }