From 6fe7bfbefcf27a9dcba9a220f9561e4d94c8247a Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 27 Mar 2026 18:11:08 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20pop-card-list=20=EC=97=85=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=93=9C=20=E2=80=94=20cart-outbound=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20+=20=ED=94=84=EB=A6=AC=EC=85=8B=20+=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=20=EC=97=B0=EB=8F=99=20+=20UI=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20-=20pop-cart-outbound=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=99=84=EC=A0=84=20=EC=82=AD=EC=A0=9C=20(4?= =?UTF-8?q?=EA=B0=9C=20=ED=99=94=EB=A9=B4=20pop-card-list=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4=20=EC=99=84=EB=A3=8C)=20-=20=EB=A0=88?= =?UTF-8?q?=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC/=ED=8C=94=EB=A0=88?= =?UTF-8?q?=ED=8A=B8/=ED=83=80=EC=9E=85=EC=97=90=EC=84=9C=20cart-outbound?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=20=EC=A0=9C=EA=B1=B0=20-=20PopRenderer=20?= =?UTF-8?q?=EB=B7=B0=EB=AA=A8=EB=93=9C=20label=20prop=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20(=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=20=EB=85=B8=EC=B6=9C=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= =?UTF-8?q?)=20-=20dataFetcher=20SUM/AVG=20varchar=20CAST=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(Dashboard=20500=20=EC=88=98=EC=A0=95)=20-=20PopCar?= =?UTF-8?q?dListConfig=20=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=EB=AA=A8=EB=93=9C=20=EC=84=B9=EC=85=98=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EC=85=8B=20=EC=82=AC=EC=9A=A9=20=EC=8B=9C=20=EC=88=A8?= =?UTF-8?q?=EA=B9=80=20-=20types.ts=EC=97=90=20CardListPresetMode,=20CardL?= =?UTF-8?q?istInspectionConfig=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop/designer/panels/ComponentPalette.tsx | 8 +- .../pop/designer/renderers/PopRenderer.tsx | 11 +- .../pop/designer/types/pop-layout.ts | 3 +- frontend/lib/registry/pop-components/index.ts | 1 - .../pop-card-list/PopCardListConfig.tsx | 16 +- .../PopCartOutboundComponent.tsx | 584 ------------------ .../PopCartOutboundConfig.tsx | 561 ----------------- .../pop-cart-outbound/index.tsx | 89 --- .../pop-dashboard/utils/dataFetcher.ts | 7 +- frontend/lib/registry/pop-components/types.ts | 64 -- 10 files changed, 21 insertions(+), 1323 deletions(-) delete mode 100644 frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundComponent.tsx delete mode 100644 frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundConfig.tsx delete mode 100644 frontend/lib/registry/pop-components/pop-cart-outbound/index.tsx diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index db56c378..f4de9053 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck, Truck } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -99,12 +99,6 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: ClipboardCheck, description: "공정별 체크리스트/검사/실적 상세 작업 화면", }, - { - type: "pop-cart-outbound", - label: "장바구니 출고", - icon: Truck, - description: "출고 전용 카드 리스트 (판매/기타/외주 출고)", - }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index ec0db469..a0f7f3b8 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -85,7 +85,6 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-scanner": "스캐너", "pop-profile": "프로필", "pop-work-detail": "작업 상세", - "pop-cart-outbound": "장바구니 출고", }; // ======================================== @@ -633,9 +632,8 @@ function renderActualComponent( if (ActualComp) { return (
- - {component.label || typeLabel} +
); } diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 39a5f3d6..f859cf5d 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -7,7 +7,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail" | "pop-cart-outbound"; +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail"; /** * 데이터 흐름 정의 @@ -378,7 +378,6 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record - {/* 장바구니 목록 모드 */} - - onUpdate({ cartListMode })} - /> - + {/* 장바구니 목록 모드 — 프리셋이 없거나 "normal"일 때만 표시 (프리셋 cart-confirm이 자동 처리) */} + {(!config.presetMode || config.presetMode === "normal") && ( + + onUpdate({ cartListMode })} + /> + + )} {/* 테이블 선택 (장바구니 모드 시 숨김) */} {!isCartListMode && ( diff --git a/frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundComponent.tsx b/frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundComponent.tsx deleted file mode 100644 index 34167b8f..00000000 --- a/frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundComponent.tsx +++ /dev/null @@ -1,584 +0,0 @@ -"use client"; - -/** - * pop-cart-outbound 런타임 컴포넌트 - * - * 출고 전용 장바구니 카드 리스트. - * - 세로형 카드 레이아웃: 헤더 → 스탯 그리드 → 수량 입력 + 담기 버튼 - * - 장바구니 로직: useCartSync 훅으로 cart_items DB 동기화 - * - 이벤트 버스: filter_changed 수신, cart_updated 발행, collect_data 응답 - * - 산업 현장 디자인: ISA-101 기반 터치 최적화 (56px 버튼, 36px 숫자, 24px 패딩) - */ - -import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; -import { ShoppingCart, X, Loader2 } from "lucide-react"; -import { toast } from "sonner"; -import type { - PopCartOutboundConfig, - OutboundStatField, - CollectDataRequest, - CollectedDataResponse, -} from "../types"; -import { dataApi } from "@/lib/api/data"; -import { usePopEvent } from "@/hooks/pop/usePopEvent"; -import { useCartSync } from "@/hooks/pop/useCartSync"; -import { NumberInputModal } from "../pop-card-list/NumberInputModal"; - -// ===== 기본 설정 ===== - -const DEFAULT_EMPTY_MESSAGE = "거래처를 선택하면 출고 대상 품목이 표시됩니다"; -const DEFAULT_EMPTY_ICON = "📦"; - -// ===== 디자인 토큰 (ISA-101 산업 현장 기준) ===== - -const DESIGN = { - card: { - padding: 24, - gap: 20, - borderRadius: 10, - }, - title: { size: 22, weight: 600 }, - code: { size: 16 }, - statValue: { size: 36, weight: 700 }, - statLabel: { size: 14 }, - button: { height: 56, minWidth: 100 }, - input: { height: 56 }, - checkbox: { size: 40 }, -} as const; - -// ===== 반응형 브레이크포인트 ===== - -type SizeMode = "lg" | "md" | "sm" | "xs"; - -function detectSizeMode(width: number): SizeMode { - if (width >= 1024) return "lg"; - if (width >= 768) return "md"; - if (width >= 480) return "sm"; - return "xs"; -} - -function getResponsiveDesign(mode: SizeMode) { - switch (mode) { - case "lg": - return { padding: 24, statValue: 36, statCols: 0, titleSize: 22, codeSize: 16, showCode: true }; - case "md": - return { padding: 16, statValue: 36, statCols: 0, titleSize: 22, codeSize: 16, showCode: true }; - case "sm": - return { padding: 16, statValue: 32, statCols: 2, titleSize: 20, codeSize: 14, showCode: true }; - case "xs": - return { padding: 12, statValue: 28, statCols: 2, titleSize: 18, codeSize: 14, showCode: false }; - } -} - -// ===== Props ===== - -interface ComponentProps { - componentId: string; - screenId: string; - config?: PopCartOutboundConfig; -} - -// ===== 메인 컴포넌트 ===== - -export function PopCartOutboundComponent({ componentId, screenId, config }: ComponentProps) { - const containerRef = useRef(null); - const [sizeMode, setSizeMode] = useState("lg"); - const [rows, setRows] = useState[]>([]); - const [loading, setLoading] = useState(false); - const [externalFilters, setExternalFilters] = useState>({}); - const [hasReceivedFilter, setHasReceivedFilter] = useState(false); - const [numberModalOpen, setNumberModalOpen] = useState(false); - const [activeRowKey, setActiveRowKey] = useState(null); - - const { publish, subscribe } = usePopEvent(screenId); - - const sourceTable = config?.dataSource?.tableName || ""; - const keyCol = config?.keyColumn || "id"; - - const cart = useCartSync(screenId, sourceTable); - - // ---- 반응형 감지 ---- - - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setSizeMode(detectSizeMode(entry.contentRect.width)); - } - }); - observer.observe(el); - return () => observer.disconnect(); - }, []); - - const responsive = getResponsiveDesign(sizeMode); - - // ---- 데이터 조회 ---- - - const fetchData = useCallback(async () => { - if (!sourceTable) return; - - // requireFilter가 설정되어 있고 아직 필터를 받지 못했으면 조회하지 않음 - if (config?.requireFilter && !hasReceivedFilter) return; - - setLoading(true); - try { - // 정적 필터 (디자이너 설정) + 외부 필터 (이벤트 버스) 병합 - const staticFilters: Record = {}; - for (const f of config?.dataSource?.filters || []) { - staticFilters[f.column] = f.value; - } - // __connId_ 접두사 키 제외 (내부 추적용) - const cleanExternalFilters: Record = {}; - for (const [k, v] of Object.entries(externalFilters)) { - if (!k.startsWith("__connId_")) { - cleanExternalFilters[k] = v; - } - } - const mergedFilters = { ...staticFilters, ...cleanExternalFilters }; - - const res = await dataApi.getTableData(sourceTable, { - size: 500, - filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined, - sortBy: config?.dataSource?.sort?.[0]?.column, - sortOrder: config?.dataSource?.sort?.[0]?.direction, - }); - setRows(Array.isArray(res) ? res : res?.data ?? []); - } catch { - setRows([]); - } finally { - setLoading(false); - } - }, [sourceTable, config?.dataSource, externalFilters, config?.requireFilter, hasReceivedFilter]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - // ---- 이벤트 구독 ---- - - // 필터 수신: useConnectionResolver가 보내는 구조화된 페이로드를 파싱하여 - // API 필터 형식 { column: value } 로 변환 - useEffect(() => { - const prefix = `__comp_input__${componentId}__`; - const unsub = subscribe(prefix + "filter_condition", (payload: unknown) => { - if (!payload || typeof payload !== "object") return; - - const data = payload as { - value?: { fieldName?: string; value?: unknown; filterColumns?: string[]; filterMode?: string }; - filterConfig?: { targetColumn?: string; targetColumns?: string[]; filterMode?: string }; - _connectionId?: string; - }; - - // 연결 해석기(useConnectionResolver)가 전달한 구조에서 실제 필터 값 추출 - const filterValue = data.value?.value; - const targetColumn = data.filterConfig?.targetColumn || data.value?.fieldName; - - if (!targetColumn || filterValue == null || filterValue === "") { - // 필터 클리어: 해당 connection의 필터 제거 - setExternalFilters((prev) => { - const next = { ...prev }; - if (data._connectionId) { - // connectionId 기반으로 이전에 설정한 필터 제거 - for (const key of Object.keys(next)) { - if (next[`__connId_${key}`] === data._connectionId) { - delete next[key]; - delete next[`__connId_${key}`]; - } - } - } - return next; - }); - return; - } - - setHasReceivedFilter(true); - - // API 필터 형식으로 변환: { partner_id: "T-CUST-001" } - setExternalFilters((prev) => { - const next = { ...prev }; - next[targetColumn] = filterValue; - // 내부 추적용 (API에는 전달되지 않으나, 클리어 시 사용) - if (data._connectionId) { - next[`__connId_${targetColumn}`] = data._connectionId; - } - return next; - }); - }); - return unsub; - }, [subscribe, componentId]); - - // 데이터 수집 요청 수신 (pop-button이 출고 확정 시) - useEffect(() => { - const unsub = subscribe("__collect_data__", (payload: unknown) => { - const req = payload as CollectDataRequest; - const response: CollectedDataResponse = { - requestId: req.requestId, - componentId, - componentType: "pop-cart-outbound", - data: { - items: cart.cartItems.map((ci) => ({ - ...ci.row, - __cart_qty: ci.quantity, - __cart_id: ci.cartId, - __cart_row_key: ci.rowKey, - })), - }, - mapping: null, - }; - publish("__collected_data__", response); - }); - return unsub; - }, [subscribe, publish, componentId, cart.cartItems]); - - // 장바구니 변경 시 이벤트 발행 - useEffect(() => { - const prefix = `__comp_output__${componentId}__`; - publish(prefix + "cart_updated", { - count: cart.cartCount, - isDirty: cart.isDirty, - }); - }, [publish, componentId, cart.cartCount, cart.isDirty]); - - // 저장 트리거 수신 - useEffect(() => { - const prefix = `__comp_input__${componentId}__`; - const unsub = subscribe(prefix + "cart_save_trigger", async () => { - const ok = await cart.saveToDb(); - const outPrefix = `__comp_output__${componentId}__`; - publish(outPrefix + "cart_save_completed", { success: ok }); - }); - return unsub; - }, [subscribe, publish, componentId, cart]); - - // ---- 담기/취소 핸들러 ---- - - const handleCartAdd = useCallback( - (row: Record, quantity: number) => { - const rk = String(row[keyCol] ?? ""); - if (!rk) return; - cart.addItem({ row, quantity }, rk); - const titleField = config?.header?.titleField; - const unit = config?.quantityInput?.unit || "EA"; - const name = titleField ? String(row[titleField] || "") : rk; - toast.success(`${name} ${quantity}${unit} 담김`); - }, - [cart, keyCol, config], - ); - - const handleCartCancel = useCallback( - (row: Record) => { - const rk = String(row[keyCol] ?? ""); - if (!rk) return; - cart.removeItem(rk); - }, - [cart, keyCol], - ); - - // ---- 수량 입력 모달 ---- - - const activeRow = useMemo(() => { - if (!activeRowKey) return null; - return rows.find((r) => String(r[keyCol]) === activeRowKey) ?? null; - }, [activeRowKey, rows, keyCol]); - - const getMaxValue = useCallback( - (row: Record) => { - const maxCol = config?.quantityInput?.maxColumn; - if (!maxCol) return 999999; - const val = Number(row[maxCol]); - return isNaN(val) ? 999999 : val; - }, - [config?.quantityInput?.maxColumn], - ); - - const getDefaultValue = useCallback( - (row: Record) => { - const defCol = config?.quantityInput?.defaultColumn; - if (!defCol) return 1; - const val = Number(row[defCol]); - return isNaN(val) || val <= 0 ? 1 : val; - }, - [config?.quantityInput?.defaultColumn], - ); - - // ---- 렌더링 ---- - - const statFields = config?.statFields || []; - const emptyMsg = config?.emptyMessage || DEFAULT_EMPTY_MESSAGE; - const emptyIcon = config?.emptyIcon || DEFAULT_EMPTY_ICON; - - // 반응형 스탯 열 수: 0이면 statFields 수 그대로 - const statCols = responsive.statCols || statFields.length || 3; - - return ( -
- {/* 헤더 바 */} -
- - 출고 대상 품목 - - {rows.length}건 -
- - {/* 카드 리스트 */} -
- {config?.requireFilter && !hasReceivedFilter ? ( -
- {emptyIcon} -

{config?.requireFilterMessage || emptyMsg}

-
- ) : loading ? ( -
- -
- ) : rows.length === 0 ? ( -
- {emptyIcon} -

데이터가 없습니다.

-
- ) : ( -
- {rows.map((row) => { - const rk = String(row[keyCol] ?? ""); - const isInCart = cart.isItemInCart(rk); - const cartItem = cart.getCartItem(rk); - - return ( - handleCartAdd(row, qty)} - onCancelCart={() => handleCartCancel(row)} - onQuantityClick={() => { - setActiveRowKey(rk); - setNumberModalOpen(true); - }} - getDefaultValue={getDefaultValue} - /> - ); - })} -
- )} -
- - {/* 수량 입력 모달 */} - {activeRow && ( - { - handleCartAdd(activeRow, value); - setNumberModalOpen(false); - setActiveRowKey(null); - }} - /> - )} -
- ); -} - -// ===== 개별 카드 컴포넌트 ===== - -interface OutboundItemCardProps { - row: Record; - rowKey: string; - config: PopCartOutboundConfig; - statFields: OutboundStatField[]; - statCols: number; - responsive: ReturnType; - isInCart: boolean; - cartQuantity?: number; - onAddToCart: (qty: number) => void; - onCancelCart: () => void; - onQuantityClick: () => void; - getDefaultValue: (row: Record) => number; -} - -function OutboundItemCard({ - row, - config, - statFields, - statCols, - responsive, - isInCart, - cartQuantity, - onAddToCart, - onCancelCart, - onQuantityClick, - getDefaultValue, -}: OutboundItemCardProps) { - const title = config.header?.titleField ? String(row[config.header.titleField] ?? "") : ""; - const code = config.header?.codeField ? String(row[config.header.codeField] ?? "") : ""; - const unit = config.header?.unitField ? String(row[config.header.unitField] ?? "") : ""; - const codeDisplay = [code, unit].filter(Boolean).join(" · "); - - const displayQty = cartQuantity ?? getDefaultValue(row); - const qtyLabel = config.quantityInput?.label || "출고수량"; - const qtyUnit = config.quantityInput?.unit || "EA"; - - return ( -
- {/* 헤더: 품목명 + 코드·단위 */} -
-
-
- {title || "—"} -
- {responsive.showCode && codeDisplay && ( -
- {codeDisplay} -
- )} -
-
- - {/* 스탯 그리드 */} - {statFields.length > 0 && ( -
- {statFields.slice(0, responsive.statCols || statFields.length).map((sf, idx) => ( -
- - {sf.label} - - - {formatStatValue(row[sf.column], sf.format)} - -
- ))} -
- )} - - {/* 하단: 수량 입력 + 담기/취소 버튼 */} -
- {/* 수량 입력 영역 */} -
- - {qtyLabel} - - -
- - {/* 담기/취소 버튼 */} - {isInCart ? ( - - ) : ( - - )} -
-
- ); -} - -// ===== 유틸 ===== - -function formatStatValue( - value: unknown, - format?: "number" | "currency" | "text", -): string { - if (value == null || value === "") return "—"; - if (format === "text") return String(value); - const num = Number(value); - if (isNaN(num)) return String(value); - if (format === "currency") return `₩${num.toLocaleString()}`; - return num.toLocaleString(); -} diff --git a/frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundConfig.tsx b/frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundConfig.tsx deleted file mode 100644 index 811ec7f0..00000000 --- a/frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundConfig.tsx +++ /dev/null @@ -1,561 +0,0 @@ -"use client"; - -/** - * pop-cart-outbound 디자이너 설정 패널 - * - * 3탭 구성: [데이터] [카드] [장바구니] - * - * 사용자 설정 가능 영역: - * - 데이터 소스 (테이블, 조인, 필터, 정렬) - * - 카드 헤더 컬럼 매핑 (품목명, 코드, 단위) - * - 스탯 필드 (추가/삭제/정렬, 라벨+컬럼+포맷) - * - 수량 입력 (라벨, 기본값 컬럼, 최대값 컬럼, 단위) - * - 빈 상태 메시지 - * - * 고정 영역 (설정 노출 안 함): - * - 장바구니 로직 (useCartSync) - * - 카드 레이아웃 (세로형) - * - 담기/취소 버튼 - */ - -import React, { useState, useEffect } from "react"; -import { Plus, Trash2 } from "lucide-react"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import type { - PopCartOutboundConfig, - OutboundStatField, - CardListDataSource, - StatFieldFormat, -} from "../types"; -import { - fetchTableList, - fetchTableColumns, - type TableInfo, - type ColumnInfo, -} from "../pop-dashboard/utils/dataFetcher"; -import { TableCombobox } from "../pop-shared/TableCombobox"; - -// ===== Props ===== - -interface ConfigPanelProps { - config: PopCartOutboundConfig | undefined; - onUpdate: (config: PopCartOutboundConfig) => void; -} - -// ===== 기본값 ===== - -const DEFAULT_CONFIG: PopCartOutboundConfig = { - dataSource: { tableName: "" }, - keyColumn: "id", - header: { titleField: "", codeField: "" }, - statFields: [], - quantityInput: { label: "출고수량", unit: "EA" }, - emptyMessage: "거래처를 선택하면 출고 대상 품목이 표시됩니다", - emptyIcon: "📦", -}; - -// ===== 메인 ===== - -export function PopCartOutboundConfigPanel({ config, onUpdate }: ConfigPanelProps) { - const [activeTab, setActiveTab] = useState<"data" | "card" | "cart">("data"); - - const cfg: PopCartOutboundConfig = config || DEFAULT_CONFIG; - - const update = (partial: Partial) => { - onUpdate({ ...cfg, ...partial }); - }; - - const hasTable = !!cfg.dataSource?.tableName; - - const tabs: { key: typeof activeTab; label: string; disabled?: boolean }[] = [ - { key: "data", label: "데이터" }, - { key: "card", label: "카드", disabled: !hasTable }, - { key: "cart", label: "장바구니", disabled: !hasTable }, - ]; - - return ( -
- {/* 탭 헤더 */} -
- {tabs.map((tab) => ( - - ))} -
- - {/* 탭 내용 */} -
- {activeTab === "data" && } - {activeTab === "card" && } - {activeTab === "cart" && } -
-
- ); -} - -// ===== 데이터 탭 ===== - -function DataTab({ - config, - onUpdate, -}: { - config: PopCartOutboundConfig; - onUpdate: (p: Partial) => void; -}) { - const [tables, setTables] = useState([]); - const [columns, setColumns] = useState([]); - - const tableName = config.dataSource?.tableName || ""; - - useEffect(() => { - fetchTableList().then(setTables); - }, []); - - useEffect(() => { - if (!tableName) { - setColumns([]); - return; - } - fetchTableColumns(tableName).then(setColumns).catch(() => setColumns([])); - }, [tableName]); - - return ( -
- {/* 테이블 선택 */} -
- -
- - onUpdate({ - dataSource: { ...config.dataSource, tableName: val }, - header: { titleField: "", codeField: "" }, - statFields: [], - }) - } - /> -
-
- - {/* PK 컬럼 */} - {tableName && ( -
- - -
- )} - - {/* 고정 안내 */} -
-

