From 12ccb85308ee29819340ce6b70dba92697900ac2 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Mar 2026 12:07:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop):=20=EA=B3=B5=EC=A0=95=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0=20+=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?+=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=EB=B0=B0=EC=A7=80=20=EA=B3=B5=EC=A0=95=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B1=83=EC=A7=80/=EC=B9=B4=EC=9A=B4=ED=8A=B8/?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=9D=B4=20=EA=B3=B5=EC=A0=95=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=ED=95=98=EB=8F=84=EB=A1=9D=20=ED=8C=8C=EC=83=9D=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0,?= =?UTF-8?q?=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20=5F=5FsubStatus=5F?= =?UTF-8?q?=5F=20=EC=A3=BC=EC=9E=85,=20=EC=A0=91=EC=88=98=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EA=B3=B5=EC=A0=95=20=ED=96=89=20=ED=8A=B9=EC=A0=95?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20[=ED=8C=8C=EC=83=9D=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0]=20-=20types.ts:=20Stat?= =?UTF-8?q?usValueMapping.isDerived=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20=20isDerived=3Dtrue=EB=A9=B4=20DB=EC=97=90=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=83=81=ED=83=9C=EB=A1=9C,=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=20=EA=B3=B5=EC=A0=95=20=EC=99=84=EB=A3=8C=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9E=90=EB=8F=99=20=EB=B3=80=ED=99=98=20-=20PopCardListV2C?= =?UTF-8?q?omponent:=20injectProcessFlow=EC=97=90=20derivedRules=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=B3=80=ED=99=98=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=20=20=EA=B0=99=EC=9D=80=20semantic=EC=9D=98=20=EC=9B=90?= =?UTF-8?q?=EB=B3=B8=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=B6=94=EB=A1=A0=20(waiting=20=E2=86=92=20acceptable)=20-=20T?= =?UTF-8?q?imelineProcessStep=EC=97=90=20processId,=20rawData=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20[=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=5F=5FsubStatus=5F=5F=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?]=20-=20PopCardListV2Component:=20filteredRows=EB=A5=BC=202?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=20=201?= =?UTF-8?q?=EB=8B=A8=EA=B3=84:=20=ED=95=98=EC=9C=84=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94(work=5Forder=5Fprocess)=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=E2=86=92=20=EB=A7=A4=EC=B9=AD=20=EA=B3=B5=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A5=BC=20=20=20VIRTUAL=5FSUB=5FSTATUS/SEMA?= =?UTF-8?q?NTIC/PROCESS/SEQ=20=EA=B0=80=EC=83=81=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A3=BC=EC=9E=85=20=20=202=EB=8B=A8?= =?UTF-8?q?=EA=B3=84:=20=EB=A9=94=EC=9D=B8=20=ED=95=84=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20status=20=EC=BB=AC=EB=9F=BC=EC=9D=84=20=5F=5FsubSta?= =?UTF-8?q?tus=5F=5F=EB=A1=9C=20=EC=9E=90=EB=8F=99=20=EB=8C=80=EC=B2=B4=20?= =?UTF-8?q?-=20cell-renderers:=20StatusBadgeCell/ActionButtonsCell?= =?UTF-8?q?=EC=9D=B4=20=5F=5FsubStatus=5F=5F=20=EC=9A=B0=EC=84=A0=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=20=20=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EB=90=9C=20=EC=A0=91=EC=88=98=EA=B0=80=EB=8A=A5=20=ED=8C=90?= =?UTF-8?q?=EB=B3=84=20=EB=A1=9C=EC=A7=81(isAcceptable)=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=E2=86=92=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20-=20all=5Frows=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89:=20{=20rows,=20subStatusColumn=20}=20envelop?= =?UTF-8?q?e=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=A9=94=ED=83=80=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=20[=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0(isCurrent)=20=EA=B0=9C=EC=84=A0]=20-=20"?= =?UTF-8?q?=EA=B8=B0=EC=A4=80"=20=EC=83=81=ED=83=9C(isDerived)=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EA=B0=95=EC=A1=B0=20+=20=EA=B3=B5=EC=A0=95=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EC=8B=9C=20=EB=A7=A4=EC=B9=AD=20=EA=B3=B5?= =?UTF-8?q?=EC=A0=95=20=EA=B0=95=EC=A1=B0=20-=20=ED=8F=B4=EB=B0=B1:=20acti?= =?UTF-8?q?ve=20=E2=86=92=20pending=20=EC=88=9C=EC=84=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B2=B0=EC=A0=95=20[=EC=A0=91=EC=88=98?= =?UTF-8?q?=20=EB=B2=84=ED=8A=BC=20=EA=B3=B5=EC=A0=95=20=ED=96=89=20?= =?UTF-8?q?=ED=8A=B9=EC=A0=95]=20-=20cell-renderers:=20ActionButtonsCell?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=98=84=EC=9E=AC=20=EA=B3=B5=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20processId=EB=A5=BC=20=5F=5FprocessId=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=EB=8B=AC=20-=20PopCardListV2Component:=20onActionButt?= =?UTF-8?q?onClick=EC=97=90=EC=84=9C=20=5F=5FprocessId=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=ED=96=89=20UPDATE=20[=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=B0=B0=EC=A7=80=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99]=20-=20PopCardListV2Config:=20StatusMappingE?= =?UTF-8?q?ditor=EC=97=90=20"=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99"=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=20=20=EA=B0=99=EC=9D=80=20=EC=B9=B4=EB=93=9C=EC=9D=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20statusMappings=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EA=B0=92/=EB=9D=BC=EB=B2=A8/=EC=83=89=EC=83=81/?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EC=9E=90=EB=8F=99=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=B4=20[=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20UI]=20-=20PopCardListV2Config:=20StatusMappingsEdit?= =?UTF-8?q?or=EC=97=90=20"=EA=B8=B0=EC=A4=80"=20=EB=9D=BC=EB=94=94?= =?UTF-8?q?=EC=98=A4=20=EB=B2=84=ED=8A=BC=20=EC=B6=94=EA=B0=80=20=20=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A7=8C=20=EC=84=A0=ED=83=9D=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5,=20=EC=9E=AC=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=20[=EC=97=B0=EA=B2=B0=20=ED=83=AD=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=EC=84=A4=EC=A0=95]=20-=20ConnectionEditor:=20isSub?= =?UTF-8?q?Table=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20+=20targetColumn?= =?UTF-8?q?/filterMode=20=EC=84=A4=EC=A0=95=20UI=20-=20pop-layout.ts:=20fi?= =?UTF-8?q?lterConfig.isSubTable=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20[status-chip=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98]=20-=20PopSearchCompone?= =?UTF-8?q?nt:=20=EC=B9=B4=EB=93=9C=EA=B0=80=20=EC=A0=84=EB=8B=AC=ED=95=9C?= =?UTF-8?q?=20subStatusColumn=20=EC=9E=90=EB=8F=99=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=20=20useSubCount=20=ED=99=9C=EC=84=B1=20=EC=8B=9C=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84/=ED=95=84=ED=84=B0=20=EC=BB=AC=EB=9F=BC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=ED=99=98=20-=20PopSearchConfig:=20useSubC?= =?UTF-8?q?ount=20=EC=B2=B4=ED=81=AC=EB=B0=95=EC=8A=A4=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20UI=20-=20types.ts:=20StatusChipConfig.useSubCount?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20[=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B4=EB=84=88=20=EB=9D=BC=EB=B2=A8]=20-=20Compone?= =?UTF-8?q?ntEditorPanel:=20comp.label=20||=20comp.id=20=ED=8C=A8=ED=84=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../designer/panels/ComponentEditorPanel.tsx | 4 +- .../pop/designer/panels/ConnectionEditor.tsx | 159 ++++++++++- .../pop/designer/types/pop-layout.ts | 1 + .../PopCardListV2Component.tsx | 249 +++++++++++------- .../pop-card-list-v2/PopCardListV2Config.tsx | 61 ++++- .../pop-card-list-v2/cell-renderers.tsx | 107 ++------ .../pop-search/PopSearchComponent.tsx | 50 +++- .../pop-search/PopSearchConfig.tsx | 20 ++ .../pop-components/pop-search/types.ts | 2 + frontend/lib/registry/pop-components/types.ts | 15 +- 10 files changed, 463 insertions(+), 205 deletions(-) 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;