"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, Check, X, } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { PopCardListV2Config, CardGridConfigV2, CardCellDefinitionV2, CardInputFieldConfig, CardCartActionConfig, CardPackageConfig, CardPresetSpec, CartItem, PackageEntry, CollectDataRequest, CollectedDataResponse, TimelineProcessStep, TimelineDataSource, ActionButtonUpdate, StatusValueMapping, SelectModeConfig, SelectModeButtonConfig, } from "../types"; import { CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC, VIRTUAL_SUB_PROCESS, VIRTUAL_SUB_SEQ, } 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"; import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout"; import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout"; import dynamic from "next/dynamic"; const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false }); 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; _source?: string }; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean }; _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, _source: data.value._source, }); } 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 [selectMode, setSelectMode] = useState(false); const [selectModeStatus, setSelectModeStatus] = useState(""); const [selectModeConfig, setSelectModeConfig] = useState(null); const [selectedRowIds, setSelectedRowIds] = useState>(new Set()); const [selectProcessing, setSelectProcessing] = useState(false); // ===== 모달 열기 (POP 화면) ===== const [popModalOpen, setPopModalOpen] = useState(false); const [popModalLayout, setPopModalLayout] = useState(null); const [popModalScreenId, setPopModalScreenId] = useState(""); const [popModalRow, setPopModalRow] = useState(null); const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => { try { const sid = parseInt(screenIdStr, 10); if (isNaN(sid)) { toast.error("올바른 화면 ID가 아닙니다."); return; } const popLayout = await screenApi.getLayoutPop(sid); if (popLayout && isV5Layout(popLayout)) { setPopModalLayout(popLayout); setPopModalScreenId(String(sid)); setPopModalRow(row); setPopModalOpen(true); } else { toast.error("해당 POP 화면을 찾을 수 없습니다."); } } catch { toast.error("POP 화면을 불러오는데 실패했습니다."); } }, []); const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record) => { const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined; if (!smConfig) return; setSelectMode(true); setSelectModeStatus(smConfig.filterStatus || whenStatus); setSelectModeConfig(smConfig); setSelectedRowIds(new Set()); }, []); const exitSelectMode = useCallback(() => { setSelectMode(false); setSelectModeStatus(""); setSelectModeConfig(null); setSelectedRowIds(new Set()); }, []); const toggleRowSelection = useCallback((row: RowData) => { const rowId = String(row.id ?? row.pk ?? ""); if (!rowId) return; setSelectedRowIds((prev) => { const next = new Set(prev); if (next.has(rowId)) next.delete(rowId); else next.add(rowId); return next; }); }, []); const isRowSelectable = useCallback((row: RowData) => { if (!selectMode) return false; const subStatus = row[VIRTUAL_SUB_STATUS]; if (subStatus !== undefined) return String(subStatus) === selectModeStatus; return true; }, [selectMode, selectModeStatus]); // 확장/페이지네이션 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; // 셀 설정에서 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]); // 외부 필터 (메인 테이블 + 하위 테이블 분기) const filteredRows = useMemo(() => { if (externalFilters.size === 0) return rows; const allFilters = [...externalFilters.values()]; const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); // 1단계: 하위 테이블 필터 → __subStatus__ 주입 const afterSubFilter = subFilters.length === 0 ? rows : rows .map((row) => { const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; if (!processFlow || processFlow.length === 0) return null; const matchingSteps = processFlow.filter((step) => subFilters.every((filter) => { const searchValue = String(filter.value).toLowerCase(); if (!searchValue) return true; const fc = filter.filterConfig; const col = fc?.targetColumn || filter.fieldName || ""; if (!col) return true; const cellValue = String(step.rawData?.[col] ?? "").toLowerCase(); const mode = fc?.filterMode || "contains"; switch (mode) { case "equals": return cellValue === searchValue; case "starts_with": return cellValue.startsWith(searchValue); default: return cellValue.includes(searchValue); } }), ); if (matchingSteps.length === 0) return null; const matched = matchingSteps[0]; // 매칭된 공정을 타임라인에서 강조 const updatedFlow = processFlow.map((s) => ({ ...s, isCurrent: s.seqNo === matched.seqNo, })); return { ...row, __processFlow__: updatedFlow, [VIRTUAL_SUB_STATUS]: matched.status, [VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending", [VIRTUAL_SUB_PROCESS]: matched.processName, [VIRTUAL_SUB_SEQ]: matched.seqNo, }; }) .filter((row): row is RowData => row !== null); // 2단계: 메인 테이블 필터 (__subStatus__ 주입된 데이터 기반) if (mainFilters.length === 0) return afterSubFilter; return afterSubFilter.filter((row) => mainFilters.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"; // 하위 필터 활성 시: 상태 컬럼(status 등)을 __subStatus__로 대체 const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; const statusCol = timelineSource?.statusColumn || "status"; const effectiveColumns = subCol ? columns.map((col) => col === statusCol || col === "status" ? subCol : col) : columns; return effectiveColumns.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, timelineSource]); // 하위 필터 활성 여부 const hasActiveSubFilter = useMemo(() => { if (externalFilters.size === 0) return false; return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable); }, [externalFilters]); // 선택 모드 일괄 처리 const handleSelectModeAction = useCallback(async (btnConfig: SelectModeButtonConfig) => { if (btnConfig.clickMode === "cancel-select") { exitSelectMode(); return; } if (btnConfig.clickMode === "status-change" && btnConfig.updates && btnConfig.targetTable) { if (selectedRowIds.size === 0) { toast.error("선택된 항목이 없습니다."); return; } if (btnConfig.confirmMessage && !window.confirm(btnConfig.confirmMessage)) return; setSelectProcessing(true); try { const selectedRows = filteredRows.filter((r) => { const rowId = String(r.id ?? r.pk ?? ""); return selectedRowIds.has(rowId); }); let successCount = 0; for (const row of selectedRows) { const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; const currentProcess = processFlow?.find((s) => s.isCurrent); const targetId = currentProcess?.processId ?? row.id ?? row.pk; if (!targetId) continue; const tasks = btnConfig.updates.map((u, idx) => ({ id: `sel-update-${idx}`, type: "data-update" as const, targetTable: btnConfig.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.value ?? ""), lookupMode: "manual" as const, manualItemField: "id", manualPkColumn: "id", })); const result = await apiClient.post("/pop/execute-action", { tasks, data: { items: [{ ...row, id: targetId }], fieldValues: {} }, mappings: {}, }); if (result.data?.success) successCount++; } if (successCount > 0) { toast.success(`${successCount}건 처리 완료`); exitSelectMode(); fetchDataRef.current(); } else { toast.error("처리에 실패했습니다."); } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : "처리 중 오류 발생"); } finally { setSelectProcessing(false); } return; } if (btnConfig.clickMode === "modal-open" && btnConfig.modalScreenId) { const selectedRows = filteredRows.filter((r) => { const rowId = String(r.id ?? r.pk ?? ""); return selectedRowIds.has(rowId); }); openPopModal(btnConfig.modalScreenId, selectedRows[0] || {}); return; } }, [selectedRowIds, filteredRows, exitSelectMode]); // status-bar 필터를 제외한 rows (카운트 집계용) // status-bar에서 "접수가능" 등 선택해도 전체 카운트가 유지되어야 함 const rowsForStatusCount = useMemo(() => { const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar"); if (!hasStatusBarFilter) return filteredRows; // status-bar 필터를 제외한 필터만 적용 const nonStatusFilters = new Map( [...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar") ); if (nonStatusFilters.size === 0) return rows; const allFilters = [...nonStatusFilters.values()]; const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable); const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable); const afterSubFilter = subFilters.length === 0 ? rows : rows .map((row) => { const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; if (!processFlow || processFlow.length === 0) return null; const matchingSteps = processFlow.filter((step) => subFilters.every((filter) => { const searchValue = String(filter.value).toLowerCase(); if (!searchValue) return true; const fc = filter.filterConfig; const col = fc?.targetColumn || filter.fieldName || ""; if (!col) return true; const cellValue = String(step.rawData?.[col] ?? "").toLowerCase(); const mode = fc?.filterMode || "contains"; switch (mode) { case "equals": return cellValue === searchValue; case "starts_with": return cellValue.startsWith(searchValue); default: return cellValue.includes(searchValue); } }), ); if (matchingSteps.length === 0) return null; const matched = matchingSteps[0]; const updatedFlow = processFlow.map((s) => ({ ...s, isCurrent: s.seqNo === matched.seqNo, })); return { ...row, __processFlow__: updatedFlow, [VIRTUAL_SUB_STATUS]: matched.status, [VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending", [VIRTUAL_SUB_PROCESS]: matched.processName, [VIRTUAL_SUB_SEQ]: matched.seqNo, }; }) .filter((row): row is RowData => row !== null); if (mainFilters.length === 0) return afterSubFilter; return afterSubFilter.filter((row) => mainFilters.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"; const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null; const statusCol = timelineSource?.statusColumn || "status"; const effectiveColumns = subCol ? columns.map((col) => col === statusCol || col === "status" ? subCol : col) : columns; return effectiveColumns.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, filteredRows, externalFilters, timelineSource]); // 카운트 집계용 rows 발행 (status-bar 필터 제외) useEffect(() => { if (!componentId || loading) return; publish(`__comp_output__${componentId}__all_rows`, { rows: rowsForStatusCount, subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null, }); }, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]); 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]); // 하위 데이터 조회 + __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 || []; // isDerived 매핑: DB에 없는 자동 판별 상태 // 같은 시맨틱의 DB 원본 상태를 자동으로 찾아 변환 조건 구축 const derivedRules: { sourceStatus: string; targetDbValue: string; targetSemantic: string }[] = []; for (const dm of mappings.filter((m) => m.isDerived)) { const source = mappings.find((m) => !m.isDerived && m.semantic === dm.semantic); if (source) { derivedRules.push({ sourceStatus: source.dbValue, targetDbValue: dm.dbValue, targetSemantic: dm.semantic }); } } 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", processId: p.id as string | number | undefined, rawData: p as Record, }); } // 파생 상태 자동 변환: 이전 공정이 완료된 경우 변환 if (derivedRules.length > 0) { for (const [, steps] of processMap) { steps.sort((a, b) => a.seqNo - b.seqNo); for (let i = 0; i < steps.length; i++) { const step = steps[i]; const prevStep = i > 0 ? steps[i - 1] : null; for (const rule of derivedRules) { if (step.status !== rule.sourceStatus) continue; const prevIsDone = prevStep ? prevStep.semantic === "done" : true; if (prevIsDone) { step.status = rule.targetDbValue; step.semantic = rule.targetSemantic as "pending" | "active" | "done"; } } } } } // isCurrent 결정: "기준" 체크된 상태와 일치하는 공정을 강조 // 기준 상태가 없으면 기존 로직 (active → 첫 pending) 폴백 const pivotDbValues = mappings.filter((m) => m.isDerived).map((m) => m.dbValue); for (const [, steps] of processMap) { steps.sort((a, b) => a.seqNo - b.seqNo); steps.forEach((s) => { s.isCurrent = false; }); if (pivotDbValues.length > 0) { const pivotStep = steps.find((s) => pivotDbValues.includes(s.status)); if (pivotStep) { pivotStep.isCurrent = true; continue; } } // 폴백: active가 있으면 첫 active, 없으면 첫 pending const firstActive = steps.find((s) => s.semantic === "active"); if (firstActive) { firstActive.isCurrent = true; continue; } const firstPending = steps.find((s) => s.semantic === "pending"); if (firstPending) { 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]); const fetchDataRef = useRef(fetchData); fetchDataRef.current = fetchData; 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 ? (