- 장바구니 로직(담기/취소/DB 동기화)은 자동으로 내장됩니다. - 카드 레이아웃은 세로형(헤더→스탯→수량+버튼)으로 고정됩니다. -

-
-
- ); -} - -// ===== 카드 탭 ===== - -function CardTab({ - config, - onUpdate, -}: { - config: PopCartOutboundConfig; - onUpdate: (p: Partial) => void; -}) { - const [columns, setColumns] = useState([]); - - const tableName = config.dataSource?.tableName || ""; - - useEffect(() => { - if (!tableName) return; - fetchTableColumns(tableName).then(setColumns).catch(() => {}); - }, [tableName]); - - const colOptions = columns.map((c) => ({ - value: c.name, - label: c.comment ? `${c.name} (${c.comment})` : c.name, - })); - - return ( -
- {/* 헤더 매핑 */} -
- - -
- - -
- -
- - -
- -
- - -
-
- - {/* 스탯 필드 */} -
-
- - -
- - {(config.statFields || []).map((sf, idx) => ( -
- {/* 라벨 */} -
- - { - const updated = [...config.statFields]; - updated[idx] = { ...sf, label: e.target.value }; - onUpdate({ statFields: updated }); - }} - /> -
- - {/* 컬럼 */} -
- - -
- - {/* 포맷 */} -
- - -
- - {/* 삭제 */} - -
- ))} - - {(config.statFields || []).length === 0 && ( -

- 스탯 필드를 추가하면 카드에 주문수량, 재고수량 등을 표시합니다 -

- )} -
-
- ); -} - -// ===== 장바구니 탭 ===== - -function CartTab({ - config, - onUpdate, -}: { - config: PopCartOutboundConfig; - onUpdate: (p: Partial) => void; -}) { - const [columns, setColumns] = useState([]); - - const tableName = config.dataSource?.tableName || ""; - - useEffect(() => { - if (!tableName) return; - fetchTableColumns(tableName).then(setColumns).catch(() => {}); - }, [tableName]); - - const colOptions = columns.map((c) => ({ - value: c.name, - label: c.comment ? `${c.name} (${c.comment})` : c.name, - })); - - const qi = config.quantityInput || { label: "출고수량", unit: "EA" }; - - return ( -
- {/* 수량 입력 설정 */} -
- - -
-
- - - onUpdate({ - quantityInput: { ...qi, label: e.target.value }, - }) - } - /> -
-
- - - onUpdate({ - quantityInput: { ...qi, unit: e.target.value }, - }) - } - /> -
-
- -
- - -
- -
- - -
-
- - {/* 빈 상태 메시지 */} -
- - -
- - onUpdate({ emptyMessage: e.target.value })} - placeholder="거래처를 선택하면 출고 대상 품목이 표시됩니다" - /> -
- -
- - onUpdate({ emptyIcon: e.target.value })} - placeholder="📦" - /> -
-
- - {/* 고정 안내 */} -
-

