From c7b8acbac3d92b4a314cd69fdaef6df05ff60e80 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Wed, 11 Mar 2026 16:35:49 +0900 Subject: [PATCH] =?UTF-8?q?refactor(pop):=20status-chip=EC=9D=84=20pop-sta?= =?UTF-8?q?tus-bar=20=EB=8F=85=EB=A6=BD=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20+=20=EC=B9=B4?= =?UTF-8?q?=EC=9A=B4=ED=8A=B8=20=EC=88=9C=ED=99=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20pop-search=EC=97=90=20=EB=82=B4=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EC=96=B4=20=EC=9E=88=EB=8D=98=20status-chip=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=9D=84=20pop-status-bar=EB=9D=BC=EB=8A=94?= =?UTF-8?q?=20=EB=8F=85=EB=A6=BD=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=98=EC=97=AC=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=84=B1=EA=B3=BC=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=9C=A0=EC=97=B0=EC=84=B1=EC=9D=84=20=EB=86=92=EC=9D=B8?= =?UTF-8?q?=EB=8B=A4.=20=EC=83=81=ED=83=9C=20=EC=B9=A9=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=20=EC=8B=9C=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=EA=B0=80=20?= =?UTF-8?q?=EC=99=9C=EA=B3=A1=EB=90=98=EB=8D=98=20=EC=88=9C=ED=99=98=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4.=20[pop-status-bar=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8]=20-=20types.t?= =?UTF-8?q?s:=20StatusBarConfig,=20StatusChipOption,=20hiddenMessage=20?= =?UTF-8?q?=EB=93=B1=20=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98=20-=20PopSt?= =?UTF-8?q?atusBarComponent:=20all=5Frows=20=EA=B5=AC=EB=8F=85=20+=20?= =?UTF-8?q?=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=A7=91=EA=B3=84=20+=20filter?= =?UTF-8?q?=5Fvalue=20=EB=B0=9C=ED=96=89=20=20=20=5Fsource:=20"status-bar"?= =?UTF-8?q?=20=EB=A7=88=EC=BB=A4=EB=A1=9C=20=EC=9E=90=EC=8B=A0=EC=9D=98=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=8B=9D=EB=B3=84=20=20=20hideUn?= =?UTF-8?q?tilSubFilter:=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=A0=84=20=EC=B9=A9=20=EC=88=A8=EA=B9=80?= =?UTF-8?q?=20+=20=EC=84=A4=EC=A0=95=20=EA=B0=80=EB=8A=A5=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20-=20PopStatusBarConfig:=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=20(DB=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B1=84=EC=9A=B0=EA=B8=B0,=20=EA=B3=A0=EC=9C=A0?= =?UTF-8?q?=EA=B0=92=20=ED=81=B4=EB=A6=AD=20=EC=B6=94=EA=B0=80,=20=20=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=EB=AC=B8=EA=B5=AC=20=EC=84=A4=EC=A0=95,?= =?UTF-8?q?=20=ED=95=98=EC=9C=84=20=ED=95=84=ED=84=B0=20=EA=B0=80=EC=83=81?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EC=95=88=EB=82=B4)=20-=20index.tsx:=20?= =?UTF-8?q?=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D,=20connectionMeta(filter=5Fvalue/all=5Frows/set=5Fval?= =?UTF-8?q?ue)=20[=EC=B9=B4=EC=9A=B4=ED=8A=B8=20=EC=88=9C=ED=99=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95]=20-=20PopCardListV2Com?= =?UTF-8?q?ponent:=20externalFilters=EC=97=90=20=5Fsource=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=80=EC=9E=A5=20=20=20all=5Frows=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=20status-bar=20=EC=86=8C=EC=8A=A4=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A5=BC=20=EC=A0=9C=EC=99=B8=ED=95=9C=20row?= =?UTF-8?q?sForStatusCount=20=EA=B3=84=EC=82=B0=20=20=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=20=EC=B9=A9=20=ED=81=B4=EB=A6=AD=ED=95=B4=EB=8F=84=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=EA=B0=80=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EB=90=A8=20[pop-search=EC=97=90=EC=84=9C=20status-chi?= =?UTF-8?q?p=20=EC=A0=9C=EA=B1=B0]=20-=20PopSearchComponent:=20StatusChipI?= =?UTF-8?q?nput,=20allRows=20=EA=B5=AC=EB=8F=85,=20autoSubStatusColumn=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20-=20PopSearchConfig:=20StatusChipDetailSet?= =?UTF-8?q?tings=20=EC=A0=9C=EA=B1=B0,=20=EB=B6=84=EB=A6=AC=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=A9=94=EC=8B=9C=EC=A7=80=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4=20-=20index.tsx:=20receivable=EC=97=90=EC=84=9C=20all?= =?UTF-8?q?=5Frows=20=EC=A0=9C=EA=B1=B0=20-=20types.ts:=20StatusChipStyle,?= =?UTF-8?q?=20StatusChipConfig=EC=97=90=20@deprecated=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20[=EC=84=A4=EC=A0=95=20UX=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0]=20-=20"=EC=A0=84=EC=B2=B4=20=EC=B9=A9=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=B6=94=EA=B0=80"=20=E2=86=92=20"=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=B3=B4=EA=B8=B0=20=EC=B9=A9=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?"=20+=20=EC=84=A4=EB=AA=85=20=EB=AC=B8=EA=B5=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20hiddenMessage:=20=EC=88=A8=EA=B9=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=95=88=EB=82=B4=20=EB=AC=B8=EA=B5=AC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B0=80=EB=8A=A5=20(=ED=95=98=EB=93=9C=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=EC=A0=9C=EA=B1=B0)=20-=20useSubCount=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=EA=B0=80=EC=83=81=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=20=EC=95=88=EB=82=B4=20=EA=B2=BD=EA=B3=A0=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/registry/pop-components/index.ts | 1 + .../PopCardListV2Component.tsx | 400 +++++++++++++- .../pop-search/PopSearchComponent.tsx | 173 +------ .../pop-search/PopSearchConfig.tsx | 156 +----- .../pop-components/pop-search/index.tsx | 1 - .../pop-components/pop-search/types.ts | 5 +- .../pop-status-bar/PopStatusBarComponent.tsx | 243 +++++++++ .../pop-status-bar/PopStatusBarConfig.tsx | 489 ++++++++++++++++++ .../pop-components/pop-status-bar/index.tsx | 87 ++++ .../pop-components/pop-status-bar/types.ts | 48 ++ 10 files changed, 1273 insertions(+), 330 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx create mode 100644 frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx create mode 100644 frontend/lib/registry/pop-components/pop-status-bar/index.tsx create mode 100644 frontend/lib/registry/pop-components/pop-status-bar/types.ts diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index 26436d86..351d6700 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -21,6 +21,7 @@ import "./pop-card-list-v2"; import "./pop-button"; import "./pop-string-list"; import "./pop-search"; +import "./pop-status-bar"; import "./pop-field"; import "./pop-scanner"; diff --git a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx index a14e3635..5a424d4e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list-v2/PopCardListV2Component.tsx @@ -10,10 +10,14 @@ import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useRouter } from "next/navigation"; import { - Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, + 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, @@ -30,6 +34,8 @@ import type { TimelineDataSource, ActionButtonUpdate, StatusValueMapping, + SelectModeConfig, + SelectModeButtonConfig, } from "../types"; import { CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE, @@ -42,6 +48,10 @@ 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; @@ -136,6 +146,7 @@ export function PopCardListV2Component({ fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean }; + _source?: string; }> >(new Map()); @@ -145,7 +156,7 @@ export function PopCardListV2Component({ `__comp_input__${componentId}__filter_condition`, (payload: unknown) => { const data = payload as { - value?: { fieldName?: string; value?: unknown }; + value?: { fieldName?: string; value?: unknown; _source?: string }; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean }; _connectionId?: string; }; @@ -157,6 +168,7 @@ export function PopCardListV2Component({ fieldName: data.value.fieldName || "", value: data.value.value, filterConfig: data.filterConfig, + _source: data.value._source, }); } else { next.delete(connId); @@ -199,6 +211,73 @@ export function PopCardListV2Component({ 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); @@ -341,14 +420,176 @@ export function PopCardListV2Component({ return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable); }, [externalFilters]); - // 필터 적용된 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용) + // 선택 모드 일괄 처리 + 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: filteredRows, + rows: rowsForStatusCount, subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null, }); - }, [componentId, filteredRows, loading, publish, hasActiveSubFilter]); + }, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]); const overflowCfg = effectiveConfig?.overflow; const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows; @@ -571,6 +812,9 @@ export function PopCardListV2Component({ } finally { setLoading(false); } }, [dataSource, timelineSource, injectProcessFlow]); + const fetchDataRef = useRef(fetchData); + fetchDataRef.current = fetchData; + useEffect(() => { if (isCartListMode) { const cartListMode = config!.cartListMode!; @@ -701,7 +945,31 @@ export function PopCardListV2Component({ ) : ( <> - {isCartListMode && ( + {/* 선택 모드 상단 바 */} + {selectMode && ( +
+
+
+ {selectedRowIds.size} +
+ + {selectedRowIds.size > 0 ? `${selectedRowIds.size}개 선택됨` : "카드를 선택하세요"} + +
+ +
+ )} + + {/* 장바구니 모드 상단 바 */} + {!selectMode && isCartListMode && (
toggleRowSelection(row)} + onEnterSelectMode={enterSelectMode} + onOpenPopModal={openPopModal} /> ))}
- {hasMoreCards && ( + {/* 선택 모드 하단 액션 바 */} + {selectMode && selectModeConfig && ( +
+
+ {selectModeConfig.buttons.map((btn, idx) => ( + + ))} +
+
+ )} + + {/* 더보기/페이지네이션 */} + {!selectMode && hasMoreCards && (
@@ -778,6 +1076,31 @@ export function PopCardListV2Component({ )} )} + + {/* POP 화면 모달 */} + { + setPopModalOpen(open); + if (!open) { + setPopModalLayout(null); + setPopModalRow(null); + } + }}> + + + 상세 작업 + +
+ {popModalLayout && ( + + )} +
+
+
); } @@ -799,12 +1122,20 @@ interface CardV2Props { 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; @@ -882,9 +1213,15 @@ function CardV2({ } 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"; + 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 ( @@ -917,13 +1254,38 @@ function CardV2({
onSelect?.(row)} + onClick={() => { + if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } + if (!selectMode) onSelect?.(row); + }} role="button" tabIndex={0} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect?.(row); }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + if (selectMode && isSelectable) { onToggleRowSelect?.(); return; } + if (!selectMode) onSelect?.(row); + } + }} > + {/* 선택 모드: 체크 인디케이터 */} + {selectMode && isSelectable && ( +
+
{ e.stopPropagation(); onToggleRowSelect?.(); }} + > + {isSelectModeSelected && } +
+
+ )} + {/* 장바구니 목록 모드: 체크박스 + 삭제 */} - {isCartListMode && ( + {!selectMode && isCartListMode && (
{ const cfg = buttonConfig as { updates?: ActionButtonUpdate[]; @@ -993,6 +1356,9 @@ function CardV2({ 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 } @@ -1013,6 +1379,13 @@ function CardV2({ } 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, @@ -1040,6 +1413,7 @@ function CardV2({ onConfirm={handleInputConfirm} /> )} +
); } diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx index a878bb2b..f019eb41 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -33,12 +33,9 @@ import type { PopSearchConfig, DatePresetOption, DateSelectionMode, - CalendarDisplayMode, ModalSelectConfig, ModalSearchMode, ModalFilterTab, - SelectOption, - StatusChipConfig, } from "./types"; import { DATE_PRESET_LABELS, @@ -89,9 +86,6 @@ 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) => { @@ -99,13 +93,7 @@ export function PopSearchComponent({ setSharedData(`search_${fieldKey}`, newValue); if (componentId) { - 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; + const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey]; publish(`__comp_output__${componentId}__filter_value`, { fieldName: fieldKey, filterColumns, @@ -116,7 +104,7 @@ export function PopSearchComponent({ publish("filter_changed", { [fieldKey]: newValue }); }, - [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns, config.statusChipConfig, autoSubStatusColumn] + [fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns] ); useEffect(() => { @@ -159,30 +147,6 @@ export function PopSearchComponent({ return unsub; }, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]); - 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 inner = (typeof data === "object" && data && "value" in data) - ? (data as { value: unknown }).value - : data; - - // 카드가 { 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; - }, [componentId, subscribe, normalizedType]); - const handleModalOpen = useCallback(() => { if (!config.modalConfig) return; setSimpleModalOpen(true); @@ -225,8 +189,6 @@ export function PopSearchComponent({ modalDisplayText={modalDisplayText} onModalOpen={handleModalOpen} onModalClear={handleModalClear} - allRows={allRows} - autoSubStatusColumn={autoSubStatusColumn} />
@@ -256,12 +218,7 @@ interface InputRendererProps { onModalClear?: () => void; } -interface InputRendererPropsExt extends InputRendererProps { - allRows?: Record[]; - autoSubStatusColumn?: string | null; -} - -function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows, autoSubStatusColumn }: InputRendererPropsExt) { +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) { const normalized = normalizeInputType(config.inputType as string); switch (normalized) { case "text": @@ -282,7 +239,11 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa case "modal": return ; case "status-chip": - return ; + return ( +
+ pop-status-bar 컴포넌트를 사용하세요 +
+ ); default: return ; } @@ -696,124 +657,6 @@ function ModalSearchInput({ config, displayText, onClick, onClear }: { config: P ); } -// ======================================== -// status-chip 서브타입 -// ======================================== - -function StatusChipInput({ - config, - 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 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 || !effectiveCountColumn || allRows.length === 0) return new Map(); - const map = new Map(); - for (const row of allRows) { - const v = String(row[effectiveCountColumn] ?? ""); - map.set(v, (map.get(v) || 0) + 1); - } - return map; - }, [allRows, effectiveCountColumn, showCount]); - - const totalCount = allRows.length; - - const chipItems: { value: string; label: string; count: number }[] = useMemo(() => { - const items: { value: string; label: string; count: number }[] = []; - if (allowAll) { - items.push({ value: "", label: allLabel, count: totalCount }); - } - for (const opt of options) { - items.push({ - value: opt.value, - label: opt.label, - count: counts.get(opt.value) || 0, - }); - } - return items; - }, [options, counts, totalCount, allowAll, allLabel]); - - if (chipStyle === "pill") { - return ( -
- {chipItems.map((item) => { - const isActive = value === item.value; - return ( - - ); - })} -
- ); - } - - // tab 스타일 (기본) - return ( -
- {chipItems.map((item) => { - const isActive = value === item.value; - return ( - - ); - })} -
- ); -} - // ======================================== // 미구현 서브타입 플레이스홀더 // ======================================== diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 7c6b98c2..8c619429 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -38,8 +38,6 @@ import type { ModalDisplayStyle, ModalSearchMode, ModalFilterTab, - StatusChipStyle, - StatusChipConfig, } from "./types"; import { SEARCH_INPUT_TYPE_LABELS, @@ -48,7 +46,6 @@ import { MODAL_DISPLAY_STYLE_LABELS, MODAL_SEARCH_MODE_LABELS, MODAL_FILTER_TAB_LABELS, - STATUS_CHIP_STYLE_LABELS, normalizeInputType, } from "./types"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; @@ -235,7 +232,14 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component case "modal": return ; case "status-chip": - return ; + return ( +
+

+ 상태 칩은 pop-status-bar 컴포넌트로 분리되었습니다. + 새로운 "상태 바" 컴포넌트를 사용해주세요. +

+
+ ); case "toggle": return (
@@ -1072,147 +1076,3 @@ function ModalDetailSettings({ cfg, update }: StepProps) { ); } -// ======================================== -// status-chip 상세 설정 -// ======================================== - -function StatusChipDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) { - const chipCfg: StatusChipConfig = cfg.statusChipConfig || {}; - const options = cfg.options || []; - - const updateChip = (partial: Partial) => { - update({ statusChipConfig: { ...chipCfg, ...partial } }); - }; - - const addOption = () => { - update({ - options: [...options, { value: `status_${options.length + 1}`, label: `상태 ${options.length + 1}` }], - }); - }; - - const removeOption = (index: number) => { - update({ options: options.filter((_, i) => i !== index) }); - }; - - const updateOption = (index: number, field: "value" | "label", val: string) => { - update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) }); - }; - - return ( -
- {/* 칩 옵션 목록 */} -
- - {options.length === 0 && ( -

옵션이 없습니다. 아래 버튼으로 추가하세요.

- )} - {options.map((opt, i) => ( -
- updateOption(i, "value", e.target.value)} placeholder="DB 값" className="h-7 flex-1 text-[10px]" /> - updateOption(i, "label", e.target.value)} placeholder="표시 라벨" className="h-7 flex-1 text-[10px]" /> - -
- ))} - -
- - {/* 전체 칩 자동 추가 */} -
- updateChip({ allowAll: Boolean(checked) })} - /> - -
- - {chipCfg.allowAll !== false && ( -
- - updateChip({ allLabel: e.target.value })} - placeholder="전체" - className="h-8 text-xs" - /> -
- )} - - {/* 건수 표시 */} -
- updateChip({ showCount: Boolean(checked) })} - /> - -
- - {chipCfg.showCount !== false && ( -
- - updateChip({ countColumn: e.target.value })} - placeholder="예: status" - className="h-8 text-xs" - /> -

- 연결된 카드의 이 컬럼 값으로 상태별 건수를 집계합니다 -

-
- )} - - {chipCfg.showCount !== false && ( -
-
- updateChip({ useSubCount: Boolean(checked) })} - /> - -
- {chipCfg.useSubCount && ( -

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

- )} -
- )} - - {/* 칩 스타일 */} -
- - -

