diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index 77fbe950..a58a6d31 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -170,9 +170,7 @@ export default function ComponentEditorPanel({
{allComponents.map((comp) => { - const label = comp.label - || COMPONENT_TYPE_LABELS[comp.type] - || comp.type; + const label = comp.label || comp.id; const isActive = comp.id === selectedComponentId; return ( )} +
+ {conn.filterConfig?.targetColumn && ( +
+ + {conn.filterConfig.targetColumn} + + + {conn.filterConfig.filterMode} + + {conn.filterConfig.isSubTable && ( + + 하위 테이블 + + )} +
+ )} )} @@ -186,6 +205,19 @@ interface SimpleConnectionFormProps { submitLabel: string; } +function extractSubTableName(comp: PopComponentDefinitionV5): string | null { + const cfg = comp.config as Record | undefined; + if (!cfg) return null; + + const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined; + if (grid?.cells) { + for (const cell of grid.cells) { + if (cell.timelineSource?.processTable) return cell.timelineSource.processTable; + } + } + return null; +} + function SimpleConnectionForm({ component, allComponents, @@ -197,6 +229,18 @@ function SimpleConnectionForm({ const [selectedTargetId, setSelectedTargetId] = React.useState( initial?.targetComponent || "" ); + const [isSubTable, setIsSubTable] = React.useState( + initial?.filterConfig?.isSubTable || false + ); + const [targetColumn, setTargetColumn] = React.useState( + initial?.filterConfig?.targetColumn || "" + ); + const [filterMode, setFilterMode] = React.useState( + initial?.filterConfig?.filterMode || "equals" + ); + + const [subColumns, setSubColumns] = React.useState([]); + const [loadingColumns, setLoadingColumns] = React.useState(false); const targetCandidates = allComponents.filter((c) => { if (c.id === component.id) return false; @@ -204,14 +248,39 @@ function SimpleConnectionForm({ return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; }); + const sourceReg = PopComponentRegistry.getComponent(component.type); + const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null; + const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value") + && targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value"); + + const subTableName = targetComp ? extractSubTableName(targetComp) : null; + + React.useEffect(() => { + if (!isSubTable || !subTableName) { + setSubColumns([]); + return; + } + setLoadingColumns(true); + getTableColumns(subTableName) + .then((res) => { + const cols = res.success && res.data?.columns; + if (Array.isArray(cols)) { + setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean)); + } + }) + .catch(() => setSubColumns([])) + .finally(() => setLoadingColumns(false)); + }, [isSubTable, subTableName]); + const handleSubmit = () => { if (!selectedTargetId) return; - const targetComp = allComponents.find((c) => c.id === selectedTargetId); + const tComp = allComponents.find((c) => c.id === selectedTargetId); const srcLabel = component.label || component.id; - const tgtLabel = targetComp?.label || targetComp?.id || "?"; + const tgtLabel = tComp?.label || tComp?.id || "?"; - onSubmit({ + const conn: Omit = { sourceComponent: component.id, sourceField: "", sourceOutput: "_auto", @@ -219,10 +288,23 @@ function SimpleConnectionForm({ targetField: "", targetInput: "_auto", label: `${srcLabel} → ${tgtLabel}`, - }); + }; + + if (isFilterConnection && isSubTable && targetColumn) { + conn.filterConfig = { + targetColumn, + filterMode: filterMode as "equals" | "contains" | "starts_with" | "range", + isSubTable: true, + }; + } + + onSubmit(conn); if (!initial) { setSelectedTargetId(""); + setIsSubTable(false); + setTargetColumn(""); + setFilterMode("equals"); } }; @@ -244,7 +326,11 @@ function SimpleConnectionForm({ 어디로? + {isFilterConnection && selectedTargetId && subTableName && ( +
+
+ { + setIsSubTable(v === true); + if (!v) setTargetColumn(""); + }} + /> + +
+ + {isSubTable && ( +
+
+ 대상 컬럼 + {loadingColumns ? ( +
+ + 컬럼 로딩 중... +
+ ) : ( + + )} +
+ +
+ 비교 방식 + +
+
+ )} +
+ )} + +
+ {hasTimeline && ( + + )} + +
{statusMap.map((m, i) => (
@@ -1708,6 +1745,22 @@ function StatusMappingsEditor({ ))} + diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx index 500af96e..5cc9afb3 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/cell-renderers.tsx @@ -10,7 +10,7 @@ import React, { useMemo, useState } from "react"; import { ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, - Loader2, Play, CheckCircle2, CircleDot, Clock, + Loader2, CheckCircle2, CircleDot, Clock, type LucideIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -19,7 +19,7 @@ import { } from "@/components/ui/dialog"; import { cn } from "@/lib/utils"; import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep } from "../types"; -import { DEFAULT_CARD_IMAGE } from "../types"; +import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types"; import type { ButtonVariant } from "../pop-button"; type RowData = Record; @@ -329,35 +329,13 @@ const STATUS_COLORS: Record = { }; function StatusBadgeCell({ cell, row }: CellRendererProps) { - const value = cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : ""); - const strValue = String(value || ""); + const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; + const effectiveValue = hasSubStatus + ? row[VIRTUAL_SUB_STATUS] + : (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : "")); + const strValue = String(effectiveValue || ""); const mapped = cell.statusMap?.find((m) => m.value === strValue); - // 접수가능 자동 판별: 하위 데이터 기반 - // 직전 항목이 done이고 현재 항목이 pending이면 "접수가능" - const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; - const isAcceptable = useMemo(() => { - if (!processFlow || strValue !== "waiting") return false; - const currentIdx = processFlow.findIndex((s) => s.isCurrent); - if (currentIdx < 0) return false; - if (currentIdx === 0) return true; - const prevStep = processFlow[currentIdx - 1]; - const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending"; - return prevSem === "done"; - }, [processFlow, strValue]); - - if (isAcceptable) { - return ( - - - 접수가능 - - ); - } - if (mapped) { return ( - {formatValue(value)} + {formatValue(effectiveValue)} ); } @@ -614,66 +592,23 @@ function TimelineCell({ cell, row }: CellRendererProps) { // ===== 11. action-buttons ===== function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) { - const statusValue = cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""); + const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined; + const statusValue = hasSubStatus + ? String(row[VIRTUAL_SUB_STATUS] || "") + : (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : "")); const rules = cell.actionRules || []; - // 접수가능 자동 판별 - const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined; - const isAcceptable = useMemo(() => { - if (!processFlow || statusValue !== "waiting") return false; - const currentIdx = processFlow.findIndex((s) => s.isCurrent); - if (currentIdx < 0) return false; - if (currentIdx === 0) return true; - const prevStep = processFlow[currentIdx - 1]; - const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending"; - return prevSem === "done"; - }, [processFlow, statusValue]); + const matchedRule = rules.find((r) => r.whenStatus === statusValue); - const effectiveStatus = isAcceptable ? "acceptable" : statusValue; - const matchedRule = rules.find((r) => r.whenStatus === effectiveStatus) - || rules.find((r) => r.whenStatus === statusValue); - - // 매칭 규칙이 없을 때 기본 동작 if (!matchedRule) { - if (isAcceptable) { - return ( -
- -
- ); - } - if (statusValue === "in_progress") { - return ( -
- -
- ); - } return null; } + // __processFlow__에서 isCurrent 공정의 processId 추출 + const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined; + const currentProcess = processFlow?.find((s) => s.isCurrent); + const currentProcessId = currentProcess?.processId; + return (
{matchedRule.buttons.map((btn, idx) => ( @@ -684,7 +619,11 @@ function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps className="h-7 text-[10px]" onClick={(e) => { e.stopPropagation(); - onActionButtonClick?.(btn.taskPreset, row, btn as Record); + const config = { ...(btn as Record) }; + if (currentProcessId !== undefined) { + config.__processId = currentProcessId; + } + onActionButtonClick?.(btn.taskPreset, row, config); }} > {btn.label} diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index 7db10988..a878bb2b 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -89,13 +89,23 @@ export function PopSearchComponent({ return "contains"; }, [config.filterMode, config.dateSelectionMode, normalizedType]); + // status-chip: 연결된 카드 컴포넌트의 전체 rows + 메타 수신 + const [allRows, setAllRows] = useState[]>([]); + const [autoSubStatusColumn, setAutoSubStatusColumn] = useState(null); + const emitFilterChanged = useCallback( (newValue: unknown) => { setValue(newValue); setSharedData(`search_${fieldKey}`, newValue); if (componentId) { - const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; + const baseColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; + const chipCfg = config.statusChipConfig; + // 카드가 전달한 subStatusColumn이 있으면 자동으로 하위 필터 컬럼 추가 + const subActive = chipCfg?.useSubCount && !!autoSubStatusColumn; + const filterColumns = subActive + ? [...new Set([...baseColumns, autoSubStatusColumn!])] + : baseColumns; publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, filterColumns, @@ -106,7 +116,7 @@ export function PopSearchComponent({ publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] + [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns, config.statusChipConfig, autoSubStatusColumn] ); useEffect(() => { @@ -149,19 +159,25 @@ export function PopSearchComponent({ return unsub; }, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]); - // status-chip: 연결된 카드 컴포넌트의 전체 rows 수신 - const [allRows, setAllRows] = useState[]>([]); - useEffect(() => { if (!componentId || normalizedType !== "status-chip") return; const unsub = subscribe( `__comp_input__${componentId}__all_rows`, (payload: unknown) => { const data = payload as { value?: unknown } | unknown; - const rows = (typeof data === "object" && data && "value" in data) + const inner = (typeof data === "object" && data && "value" in data) ? (data as { value: unknown }).value : data; - if (Array.isArray(rows)) setAllRows(rows); + + // 카드가 { rows, subStatusColumn } 형태로 발행하는 경우 메타 추출 + if (typeof inner === "object" && inner && !Array.isArray(inner) && "rows" in inner) { + const envelope = inner as { rows?: unknown; subStatusColumn?: string | null }; + if (Array.isArray(envelope.rows)) setAllRows(envelope.rows as Record[]); + setAutoSubStatusColumn(envelope.subStatusColumn ?? null); + } else if (Array.isArray(inner)) { + setAllRows(inner as Record[]); + setAutoSubStatusColumn(null); + } } ); return unsub; @@ -210,6 +226,7 @@ export function PopSearchComponent({ onModalOpen={handleModalOpen} onModalClear={handleModalClear} allRows={allRows} + autoSubStatusColumn={autoSubStatusColumn} />
@@ -241,9 +258,10 @@ interface InputRendererProps { interface InputRendererPropsExt extends InputRendererProps { allRows?: Record[]; + autoSubStatusColumn?: string | null; } -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows }: InputRendererPropsExt) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows, autoSubStatusColumn }: InputRendererPropsExt) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -264,7 +282,7 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa case "modal": return ; case "status-chip": - return ; + return ; default: return ; } @@ -687,30 +705,36 @@ function StatusChipInput({ value, onChange, allRows, + autoSubStatusColumn, }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void; allRows: Record[]; + autoSubStatusColumn: string | null; }) { const chipCfg: StatusChipConfig = config.statusChipConfig || {}; const chipStyle = chipCfg.chipStyle || "tab"; const showCount = chipCfg.showCount !== false; - const countColumn = chipCfg.countColumn || config.fieldName || ""; + const baseCountColumn = chipCfg.countColumn || config.fieldName || ""; + const useSubCount = chipCfg.useSubCount || false; const allowAll = chipCfg.allowAll !== false; const allLabel = chipCfg.allLabel || "전체"; const options: SelectOption[] = config.options || []; + // 카드가 전달한 가상 컬럼명이 있으면 자동 사용 + const effectiveCountColumn = (useSubCount && autoSubStatusColumn) ? autoSubStatusColumn : baseCountColumn; + const counts = useMemo(() => { - if (!showCount || !countColumn || allRows.length === 0) return new Map(); + if (!showCount || !effectiveCountColumn || allRows.length === 0) return new Map(); const map = new Map(); for (const row of allRows) { - const v = String(row[countColumn] ?? ""); + const v = String(row[effectiveCountColumn] ?? ""); map.set(v, (map.get(v) || 0) + 1); } return map; - }, [allRows, countColumn, showCount]); + }, [allRows, effectiveCountColumn, showCount]); const totalCount = allRows.length; diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index eac031c3..7c6b98c2 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -1168,6 +1168,26 @@ function StatusChipDetailSettings({ cfg, update, allComponents, connections, com
)} + {chipCfg.showCount !== false && ( +
+
+ updateChip({ useSubCount: Boolean(checked) })} + /> + +
+ {chipCfg.useSubCount && ( +

+ 연결된 카드의 하위 테이블 필터가 적용되면 집계 컬럼이 자동 전환됩니다 +

+ )} +
+ )} + {/* 칩 스타일 */}
diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index 9157e024..d8a15fc2 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -89,6 +89,8 @@ export interface StatusChipConfig { allowAll?: boolean; allLabel?: string; chipStyle?: StatusChipStyle; + /** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */ + useSubCount?: boolean; } /** pop-search 전체 설정 */ diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index 8d478ff3..e883202f 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -747,9 +747,11 @@ export type CardCellType = export interface TimelineProcessStep { seqNo: number; processName: string; - status: string; // DB 원본 값 + status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값) semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정) isCurrent: boolean; + processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용) + rawData?: Record; // 하위 테이블 원본 행 (하위 필터 매칭용) } // timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정 @@ -767,9 +769,10 @@ export interface TimelineDataSource { export type TimelineStatusSemantic = "pending" | "active" | "done"; export interface StatusValueMapping { - dbValue: string; // DB에 저장된 실제 값 + dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값) label: string; // 화면에 보이는 이름 semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록) + isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환) } export interface CardCellDefinitionV2 { @@ -817,7 +820,7 @@ export interface CardCellDefinitionV2 { cartIconType?: "lucide" | "emoji"; cartIconValue?: string; - // status-badge 타입 전용 (CARD-3에서 구현) + // status-badge 타입 전용 statusColumn?: string; statusMap?: Array<{ value: string; label: string; color: string }>; @@ -902,3 +905,9 @@ export interface PopCardListV2Config { cartListMode?: CartListModeConfig; saveMapping?: CardListSaveMapping; } + +/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */ +export const VIRTUAL_SUB_STATUS = "__subStatus__" as const; +export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const; +export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const; +export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;