데이터가 없습니다.

) : ( <> {/* 선택 모드 상단 바 */} {selectMode && (
{selectedRowIds.size}
{selectedRowIds.size > 0 ? `${selectedRowIds.size}개 선택됨` : "카드를 선택하세요"}
)} {/* 장바구니 모드 상단 바 */} {!selectMode && 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} selectMode={selectMode} isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))} isSelectable={isRowSelectable(row)} onToggleRowSelect={() => toggleRowSelection(row)} onEnterSelectMode={enterSelectMode} onOpenPopModal={openPopModal} /> ))}
{/* 선택 모드 하단 액션 바 */} {selectMode && selectModeConfig && (
{selectModeConfig.buttons.map((btn, idx) => ( ))}
)} {/* 더보기/페이지네이션 */} {!selectMode && hasMoreCards && (
{filteredRows.length}건{externalFilters.size > 0 && filteredRows.length !== rows.length ? ` / ${rows.length}건` : ""}
{isExpanded && needsPagination && (
{currentPage} / {totalPages}
)}
)} )} {/* POP 화면 모달 */} { setPopModalOpen(open); if (!open) { setPopModalLayout(null); setPopModalRow(null); } }}> 상세 작업
{popModalLayout && ( )}
); } // ===== 카드 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; selectMode?: boolean; isSelectModeSelected?: boolean; isSelectable?: boolean; onToggleRowSelect?: () => void; onEnterSelectMode?: (whenStatus: string, buttonConfig: Record) => void; onOpenPopModal?: (screenId: string, row: RowData) => void; } function CardV2({ row, cardGrid, spec, config, onSelect, cart, publish, parentComponentId, isCartListMode, isSelected, onToggleSelect, onDeleteItem, onUpdateQuantity, onRefresh, selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode, onOpenPopModal, }: 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 = selectMode ? isSelectModeSelected ? "border-primary border-2 bg-primary/5" : isSelectable ? "hover:border-2 hover:border-primary/50" : "opacity-40 pointer-events-none" : 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 (
{ if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); }} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } if (!selectMode) onSelect?.(row); } }} > {/* 선택 모드: 체크 인디케이터 */} {selectMode && isSelectable && (
{ e.stopPropagation(); onToggleRowSelect?.(); }} > {isSelectModeSelected && }
)} {/* 장바구니 목록 모드: 체크박스 + 삭제 */} {!selectMode && 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, onEnterSelectMode, onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => { const cfg = buttonConfig as { updates?: ActionButtonUpdate[]; targetTable?: string; confirmMessage?: string; __processId?: string | number; } | undefined; if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) { if (cfg.confirmMessage) { if (!window.confirm(cfg.confirmMessage)) return; } try { // 공정 테이블 대상이면 processId 우선 사용 const rowId = cfg.__processId ?? 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 ?? ""), lookupMode: "manual" as const, manualItemField: "id", manualPkColumn: "id", })); const targetRow = cfg.__processId ? { ...actionRow, id: cfg.__processId } : actionRow; const result = await apiClient.post("/pop/execute-action", { tasks, data: { items: [targetRow], 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; } const actionCfg = buttonConfig as { type?: string; modalScreenId?: string } | undefined; if (actionCfg?.type === "modal-open" && actionCfg.modalScreenId) { onOpenPopModal?.(actionCfg.modalScreenId, actionRow); return; } if (parentComponentId) { publish(`__comp_output__${parentComponentId}__action`, { taskPreset, row: actionRow, }); } }, packageEntries, inputUnit: inputField?.unit, })}
))}
{inputField?.enabled && ( )}
); }