- 담기/취소 버튼과 장바구니 DB 동기화(cart_items)는 자동으로 동작합니다. - 별도 설정이 필요 없습니다. -

-
-
- ); -} diff --git a/frontend/lib/registry/pop-components/pop-cart-outbound/index.tsx b/frontend/lib/registry/pop-components/pop-cart-outbound/index.tsx deleted file mode 100644 index 68a81a13..00000000 --- a/frontend/lib/registry/pop-components/pop-cart-outbound/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client"; - -/** - * pop-cart-outbound 컴포넌트 레지스트리 등록 진입점 - * - * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨 - */ - -import { PopComponentRegistry } from "../../PopComponentRegistry"; -import { PopCartOutboundComponent } from "./PopCartOutboundComponent"; -import { PopCartOutboundConfigPanel } from "./PopCartOutboundConfig"; -import type { PopCartOutboundConfig } from "../types"; - -const defaultConfig: PopCartOutboundConfig = { - dataSource: { tableName: "" }, - keyColumn: "id", - header: { - titleField: "", - codeField: "", - }, - statFields: [], - quantityInput: { - label: "출고수량", - unit: "EA", - }, - emptyMessage: "거래처를 선택하면 출고 대상 품목이 표시됩니다", - emptyIcon: "📦", -}; - -PopComponentRegistry.registerComponent({ - id: "pop-cart-outbound", - name: "장바구니 출고", - description: "출고 전용 카드 리스트 (판매출고, 기타출고, 외주출고 등)", - category: "display", - icon: "Truck", - component: PopCartOutboundComponent, - configPanel: PopCartOutboundConfigPanel, - defaultProps: defaultConfig, - connectionMeta: { - sendable: [ - { - key: "cart_updated", - label: "장바구니 상태", - type: "event", - category: "event", - description: "장바구니 변경 시 count/isDirty 전달", - }, - { - key: "cart_save_completed", - label: "저장 완료", - type: "event", - category: "event", - description: "장바구니 DB 저장 완료 후 결과 전달", - }, - { - key: "collected_data", - label: "수집 응답", - type: "event", - category: "event", - description: "데이터 수집 요청에 대한 응답 (장바구니 항목 + 수량)", - }, - ], - receivable: [ - { - key: "filter_condition", - label: "필터 조건", - type: "filter_value", - category: "filter", - description: "거래처/품목 필터 조건으로 카드 목록 필터링", - }, - { - key: "cart_save_trigger", - label: "저장 요청", - type: "event", - category: "event", - description: "장바구니 DB 일괄 저장 트리거", - }, - { - key: "collect_data", - label: "수집 요청", - type: "event", - category: "event", - description: "버튼에서 데이터+매핑 수집 요청 수신", - }, - ], - }, - touchOptimized: true, - supportedDevices: ["mobile", "tablet"], -}); diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index b05846ef..deaa3cba 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -148,12 +148,17 @@ export function buildAggregationSQL(config: DataSourceConfig): string { : ""; // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수 + // SUM/AVG 등 숫자 집계 시 varchar 컬럼 대응: CAST AS NUMERIC 적용 + const needsCast = ["SUM", "AVG"].includes(aggType); if (!aggCol) { selectClause = aggType === "COUNT" ? "COUNT(*) as value" : `${aggType}(${tableName}.*) as value`; } else { - selectClause = `${aggType}(${aggCol}) as value`; + const colExpr = needsCast + ? `CAST(NULLIF(${aggCol}, '') AS NUMERIC)` + : aggCol; + selectClause = `${aggType}(${colExpr}) as value`; } // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index b3a43b53..badffc5c 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -1125,67 +1125,3 @@ export interface PopWorkDetailConfig { navigation: WorkDetailNavigationConfig; resultSections?: ResultSectionConfig[]; } - - -// ============================================= -// pop-cart-outbound 전용 타입 (장바구니 출고) -// ============================================= - -// ----- 스탯 필드 포맷 ----- - -export type StatFieldFormat = "number" | "currency" | "text"; - -// ----- 스탯 필드 바인딩 ----- - -export interface OutboundStatField { - id: string; - label: string; // "주문수량", "재고수량", "단가" 등 - column: string; // DB 컬럼명 - format?: StatFieldFormat; // 표시 포맷 (기본: "number") -} - -// ----- pop-cart-outbound 전체 설정 ----- - -export interface PopCartOutboundConfig { - // ===== 사용자 설정 가능 영역 ===== - - /** 데이터 소스 (어떤 테이블에서 품목을 가져올지) */ - dataSource: CardListDataSource; - - /** 행 식별 PK 컬럼 (기본: "id") */ - keyColumn: string; - - /** 카드 헤더 컬럼 매핑 */ - header: { - titleField: string; // 품목명 컬럼 - codeField: string; // 코드 컬럼 - unitField?: string; // 단위 컬럼 - }; - - /** 스탯 그리드 필드 (사용자가 추가/삭제/변경 가능) */ - statFields: OutboundStatField[]; - - /** 수량 입력 설정 */ - quantityInput: { - label: string; // "출고수량" - defaultColumn?: string; // 기본값 컬럼 (예: plan_qty) - maxColumn?: string; // 최대값 컬럼 (예: current_stock) - unit?: string; // "EA", "kg" 등 - }; - - /** 빈 상태 메시지 */ - emptyMessage?: string; - emptyIcon?: string; - - /** 필터 필수 여부 (true이면 외부 필터가 없으면 데이터 조회 안 함) */ - requireFilter?: boolean; - /** 필터 미선택 시 표시할 안내 메시지 */ - requireFilterMessage?: string; - - // ===== 고정 영역 (코드에 내장, 설정 노출 안 함) ===== - // - 카드 레이아웃: 세로형 (헤더→스탯 그리드→수량+버튼) 고정 - // - 장바구니 로직: useCartSync 자동 연동 - // - 담기/취소 버튼: 항상 표시, 동작 고정 - // - 수량 입력: 항상 활성화 - // - 이벤트 버스: filter_changed 수신, cart_updated 발행, collect_data 응답 -}