- 탭: 큰 숫자 + 라벨 / 알약: 작은 뱃지 형태 -

-
- - {/* 필터 연결 */} - -
- ); -} diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx index fadf0bd7..e78dd11c 100644 --- a/frontend/lib/registry/pop-components/pop-search/index.tsx +++ b/frontend/lib/registry/pop-components/pop-search/index.tsx @@ -40,7 +40,6 @@ PopComponentRegistry.registerComponent({ ], receivable: [ { key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, - { key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "연결된 카드의 전체 데이터를 받아 상태 칩 건수 표시" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts index d8a15fc2..6b284b60 100644 --- a/frontend/lib/registry/pop-components/pop-search/types.ts +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -79,17 +79,16 @@ export interface ModalSelectConfig { distinct?: boolean; } -/** 상태 칩 표시 스타일 */ +/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */ export type StatusChipStyle = "tab" | "pill"; -/** status-chip 전용 설정 */ +/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */ export interface StatusChipConfig { showCount?: boolean; countColumn?: string; allowAll?: boolean; allLabel?: string; chipStyle?: StatusChipStyle; - /** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */ useSubCount?: boolean; } diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx new file mode 100644 index 00000000..805fadcd --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { usePopEvent } from "@/hooks/pop"; +import type { StatusBarConfig, StatusChipOption } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG } from "./types"; + +interface PopStatusBarComponentProps { + config: StatusBarConfig; + label?: string; + screenId?: string; + componentId?: string; +} + +export function PopStatusBarComponent({ + config: rawConfig, + label, + screenId, + componentId, +}: PopStatusBarComponentProps) { + const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) }; + const { publish, subscribe } = usePopEvent(screenId || ""); + + const [selectedValue, setSelectedValue] = useState(""); + const [allRows, setAllRows] = useState[]>([]); + const [autoSubStatusColumn, setAutoSubStatusColumn] = useState(null); + + // all_rows 이벤트 구독 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__all_rows`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const inner = + typeof data === "object" && data && "value" in data + ? (data as { value: unknown }).value + : data; + + 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; + }, [componentId, subscribe]); + + // 외부에서 값 설정 이벤트 구독 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__set_value`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const incoming = + typeof data === "object" && data && "value" in data + ? (data as { value: unknown }).value + : data; + setSelectedValue(String(incoming ?? "")); + } + ); + return unsub; + }, [componentId, subscribe]); + + const emitFilter = useCallback( + (newValue: string) => { + setSelectedValue(newValue); + if (!componentId) return; + + const baseColumn = config.filterColumn || config.countColumn || ""; + const subActive = config.useSubCount && !!autoSubStatusColumn; + const filterColumns = subActive + ? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))] + : [baseColumn].filter(Boolean); + + publish(`__comp_output__${componentId}__filter_value`, { + fieldName: baseColumn, + filterColumns, + value: newValue, + filterMode: "equals", + _source: "status-bar", + }); + }, + [componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn] + ); + + const chipCfg = config; + const showCount = chipCfg.showCount !== false; + const baseCountColumn = chipCfg.countColumn || ""; + const useSubCount = chipCfg.useSubCount || false; + const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false; + const allowAll = chipCfg.allowAll !== false; + const allLabel = chipCfg.allLabel || "전체"; + const chipStyle = chipCfg.chipStyle || "tab"; + const options: StatusChipOption[] = chipCfg.options || []; + + // 하위 필터(공정) 활성 여부 + const subFilterActive = useSubCount && !!autoSubStatusColumn; + + // hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김 + const shouldHide = hideUntilSubFilter && !subFilterActive; + + const effectiveCountColumn = + subFilterActive ? autoSubStatusColumn : baseCountColumn; + + const counts = useMemo(() => { + if (!showCount || !effectiveCountColumn || allRows.length === 0) + return new Map(); + const map = new Map(); + for (const row of allRows) { + if (row == null || typeof row !== "object") continue; + const v = String(row[effectiveCountColumn] ?? ""); + map.set(v, (map.get(v) || 0) + 1); + } + return map; + }, [allRows, effectiveCountColumn, showCount]); + + const totalCount = allRows.length; + + const chipItems = useMemo(() => { + const items: { value: string; label: string; count: number }[] = []; + if (allowAll) { + items.push({ value: "", label: allLabel, count: totalCount }); + } + for (const opt of options) { + items.push({ + value: opt.value, + label: opt.label, + count: counts.get(opt.value) || 0, + }); + } + return items; + }, [options, counts, totalCount, allowAll, allLabel]); + + const showLabel = !!label; + + if (shouldHide) { + return ( +
+ + {chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"} + +
+ ); + } + + if (chipStyle === "pill") { + return ( +
+ {showLabel && ( + + {label} + + )} +
+ {chipItems.map((item) => { + const isActive = selectedValue === item.value; + return ( + + ); + })} +
+
+ ); + } + + // tab 스타일 (기본) + return ( +
+ {showLabel && ( + + {label} + + )} +
+ {chipItems.map((item) => { + const isActive = selectedValue === item.value; + return ( + + ); + })} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx new file mode 100644 index 00000000..3b0ce864 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarConfig.tsx @@ -0,0 +1,489 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Trash2, Loader2, AlertTriangle, RefreshCw } from "lucide-react"; +import { getTableColumns } from "@/lib/api/tableManagement"; +import { dataApi } from "@/lib/api/data"; +import type { ColumnTypeInfo } from "@/lib/api/tableManagement"; +import type { StatusBarConfig, StatusChipStyle, StatusChipOption } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types"; + +interface ConfigPanelProps { + config: StatusBarConfig | undefined; + onUpdate: (config: StatusBarConfig) => void; + allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinitionV5[]; + connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[]; + componentId?: string; +} + +export function PopStatusBarConfigPanel({ + config: rawConfig, + onUpdate, + allComponents, + connections, + componentId, +}: ConfigPanelProps) { + const cfg = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + const options = cfg.options || []; + + const removeOption = (index: number) => { + update({ options: options.filter((_, i) => i !== index) }); + }; + + const updateOption = ( + index: number, + field: keyof StatusChipOption, + val: string + ) => { + update({ + options: options.map((opt, i) => + i === index ? { ...opt, [field]: val } : opt + ), + }); + }; + + // 연결된 카드 컴포넌트의 테이블 컬럼 가져오기 + const connectedTableName = useMemo(() => { + if (!componentId || !connections || !allComponents) return null; + const targetIds = connections + .filter((c) => c.sourceComponent === componentId) + .map((c) => c.targetComponent); + const sourceIds = connections + .filter((c) => c.targetComponent === componentId) + .map((c) => c.sourceComponent); + const peerIds = [...new Set([...targetIds, ...sourceIds])]; + + for (const pid of peerIds) { + const comp = allComponents.find((c) => c.id === pid); + if (!comp?.config) continue; + const compCfg = comp.config as Record; + const ds = compCfg.dataSource as { tableName?: string } | undefined; + if (ds?.tableName) return ds.tableName; + } + return null; + }, [componentId, connections, allComponents]); + + const [targetColumns, setTargetColumns] = useState([]); + const [columnsLoading, setColumnsLoading] = useState(false); + + // 집계 컬럼의 고유값 (옵션 선택용) + const [distinctValues, setDistinctValues] = useState([]); + const [distinctLoading, setDistinctLoading] = useState(false); + + useEffect(() => { + if (!connectedTableName) { + setTargetColumns([]); + return; + } + let cancelled = false; + setColumnsLoading(true); + getTableColumns(connectedTableName) + .then((res) => { + if (cancelled) return; + if (res.success && res.data?.columns) { + setTargetColumns(res.data.columns); + } + }) + .finally(() => { + if (!cancelled) setColumnsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [connectedTableName]); + + const fetchDistinctValues = useCallback(async (tableName: string, column: string) => { + setDistinctLoading(true); + try { + const res = await dataApi.getTableData(tableName, { page: 1, size: 9999 }); + const vals = new Set(); + for (const row of res.data) { + const v = row[column]; + if (v != null && String(v).trim() !== "") { + vals.add(String(v)); + } + } + const sorted = [...vals].sort(); + setDistinctValues(sorted); + return sorted; + } catch { + setDistinctValues([]); + return []; + } finally { + setDistinctLoading(false); + } + }, []); + + // 집계 컬럼 변경 시 고유값 새로 가져오기 + useEffect(() => { + const col = cfg.countColumn; + if (!connectedTableName || !col) { + setDistinctValues([]); + return; + } + fetchDistinctValues(connectedTableName, col); + }, [connectedTableName, cfg.countColumn, fetchDistinctValues]); + + const handleAutoFill = useCallback(async () => { + if (!connectedTableName || !cfg.countColumn) return; + const vals = await fetchDistinctValues(connectedTableName, cfg.countColumn); + if (vals.length === 0) return; + const newOptions: StatusChipOption[] = vals.map((v) => { + const existing = options.find((o) => o.value === v); + return { value: v, label: existing?.label || v }; + }); + update({ options: newOptions }); + }, [connectedTableName, cfg.countColumn, options, fetchDistinctValues]); + + const addOptionFromValue = (value: string) => { + if (options.some((o) => o.value === value)) return; + update({ + options: [...options, { value, label: value }], + }); + }; + + return ( +
+ {/* --- 칩 옵션 목록 --- */} +
+
+ + {connectedTableName && cfg.countColumn && ( + + )} +
+ {cfg.useSubCount && ( +
+ +

+ 하위 필터 자동 전환이 켜져 있으면 런타임에 가상 컬럼으로 + 집계됩니다. DB 값과 다를 수 있으니 직접 입력을 권장합니다. +

+
+ )} + {options.length === 0 && ( +

+ {connectedTableName && cfg.countColumn + ? "\"DB에서 자동 채우기\"를 클릭하거나 아래에서 추가하세요." + : "옵션이 없습니다. 먼저 집계 컬럼을 선택한 후 추가하세요."} +

+ )} + {options.map((opt, i) => ( +
+ updateOption(i, "value", e.target.value)} + placeholder="DB 값" + className="h-7 flex-1 text-[10px]" + /> + updateOption(i, "label", e.target.value)} + placeholder="표시 라벨" + className="h-7 flex-1 text-[10px]" + /> + +
+ ))} + + {/* 고유값에서 추가 */} + {distinctValues.length > 0 && ( +
+ +
+ {distinctValues + .filter((dv) => !options.some((o) => o.value === dv)) + .map((dv) => ( + + ))} + {distinctValues.every((dv) => options.some((o) => o.value === dv)) && ( +

모든 값이 추가되었습니다

+ )} +
+
+ )} + + {/* 수동 추가 */} + +
+ + {/* --- 전체 보기 칩 --- */} +
+
+ update({ allowAll: Boolean(checked) })} + /> + +
+

