"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 } from "lucide-react"; import * as LucideIcons from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import type { PopCardListConfig, CardTemplateConfig, CardFieldBinding, CardInputFieldConfig, CardCalculatedFieldConfig, CardCartActionConfig, CardPresetSpec, CartItem, } 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"; // Lucide 아이콘 동적 렌더링 function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { if (!name) return ; const icons = LucideIcons as unknown as Record>; const IconComp = icons[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; // 이벤트 기반 company_code 필터링 const [eventCompanyCode, setEventCompanyCode] = useState(); const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default"); const router = useRouter(); useEffect(() => { if (!screenId) return; const unsub = subscribe("company_selected", (payload: unknown) => { const p = payload as { companyCode?: string } | undefined; setEventCompanyCode(p?.companyCode); }); return unsub; }, [screenId, subscribe]); // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 확장/페이지네이션 상태 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 spec: CardPresetSpec = 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); // 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지) const effectiveGridRows = useMemo(() => { if (containerHeight <= 0) return configGridRows; const controlBarHeight = 44; const effectiveHeight = baseContainerHeight.current > 0 ? baseContainerHeight.current : containerHeight; const availableHeight = effectiveHeight - controlBarHeight; const cardHeightWithGap = spec.height + spec.gap; const fittableRows = Math.max(1, Math.floor( (availableHeight + spec.gap) / cardHeightWithGap )); return Math.min(configGridRows, fittableRows); }, [containerHeight, configGridRows, spec]); const gridRows = effectiveGridRows; // 카드 크기: 컨테이너 실측 크기에서 gridColumns x gridRows 기준으로 동적 계산 const scaled = useMemo((): ScaledConfig => { const gap = spec.gap; const controlBarHeight = 44; const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => { const scale = cardHeight / spec.height; return { cardHeight, cardWidth, imageSize: Math.round(spec.imageSize * scale), padding: Math.round(spec.padding * scale), gap, headerPaddingX: Math.round(spec.headerPadX * scale), headerPaddingY: Math.round(spec.headerPadY * scale), codeTextSize: Math.round(spec.codeText * scale), titleTextSize: Math.round(spec.titleText * scale), bodyTextSize: Math.round(spec.bodyText * scale), }; }; if (containerWidth <= 0 || containerHeight <= 0) { return buildScaledConfig(Math.round(spec.height * 1.6), spec.height); } const effectiveHeight = baseContainerHeight.current > 0 ? baseContainerHeight.current : containerHeight; const availableHeight = effectiveHeight - controlBarHeight; const availableWidth = containerWidth; const cardHeight = Math.max(spec.height, Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows)); const cardWidth = Math.max(Math.round(spec.height * 1.6), Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns)); return buildScaledConfig(cardWidth, cardHeight); }, [spec, containerWidth, containerHeight, gridColumns, gridRows]); // 기본 상태에서 표시할 카드 수 const visibleCardCount = useMemo(() => { return gridColumns * gridRows; }, [gridColumns, gridRows]); // 더보기 버튼 표시 여부 const hasMoreCards = rows.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) { // 기본 상태: visibleCardCount만큼만 표시 return rows.slice(0, visibleCardCount); } else { // 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이) const start = (currentPage - 1) * expandedCardsPerPage; const end = start + expandedCardsPerPage; return rows.slice(start, end); } }, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); // 총 페이지 수 const totalPages = isExpanded ? Math.ceil(rows.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]); // 데이터 조회 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; } }); } // 이벤트로 수신한 company_code 필터 병합 if (eventCompanyCode) { filters["company_code"] = eventCompanyCode; } // 정렬 조건 const sortBy = dataSource.sort?.column; const sortOrder = dataSource.sort?.direction; // 개수 제한 const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100; // TODO: 조인 지원은 추후 구현 // 현재는 단일 테이블 조회만 지원 const result = await dataApi.getTableData(dataSource.tableName, { page: 1, size, sortBy: sortOrder ? 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(); }, [dataSource, eventCompanyCode]); // 이미지 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) => ( ))}
{/* 하단 컨트롤 영역 */} {hasMoreCards && (
{rows.length}건
{isExpanded && needsPagination && (
{currentPage} / {totalPages}
)}
)} )}
); } // ===== 카드 컴포넌트 ===== function Card({ row, template, scaled, inputField, calculatedField, cartAction, publish, getSharedData, setSharedData, router, }: { row: RowData; template?: CardTemplateConfig; scaled: ScaledConfig; inputField?: CardInputFieldConfig; calculatedField?: CardCalculatedFieldConfig; cartAction?: CardCartActionConfig; publish: (eventName: string, payload?: unknown) => void; getSharedData: (key: string) => T | undefined; setSharedData: (key: string, value: unknown) => void; router: ReturnType; }) { const header = template?.header; const image = template?.image; const body = template?.body; // 입력 필드 상태 const [inputValue, setInputValue] = useState( inputField?.defaultValue || 0 ); const [packageUnit, setPackageUnit] = useState(undefined); 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; // 이미지 URL 결정 const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] ? String(row[image.imageColumn]) : image?.defaultImage || DEFAULT_CARD_IMAGE; // 계산 필드 값 계산 const calculatedValue = useMemo(() => { if (!calculatedField?.enabled || !calculatedField?.formula) return null; return evaluateFormula(calculatedField.formula, row, inputValue); }, [calculatedField, row, inputValue]); // effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백 const effectiveMax = useMemo(() => { if (inputField?.maxColumn) { const colVal = Number(row[inputField.maxColumn]); if (!isNaN(colVal) && colVal > 0) return colVal; } return inputField?.max ?? 999999; }, [inputField, row]); // 기본값이 설정되지 않은 경우 최대값으로 자동 초기화 useEffect(() => { if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) { setInputValue(effectiveMax); } }, [effectiveMax, inputField?.enabled, inputField?.defaultValue]); 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) => { setInputValue(value); setPackageUnit(unit); }; // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 const handleCartAdd = () => { const cartItem: CartItem = { row, quantity: inputValue, packageUnit: packageUnit || 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 || "취소"; return (
{/* 헤더 영역 */} {(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) => ( )) ) : (
본문 필드를 추가하세요
)} {/* 계산 필드 */} {calculatedField?.enabled && calculatedValue !== null && (
{calculatedField.label || "계산값"} {calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""}
)}
{/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */} {inputField?.enabled && (
{/* 수량 버튼 */} {/* pop-icon 스타일 담기/취소 토글 버튼 */} {isCarted ? ( ) : ( )}
)}
{/* 숫자 입력 모달 */} {inputField?.enabled && ( )}
); } // ===== 필드 행 컴포넌트 ===== function FieldRow({ field, row, scaled, }: { field: CardFieldBinding; row: RowData; scaled: ScaledConfig; }) { const value = row[field.columnName]; // 비율 기반 라벨 최소 너비 const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12)); return (
{/* 라벨 */} {field.label} {/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */} {formatValue(value)}
); } // ===== 값 포맷팅 ===== 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)) { console.warn("Invalid formula expression:", 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) { console.warn("Formula evaluation error:", error); return null; } }