"use client"; /** * pop-card-list-v2 런타임 컴포넌트 * * pop-card-list의 데이터 로딩/필터링/페이징/장바구니 로직을 재활용하되, * 카드 내부 렌더링은 CSS Grid + 셀 타입별 렌더러(cell-renderers.tsx)로 대체. */ import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useRouter } from "next/navigation"; import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import type { PopCardListV2Config, CardGridConfigV2, CardCellDefinitionV2, CardInputFieldConfig, CardCartActionConfig, CardPackageConfig, CardPresetSpec, CartItem, PackageEntry, CollectDataRequest, CollectedDataResponse, TimelineProcessStep, TimelineDataSource, ActionButtonUpdate, StatusValueMapping, } from "../types"; import { CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE } from "../types"; import { dataApi } from "@/lib/api/data"; import { screenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "../pop-card-list/NumberInputModal"; import { renderCellV2 } from "./cell-renderers"; type RowData = Record; // cart_items 행 파싱 (pop-card-list에서 그대로 차용) 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, }; } // 레거시 statusValues(고정 4키 객체) → statusMappings(동적 배열) 자동 변환 function resolveStatusMappings(src: TimelineDataSource): StatusValueMapping[] { if (src.statusMappings && src.statusMappings.length > 0) return src.statusMappings; // 레거시 호환: 기존 statusValues 객체가 있으면 변환 const sv = (src as Record).statusValues as Record | undefined; return [ { dbValue: sv?.waiting || "waiting", label: "대기", semantic: "pending" as const }, { dbValue: sv?.accepted || "accepted", label: "접수", semantic: "active" as const }, { dbValue: sv?.inProgress || "in_progress", label: "진행중", semantic: "active" as const }, { dbValue: sv?.completed || "completed", label: "완료", semantic: "done" as const }, ]; } interface PopCardListV2ComponentProps { config?: PopCardListV2Config; className?: string; screenId?: string; componentId?: string; currentRowSpan?: number; currentColSpan?: number; onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; } export function PopCardListV2Component({ config, className, screenId, componentId, currentRowSpan, currentColSpan, onRequestResize, }: PopCardListV2ComponentProps) { const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); const isCartListMode = config?.cartListMode?.enabled === true; const [inheritedConfig, setInheritedConfig] = useState | null>(null); const [selectedKeys, setSelectedKeys] = useState>(new Set()); const effectiveConfig = useMemo(() => { if (!isCartListMode || !inheritedConfig) return config; return { ...config, ...inheritedConfig, cartListMode: config?.cartListMode, dataSource: config?.dataSource, } as PopCardListV2Config; }, [config, inheritedConfig, isCartListMode]); const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal"; const maxGridColumns = effectiveConfig?.gridColumns || 2; const configGridRows = effectiveConfig?.gridRows || 3; const dataSource = effectiveConfig?.dataSource; const cardGrid = effectiveConfig?.cardGrid; const sourceTableName = (!isCartListMode && dataSource?.tableName) || ""; const cart = useCartSync(screenId || "", sourceTableName); const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // 외부 필터 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]); const cartRef = useRef(cart); cartRef.current = cart; // 저장 요청 수신 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]); // 초기 장바구니 상태 전달 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]); 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); 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(); }, []); const cardSizeKey = effectiveConfig?.cardSize || "large"; const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; 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)); const gridRows = configGridRows; // 외부 필터 const filteredRows = useMemo(() => { if (externalFilters.size === 0) return rows; const allFilters = [...externalFilters.values()]; return rows.filter((row) => allFilters.every((filter) => { 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"; return columns.some((col) => { const cellValue = String(row[col] ?? "").toLowerCase(); switch (mode) { case "equals": return cellValue === searchValue; case "starts_with": return cellValue.startsWith(searchValue); default: return cellValue.includes(searchValue); } }); }), ); }, [rows, externalFilters]); const overflowCfg = effectiveConfig?.overflow; const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows; const visibleCardCount = useMemo(() => Math.max(1, baseVisibleCount), [baseVisibleCount]); const hasMoreCards = filteredRows.length > visibleCardCount; const expandedCardsPerPage = useMemo(() => { if (overflowCfg?.mode === "pagination" && overflowCfg.pageSize) return overflowCfg.pageSize; if (overflowCfg?.mode === "loadMore" && overflowCfg.loadMoreCount) return overflowCfg.loadMoreCount + visibleCardCount; return Math.max(1, visibleCardCount * 2 + gridColumns); }, [visibleCardCount, gridColumns, overflowCfg]); const scrollAreaRef = useRef(null); const displayCards = useMemo(() => { if (!isExpanded) return filteredRows.slice(0, visibleCardCount); const start = (currentPage - 1) * expandedCardsPerPage; return filteredRows.slice(start, start + expandedCardsPerPage); }, [filteredRows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); const totalPages = isExpanded ? Math.ceil(filteredRows.length / expandedCardsPerPage) : 1; const needsPagination = isExpanded && totalPages > 1; 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]); // 데이터 조회 const dataSourceKey = useMemo(() => JSON.stringify(dataSource || null), [dataSource]); const cartListModeKey = useMemo(() => JSON.stringify(config?.cartListMode || null), [config?.cartListMode]); // 셀 설정에서 timelineSource 탐색 (timeline/status-badge/action-buttons 중 하나에 설정됨) const timelineSource = useMemo(() => { const cells = cardGrid?.cells || []; for (const c of cells) { if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) { return c.timelineSource; } } return undefined; }, [cardGrid?.cells]); // 하위 데이터 조회 + __processFlow__ 가상 컬럼 주입 const injectProcessFlow = useCallback(async ( fetchedRows: RowData[], src: TimelineDataSource, ): Promise => { if (fetchedRows.length === 0) return fetchedRows; const rowIds = fetchedRows.map((r) => String(r.id)).filter(Boolean); if (rowIds.length === 0) return fetchedRows; // statusMappings 동적 배열 → dbValue-to-내부키 맵 구축 // 레거시 statusValues 객체도 자동 변환 const mappings = resolveStatusMappings(src); const dbToInternal = new Map(); const dbToSemantic = new Map(); for (const m of mappings) { dbToInternal.set(m.dbValue, m.dbValue); dbToSemantic.set(m.dbValue, m.semantic); } const processResult = await dataApi.getTableData(src.processTable, { page: 1, size: 1000, sortBy: src.seqColumn || "seq_no", sortOrder: "asc", }); const allProcesses = processResult.data || []; const processMap = new Map(); for (const p of allProcesses) { const fkValue = String(p[src.foreignKey] || ""); if (!fkValue || !rowIds.includes(fkValue)) continue; if (!processMap.has(fkValue)) processMap.set(fkValue, []); const rawStatus = String(p[src.statusColumn] || ""); const normalizedStatus = dbToInternal.get(rawStatus) || rawStatus; const semantic = dbToSemantic.get(rawStatus) || "pending"; processMap.get(fkValue)!.push({ seqNo: parseInt(String(p[src.seqColumn] || "0"), 10), processName: String(p[src.nameColumn] || ""), status: normalizedStatus, semantic: semantic as "pending" | "active" | "done", isCurrent: semantic === "active", }); } // isCurrent 보정: active가 없으면 첫 pending을 current로 for (const [, steps] of processMap) { steps.sort((a, b) => a.seqNo - b.seqNo); const hasActive = steps.some((s) => s.isCurrent); if (!hasActive) { const firstPending = steps.find((s) => { const sem = dbToSemantic.get(s.status) || "pending"; return sem === "pending"; }); if (firstPending) { steps.forEach((s) => { s.isCurrent = false; }); firstPending.isCurrent = true; } } } return fetchedRows.map((row) => ({ ...row, __processFlow__: processMap.get(String(row.id)) || [], })); }, []); const fetchData = useCallback(async () => { if (!dataSource?.tableName) { setLoading(false); setRows([]); return; } setLoading(true); setError(null); try { const filters: Record = {}; if (dataSource.filters?.length) { dataSource.filters.forEach((f) => { if (f.column && f.value && (!f.operator || f.operator === "=")) 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[0]; const size = dataSource.limit?.mode === "limited" && dataSource.limit?.count ? dataSource.limit.count : 100; const result = await dataApi.getTableData(dataSource.tableName, { page: 1, size, sortBy: primarySort?.column || undefined, sortOrder: primarySort?.direction, filters: Object.keys(filters).length > 0 ? filters : undefined, }); let fetchedRows = result.data || []; const clientFilters = (dataSource.filters || []).filter( (f) => f.column && f.value && f.operator && f.operator !== "=", ); if (clientFilters.length > 0) { fetchedRows = fetchedRows.filter((row) => clientFilters.every((f) => { const cellVal = row[f.column]; const filterVal = f.value; switch (f.operator) { case "!=": return String(cellVal ?? "") !== filterVal; case ">": return Number(cellVal) > Number(filterVal); case ">=": return Number(cellVal) >= Number(filterVal); case "<": return Number(cellVal) < Number(filterVal); case "<=": return Number(cellVal) <= Number(filterVal); case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase()); default: return true; } }), ); } // timelineSource 설정이 있으면 공정 데이터 조회하여 __processFlow__ 주입 if (timelineSource) { try { fetchedRows = await injectProcessFlow(fetchedRows, timelineSource); } catch { // 공정 데이터 조회 실패 시 무시 (메인 데이터는 정상 표시) } } setRows(fetchedRows); } catch (err) { setError(err instanceof Error ? err.message : "데이터 조회 실패"); setRows([]); } finally { setLoading(false); } }, [dataSource, timelineSource, injectProcessFlow]); useEffect(() => { if (isCartListMode) { const cartListMode = config!.cartListMode!; if (!cartListMode.sourceScreenId) { setLoading(false); setRows([]); return; } const fetchCartData = async () => { setLoading(true); setError(null); try { try { const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId!); const componentsMap = layoutJson?.components || {}; const componentList = Object.values(componentsMap) as any[]; const matched = cartListMode.sourceComponentId ? componentList.find((c: any) => c.id === cartListMode.sourceComponentId) : componentList.find((c: any) => c.type === "pop-card-list-v2" || c.type === "pop-card-list"); if (matched?.config) setInheritedConfig(matched.config); } catch { /* 레이아웃 로드 실패 시 자체 config 폴백 */ } const cartFilters: Record = { status: cartListMode.statusFilter || "in_cart" }; if (cartListMode.sourceScreenId) cartFilters.screen_id = String(cartListMode.sourceScreenId); const result = await dataApi.getTableData("cart_items", { size: 500, filters: cartFilters }); setRows((result.data || []).map(parseCartRow)); } catch (err) { setError(err instanceof Error ? err.message : "장바구니 데이터 조회 실패"); setRows([]); } finally { setLoading(false); } }; fetchCartData(); return; } fetchData(); }, [dataSourceKey, isCartListMode, cartListModeKey, fetchData]); // eslint-disable-line react-hooks/exhaustive-deps // 장바구니 목록 모드 콜백 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; }); }, []); 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) return; const unsub = subscribe( `__comp_input__${componentId}__collect_data`, (payload: unknown) => { const request = (payload as Record)?.value as CollectDataRequest | undefined; const selectedItems = isCartListMode ? filteredRows.filter((r) => selectedKeys.has(String(r.__cart_id ?? ""))) : rows; const sm = config?.saveMapping; const mapping = sm?.targetTable && sm.mappings.length > 0 ? { targetTable: sm.targetTable, columnMapping: Object.fromEntries(sm.mappings.filter((m) => m.sourceField && m.targetColumn).map((m) => [m.sourceField, m.targetColumn])) } : null; const cartChanges = cart.isDirty ? cart.getChanges() : undefined; const response: CollectedDataResponse = { requestId: request?.requestId ?? "", componentId: componentId, componentType: "pop-card-list-v2", data: { items: selectedItems, cartChanges } as any, mapping, }; publish(`__comp_output__${componentId}__collected_data`, response); }, ); return unsub; }, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]); // 선택 항목 이벤트 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 cardGap = effectiveConfig?.cardGap ?? spec.gap; const cardMinHeight = spec.height; const cardAreaStyle: React.CSSProperties = { gap: `${cardGap}px`, ...(isHorizontalMode ? { gridTemplateRows: `repeat(${gridRows}, minmax(${cardMinHeight}px, auto))`, gridAutoFlow: "column", gridAutoColumns: `${Math.round(cardMinHeight * 1.6)}px`, } : { gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, gridAutoRows: `minmax(${cardMinHeight}px, auto)`, }), }; const scrollClassName = isHorizontalMode ? "overflow-x-auto overflow-y-hidden" : isExpanded ? "overflow-y-auto overflow-x-hidden" : "overflow-hidden"; return (
{isCartListMode && !config?.cartListMode?.sourceScreenId ? (

원본 화면을 선택해주세요.

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

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

) : loading ? (
) : error ? (

{error}

) : rows.length === 0 ? (

데이터가 없습니다.

) : ( <> {isCartListMode && (
0} onChange={(e) => { if (e.target.checked) { setSelectedKeys(new Set(filteredRows.map((r) => String(r.__cart_id ?? "")))); } else { setSelectedKeys(new Set()); } }} className="h-4 w-4 rounded border-input" /> {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"}
)}
{displayCards.map((row, index) => ( { const cartId = row.__cart_id != null ? String(row.__cart_id) : ""; if (!cartId) return; setSelectedKeys((prev) => { const next = new Set(prev); if (next.has(cartId)) next.delete(cartId); else next.add(cartId); return next; }); }} onDeleteItem={handleDeleteItem} onUpdateQuantity={handleUpdateQuantity} onRefresh={fetchData} /> ))}
{hasMoreCards && (
{filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""}
{isExpanded && needsPagination && (
{currentPage} / {totalPages}
)}
)} )}
); } // ===== 카드 V2 ===== interface CardV2Props { row: RowData; cardGrid?: CardGridConfigV2; spec: CardPresetSpec; config?: PopCardListV2Config; onSelect?: (row: RowData) => void; cart: ReturnType; publish: (eventName: string, payload?: unknown) => void; parentComponentId?: string; isCartListMode?: boolean; isSelected?: boolean; onToggleSelect?: () => void; onDeleteItem?: (cartId: string) => void; onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void; onRefresh?: () => void; } function CardV2({ row, cardGrid, spec, config, onSelect, cart, publish, parentComponentId, isCartListMode, isSelected, onToggleSelect, onDeleteItem, onUpdateQuantity, onRefresh, }: CardV2Props) { const inputField = config?.inputField; const cartAction = config?.cartAction; const packageConfig = config?.packageConfig; const keyColumnName = cartAction?.keyColumn || "id"; const [inputValue, setInputValue] = useState(0); const [packageUnit, setPackageUnit] = useState(undefined); const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : ""; 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]); // 장바구니 목록 모드 초기값 useEffect(() => { if (!isCartListMode) return; setInputValue(Number(row.__cart_quantity) || 0); setPackageUnit(row.__cart_package_unit ? String(row.__cart_package_unit) : undefined); }, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]); // 제한 컬럼 자동 초기화 const limitCol = inputField?.limitColumn || inputField?.maxColumn; const effectiveMax = useMemo(() => { if (limitCol) { const v = Number(row[limitCol]); if (!isNaN(v) && v > 0) return v; } return 999999; }, [limitCol, row]); useEffect(() => { if (isCartListMode) return; if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) { setInputValue(effectiveMax); } }, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]); 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; cart.addItem({ row, quantity: inputValue, packageUnit, packageEntries: packageEntries.length > 0 ? packageEntries : undefined }, 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 = row.__cart_id != null ? String(row.__cart_id) : ""; if (!cartId) return; if (!window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?")) return; try { await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" }); onDeleteItem?.(cartId); } catch { toast.error("삭제에 실패했습니다."); } }; 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"; if (!cardGrid || cardGrid.cells.length === 0) { return (
카드 레이아웃을 설정하세요
); } const gridStyle: React.CSSProperties = { display: "grid", gridTemplateColumns: cardGrid.colWidths.length > 0 ? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ") : "1fr", gridTemplateRows: cardGrid.rowHeights?.length ? cardGrid.rowHeights.map((h) => { if (!h) return "minmax(24px, auto)"; if (h.endsWith("px")) return `minmax(${h}, auto)`; const px = Math.round(parseFloat(h) * 24) || 24; return `minmax(${px}px, auto)`; }).join(" ") : `repeat(${cardGrid.rows || 1}, minmax(24px, auto))`, gap: `${cardGrid.gap || 0}px`, }; const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const; const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const; return (
onSelect?.(row)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect?.(row); }} > {/* 장바구니 목록 모드: 체크박스 + 삭제 */} {isCartListMode && (
{ e.stopPropagation(); onToggleSelect?.(); }} onClick={(e) => e.stopPropagation()} className="h-4 w-4 rounded border-input" />
)} {/* CSS Grid 기반 셀 렌더링 */}
{cardGrid.cells.map((cell) => (
{renderCellV2({ cell, row, inputValue, isCarted, onInputClick: handleInputClick, onCartAdd: handleCartAdd, onCartCancel: handleCartCancel, onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => { const cfg = buttonConfig as { updates?: ActionButtonUpdate[]; targetTable?: string; confirmMessage?: string; } | undefined; if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) { if (cfg.confirmMessage) { if (!window.confirm(cfg.confirmMessage)) return; } try { const rowId = actionRow.id ?? actionRow.pk; if (!rowId) { toast.error("대상 레코드의 ID를 찾을 수 없습니다."); return; } const tasks = cfg.updates.map((u, idx) => ({ id: `btn-update-${idx}`, type: "data-update" as const, targetTable: cfg.targetTable!, targetColumn: u.column, operationType: "assign" as const, valueSource: "fixed" as const, fixedValue: u.valueType === "static" ? (u.value ?? "") : u.valueType === "currentUser" ? "__CURRENT_USER__" : u.valueType === "currentTime" ? "__CURRENT_TIME__" : u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") : (u.value ?? ""), })); const result = await apiClient.post("/pop/execute-action", { tasks, data: { items: [actionRow], fieldValues: {} }, mappings: {}, }); if (result.data?.success) { toast.success(result.data.message || "처리 완료"); onRefresh?.(); } else { toast.error(result.data?.message || "처리 실패"); } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); } return; } if (parentComponentId) { publish(`__comp_output__${parentComponentId}__action`, { taskPreset, row: actionRow, }); } }, packageEntries, inputUnit: inputField?.unit, })}
))}
{inputField?.enabled && ( )}
); }