+ 필터 해제용 칩을 옵션 목록 맨 앞에 자동 추가합니다 +

+ + {cfg.allowAll !== false && ( +
+ + update({ allLabel: e.target.value })} + placeholder="전체" + className="h-7 text-[10px]" + /> +
+ )} +
+ + {/* --- 건수 표시 --- */} +
+ update({ showCount: Boolean(checked) })} + /> + +
+ + {cfg.showCount !== false && ( +
+ + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( + + ) : ( + update({ countColumn: e.target.value })} + placeholder="예: status" + className="h-8 text-xs" + /> + )} +

+ 연결된 카드의 이 컬럼 값으로 상태별 건수를 집계합니다 +

+
+ )} + + {cfg.showCount !== false && ( +
+
+ + update({ useSubCount: Boolean(checked) }) + } + /> + +
+ {cfg.useSubCount && ( + <> +

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

+
+ + update({ hideUntilSubFilter: Boolean(checked) }) + } + /> + +
+ {cfg.hideUntilSubFilter && ( +
+ + update({ hiddenMessage: e.target.value })} + placeholder="조건을 선택하면 상태별 현황이 표시됩니다" + className="h-7 text-[10px]" + /> +
+ )} + + )} +
+ )} + + {/* --- 칩 스타일 --- */} +
+ + +

