From 4c113f2b8e81155ca1ecabe2ed85d6acb8c13ca7 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 24 Mar 2026 18:34:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20pop-cart-outbound=20=EC=B6=9C=EA=B3=A0?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20=EC=9E=A5=EB=B0=94=EA=B5=AC=EB=8B=88=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=B6=9C=EA=B3=A0(=ED=8C=90=EB=A7=A4/?= =?UTF-8?q?=EA=B8=B0=ED=83=80/=EC=99=B8=EC=A3=BC)=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EB=A5=BC=20=EC=8B=A0=EA=B7=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=9C=EB=8B=A4.=20-=20=EC=84=B8=EB=A1=9C?= =?UTF-8?q?=ED=98=95=20=EC=B9=B4=EB=93=9C=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83:=20=ED=97=A4=EB=8D=94=20+=20=EC=8A=A4=ED=83=AF=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20+=20=EC=88=98=EB=9F=89=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20+=20=EB=8B=B4=EA=B8=B0/=EC=B7=A8=EC=86=8C?= =?UTF-8?q?=20-=20ISA-101=20=EC=82=B0=EC=97=85=20=ED=98=84=EC=9E=A5=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=ED=86=A0=ED=81=B0=20(56px=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC,=2036px=20=EC=88=AB=EC=9E=90)=20-=20useCartS?= =?UTF-8?q?ync=20=ED=9B=85=20=EC=97=B0=EB=8F=99,=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=B2=84=EC=8A=A4=20filter/collect/save=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20-=20=EB=94=94=EC=9E=90=EC=9D=B4=EB=84=88?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20=ED=8C=A8=EB=84=90=203=ED=83=AD=20(?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0/=EC=B9=B4=EB=93=9C/=EC=9E=A5?= =?UTF-8?q?=EB=B0=94=EA=B5=AC=EB=8B=88)=20-=20React=20key=20prop=20fallbac?= =?UTF-8?q?k=20=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9=20(sf.id=20||=20idx?= =?UTF-8?q?)=20-=20PopComponentType,=20ComponentPalette,=20PopRenderer=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=20-=20@playwright/test=20devDependency=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20Co-Authored-By:=20Claude=20Sonnet=204.6=20?= 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 | 1 + .../pop/designer/types/pop-layout.ts | 3 +- frontend/lib/registry/pop-components/index.ts | 1 + .../PopCartOutboundComponent.tsx | 584 ++++++++++++++++++ .../PopCartOutboundConfig.tsx | 561 +++++++++++++++++ .../pop-cart-outbound/index.tsx | 89 +++ frontend/lib/registry/pop-components/types.ts | 64 ++ frontend/package-lock.json | 22 +- frontend/package.json | 1 + 10 files changed, 1330 insertions(+), 4 deletions(-) create mode 100644 frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundComponent.tsx create mode 100644 frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundConfig.tsx create 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 f4de9053..db56c378 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 } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck, Truck } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -99,6 +99,12 @@ 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 f802dfc8..ec0db469 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -85,6 +85,7 @@ const COMPONENT_TYPE_LABELS: Record = { "pop-scanner": "스캐너", "pop-profile": "프로필", "pop-work-detail": "작업 상세", + "pop-cart-outbound": "장바구니 출고", }; // ======================================== diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index f859cf5d..39a5f3d6 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"; +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"; /** * 데이터 흐름 정의 @@ -378,6 +378,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record= 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 new file mode 100644 index 00000000..811ec7f0 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-cart-outbound/PopCartOutboundConfig.tsx @@ -0,0 +1,561 @@ +"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 new file mode 100644 index 00000000..68a81a13 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-cart-outbound/index.tsx @@ -0,0 +1,89 @@ +"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/types.ts b/frontend/lib/registry/pop-components/types.ts index 5f08c1ab..9ffe58ad 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -1067,3 +1067,67 @@ 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 응답 +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 85329c8b..a2b1e18f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -102,6 +102,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@tanstack/react-query-devtools": "^5.86.0", "@types/jsbarcode": "^3.11.4", @@ -1413,6 +1414,23 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -12962,7 +12980,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.58.2" @@ -12981,7 +12999,7 @@ "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/frontend/package.json b/frontend/package.json index 76773512..a7bad0ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -111,6 +111,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", "@tanstack/react-query-devtools": "^5.86.0", "@types/jsbarcode": "^3.11.4",