+ 탭: 큰 숫자 + 라벨 / 알약: 작은 뱃지 형태 +

+
+ + {/* --- 필터 컬럼 --- */} +
+ + {!connectedTableName && ( +
+ +

+ 연결 탭에서 대상 카드 컴포넌트를 먼저 연결해주세요. +

+
+ )} + {connectedTableName && ( + <> + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : targetColumns.length > 0 ? ( + + ) : ( + update({ filterColumn: e.target.value })} + placeholder="예: status" + className="h-8 text-xs" + /> + )} +

+ 선택한 상태 칩 값으로 카드를 필터링할 컬럼 (비어있으면 집계 + 컬럼과 동일) +

+ + )} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-status-bar/index.tsx b/frontend/lib/registry/pop-components/pop-status-bar/index.tsx new file mode 100644 index 00000000..e94f321a --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/index.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopStatusBarComponent } from "./PopStatusBarComponent"; +import { PopStatusBarConfigPanel } from "./PopStatusBarConfig"; +import type { StatusBarConfig } from "./types"; +import { DEFAULT_STATUS_BAR_CONFIG } from "./types"; + +function PopStatusBarPreviewComponent({ + config, + label, +}: { + config?: StatusBarConfig; + label?: string; +}) { + const cfg = config || DEFAULT_STATUS_BAR_CONFIG; + const options = cfg.options || []; + const displayLabel = label || "상태 바"; + + return ( +
+ + {displayLabel} + +
+ {options.length === 0 ? ( + + 옵션 없음 + + ) : ( + options.slice(0, 4).map((opt) => ( +
+ 0 + + {opt.label} + +
+ )) + )} +
+
+ ); +} + +PopComponentRegistry.registerComponent({ + id: "pop-status-bar", + name: "상태 바", + description: "상태별 건수 대시보드 + 필터", + category: "display", + icon: "BarChart3", + component: PopStatusBarComponent, + configPanel: PopStatusBarConfigPanel, + preview: PopStatusBarPreviewComponent, + defaultProps: DEFAULT_STATUS_BAR_CONFIG, + connectionMeta: { + sendable: [ + { + key: "filter_value", + label: "필터 값", + type: "filter_value", + category: "filter", + description: "선택한 상태 칩 값을 카드에 필터로 전달", + }, + ], + receivable: [ + { + key: "all_rows", + label: "전체 데이터", + type: "all_rows", + category: "data", + description: "연결된 카드의 전체 데이터를 받아 상태별 건수 집계", + }, + { + key: "set_value", + label: "값 설정", + type: "filter_value", + category: "filter", + description: "외부에서 선택 값 설정", + }, + ], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-status-bar/types.ts b/frontend/lib/registry/pop-components/pop-status-bar/types.ts new file mode 100644 index 00000000..91a37c40 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-status-bar/types.ts @@ -0,0 +1,48 @@ +// ===== pop-status-bar 전용 타입 ===== +// 상태 칩 대시보드 컴포넌트. 카드 데이터를 집계하여 상태별 건수 표시 + 필터 발행. + +/** 상태 칩 표시 스타일 */ +export type StatusChipStyle = "tab" | "pill"; + +/** 개별 옵션 */ +export interface StatusChipOption { + value: string; + label: string; +} + +/** status-bar 전용 설정 */ +export interface StatusBarConfig { + showCount?: boolean; + countColumn?: string; + allowAll?: boolean; + allLabel?: string; + chipStyle?: StatusChipStyle; + /** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */ + useSubCount?: boolean; + /** 하위 필터(공정 선택 등)가 활성화되기 전까지 칩을 숨김 */ + hideUntilSubFilter?: boolean; + /** 칩 숨김 상태일 때 표시할 안내 문구 */ + hiddenMessage?: string; + + options?: StatusChipOption[]; + + /** 필터 대상 컬럼명 (기본: countColumn) */ + filterColumn?: string; + /** 추가 필터 대상 컬럼 (하위 테이블 등) */ + filterColumns?: string[]; +} + +/** 기본 설정값 */ +export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = { + showCount: true, + allowAll: true, + allLabel: "전체", + chipStyle: "tab", + options: [], +}; + +/** 칩 스타일 라벨 (설정 패널용) */ +export const STATUS_CHIP_STYLE_LABELS: Record = { + tab: "탭 (큰 숫자)", + pill: "알약 (작은 뱃지)", +};