From aa319a6bda9321f6fdeb3eaacb87bc349d34f217 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Fri, 27 Feb 2026 14:57:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(pop-card-list):=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EB=AA=A9=EB=A1=9D=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?(cartListMode)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CartListModeConfig 타입 추가 (sourceScreenId, cartType, statusFilter) - 원본 화면 카드 설정 자동 상속 (screenApi.getLayoutPop) - cart_items 조회 + row_data JSON 파싱 + __cart_ 접두사 병합 - 체크박스 선택 (전체/개별) + selected_items 이벤트 발행 - 인라인 삭제 (확인 후 즉시 DB 반영) - 수량 수정 (NumberInputModal 재사용, 로컬 __cart_modified) - 설정 패널: 장바구니 모드 토글 + 원본 화면/컴포넌트 선택 - connectionMeta: selected_items, confirm_trigger 추가 --- .../pop-card-list/PopCardListComponent.tsx | 300 ++++++++++++++++-- .../pop-card-list/PopCardListConfig.tsx | 207 +++++++++--- .../pop-components/pop-card-list/index.tsx | 2 + frontend/lib/registry/pop-components/types.ts | 12 +- 4 files changed, 455 insertions(+), 66 deletions(-) diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx index e3e7dc4c..0a7f4f9e 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -14,6 +14,7 @@ import { useRouter } from "next/navigation"; import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star, + Trash2, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -28,12 +29,14 @@ import type { CardPresetSpec, CartItem, PackageEntry, + CartListModeConfig, } from "../types"; import { DEFAULT_CARD_IMAGE, CARD_PRESET_SPECS, } from "../types"; import { dataApi } from "@/lib/api/data"; +import { screenApi } from "@/lib/api/screen"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "./NumberInputModal"; @@ -121,6 +124,28 @@ function MarqueeText({ ); } +// cart_items 행의 row_data JSON을 풀어서 __cart_ 접두사 메타데이터와 병합 +function parseCartRow(dbRow: Record): Record { + let rowData: Record = {}; + try { + const raw = dbRow.row_data; + if (typeof raw === "string" && raw.trim()) rowData = JSON.parse(raw); + else if (typeof raw === "object" && raw !== null) rowData = raw as Record; + } catch { rowData = {}; } + + return { + ...rowData, + __cart_id: dbRow.id, + __cart_quantity: Number(dbRow.quantity) || 0, + __cart_package_unit: dbRow.package_unit || "", + __cart_package_entries: dbRow.package_entries, + __cart_status: dbRow.status || "in_cart", + __cart_memo: dbRow.memo || "", + __cart_row_key: dbRow.row_key || "", + __cart_modified: false, + }; +} + interface PopCardListComponentProps { config?: PopCardListConfig; className?: string; @@ -172,6 +197,14 @@ export function PopCardListComponent({ const cartType = config?.cartAction?.cartType; const cart = useCartSync(screenId || "", sourceTableName, cartType); + // 장바구니 목록 모드 플래그 및 상태 + const isCartListMode = config?.cartListMode?.enabled === true; + const [inheritedTemplate, setInheritedTemplate] = useState(null); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + + // 장바구니 목록 모드에서는 원본 화면의 템플릿을 사용, 폴백으로 자체 설정 + const effectiveTemplate = (isCartListMode && inheritedTemplate) ? inheritedTemplate : template; + // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -219,9 +252,9 @@ export function PopCardListComponent({ const cartRef = useRef(cart); cartRef.current = cart; - // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 + // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 (장바구니 목록 모드 제외) useEffect(() => { - if (!componentId) return; + if (!componentId || isCartListMode) return; const unsub = subscribe( `__comp_input__${componentId}__cart_save_trigger`, async (payload: unknown) => { @@ -233,16 +266,16 @@ export function PopCardListComponent({ } ); return unsub; - }, [componentId, subscribe, publish]); + }, [componentId, subscribe, publish, isCartListMode]); - // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 + // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 (장바구니 목록 모드 제외) useEffect(() => { - if (!componentId || cart.loading) return; + if (!componentId || cart.loading || isCartListMode) return; publish(`__comp_output__${componentId}__cart_updated`, { count: cart.cartCount, isDirty: cart.isDirty, }); - }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]); + }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish, isCartListMode]); // 카드 선택 시 selected_row 이벤트 발행 const handleCardSelect = useCallback((row: RowData) => { @@ -454,7 +487,70 @@ export function PopCardListComponent({ [dataSource] ); + // 장바구니 목록 모드 설정을 직렬화 (의존성 안정화) + const cartListModeKey = useMemo( + () => JSON.stringify(config?.cartListMode || null), + [config?.cartListMode] + ); + useEffect(() => { + // 장바구니 목록 모드: cart_items에서 직접 조회 + if (isCartListMode) { + const cartListMode = config!.cartListMode!; + + // 설정 미완료 시 데이터 조회하지 않음 + if (!cartListMode.sourceScreenId || !cartListMode.cartType) { + setLoading(false); + setRows([]); + return; + } + + const fetchCartData = async () => { + setLoading(true); + setError(null); + try { + // 원본 화면 레이아웃에서 cardTemplate 상속 + if (cartListMode.sourceScreenId) { + try { + const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId); + const components = layoutJson?.components || []; + const matched = components.find( + (c: any) => + c.type === "pop-card-list" && + c.props?.cartAction?.cartType === cartListMode.cartType + ); + if (matched?.props?.cardTemplate) { + setInheritedTemplate(matched.props.cardTemplate); + } + } catch { + // 레이아웃 로드 실패 시 config.cardTemplate 폴백 + } + } + + // cart_items 조회 + const result = await dataApi.getTableData("cart_items", { + size: 500, + filters: { + cart_type: cartListMode.cartType, + status: cartListMode.statusFilter || "in_cart", + }, + }); + + const parsed = (result.data || []).map(parseCartRow); + setRows(parsed); + } catch (err) { + const message = err instanceof Error ? err.message : "장바구니 데이터 조회 실패"; + setError(message); + setRows([]); + } finally { + setLoading(false); + } + }; + fetchCartData(); + return; + } + + // 기본 모드: 데이터 소스에서 조회 if (!dataSource?.tableName) { setLoading(false); setRows([]); @@ -510,16 +606,51 @@ export function PopCardListComponent({ }; fetchData(); - }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps + }, [dataSourceKey, isCartListMode, cartListModeKey]); // eslint-disable-line react-hooks/exhaustive-deps // 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시) useEffect(() => { - if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) { - const imageColumn = template.image.imageColumn; + if (!loading && rows.length > 0 && effectiveTemplate?.image?.enabled && effectiveTemplate?.image?.imageColumn) { + const imageColumn = effectiveTemplate.image.imageColumn; missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length; } - }, [loading, rows, template?.image]); + }, [loading, rows, effectiveTemplate?.image]); + // 장바구니 목록 모드: 항목 삭제 콜백 + const handleDeleteItem = useCallback((cartId: string) => { + setRows(prev => prev.filter(r => String(r.__cart_id) !== cartId)); + setSelectedKeys(prev => { + const next = new Set(prev); + next.delete(cartId); + return next; + }); + }, []); + + // 장바구니 목록 모드: 수량 수정 콜백 (로컬만 업데이트, DB 미반영) + const handleUpdateQuantity = useCallback(( + cartId: string, + quantity: number, + unit?: string, + entries?: PackageEntry[], + ) => { + setRows(prev => prev.map(r => { + if (String(r.__cart_id) !== cartId) return r; + return { + ...r, + __cart_quantity: quantity, + __cart_package_unit: unit || r.__cart_package_unit, + __cart_package_entries: entries || r.__cart_package_entries, + __cart_modified: true, + }; + })); + }, []); + + // 장바구니 목록 모드: 선택 항목 이벤트 발행 + useEffect(() => { + if (!componentId || !isCartListMode) return; + const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id))); + publish(`__comp_output__${componentId}__selected_items`, selectedItems); + }, [selectedKeys, filteredRows, componentId, isCartListMode, publish]); // 카드 영역 스타일 const cardAreaStyle: React.CSSProperties = { @@ -549,7 +680,13 @@ export function PopCardListComponent({ ref={containerRef} className={`flex h-full w-full flex-col ${className || ""}`} > - {!dataSource?.tableName ? ( + {isCartListMode && (!config?.cartListMode?.sourceScreenId || !config?.cartListMode?.cartType) ? ( +
+

+ 원본 화면과 장바구니 구분값을 설정해주세요. +

+
+ ) : !isCartListMode && !dataSource?.tableName ? (

데이터 소스를 설정해주세요. @@ -569,6 +706,27 @@ export function PopCardListComponent({

) : ( <> + {/* 장바구니 목록 모드: 선택 바 */} + {isCartListMode && ( +
+ 0} + onChange={(e) => { + if (e.target.checked) { + setSelectedKeys(new Set(displayCards.map(r => String(r.__cart_id)))); + } else { + setSelectedKeys(new Set()); + } + }} + className="h-4 w-4 rounded border-gray-300" + /> + + {selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"} + +
+ )} + {/* 카드 영역 (스크롤 가능) */}
{displayCards.map((row, index) => { - const codeValue = template?.header?.codeField && row[template.header.codeField] - ? String(row[template.header.codeField]) + const codeValue = effectiveTemplate?.header?.codeField && row[effectiveTemplate.header.codeField] + ? String(row[effectiveTemplate.header.codeField]) : null; const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`; return ( { + const cartId = String(row.__cart_id); + setSelectedKeys(prev => { + const next = new Set(prev); + if (next.has(cartId)) next.delete(cartId); + else next.add(cartId); + return next; + }); + }} + onDeleteItem={handleDeleteItem} + onUpdateQuantity={handleUpdateQuantity} /> ); })} @@ -681,6 +852,11 @@ function Card({ cart, codeFieldName, parentComponentId, + isCartListMode, + isSelected, + onToggleSelect, + onDeleteItem, + onUpdateQuantity, }: { row: RowData; template?: CardTemplateConfig; @@ -694,6 +870,11 @@ function Card({ cart: ReturnType; codeFieldName?: string; parentComponentId?: string; + isCartListMode?: boolean; + isSelected?: boolean; + onToggleSelect?: () => void; + onDeleteItem?: (cartId: string) => void; + onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void; }) { const header = template?.header; const image = template?.image; @@ -712,14 +893,24 @@ function Card({ const isCarted = cart.isItemInCart(rowKey); const existingCartItem = cart.getCartItem(rowKey); - // DB에서 로드된 장바구니 품목이면 입력값 복원 + // DB에서 로드된 장바구니 품목이면 입력값 복원 (기본 모드) useEffect(() => { + if (isCartListMode) return; if (existingCartItem && existingCartItem._origin === "db") { setInputValue(existingCartItem.quantity); setPackageUnit(existingCartItem.packageUnit); setPackageEntries(existingCartItem.packageEntries || []); } - }, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + }, [isCartListMode, existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + + // 장바구니 목록 모드: __cart_quantity에서 초기값 복원 + useEffect(() => { + if (!isCartListMode) return; + const cartQty = Number(row.__cart_quantity) || 0; + setInputValue(cartQty); + const cartUnit = row.__cart_package_unit ? String(row.__cart_package_unit) : undefined; + setPackageUnit(cartUnit); + }, [isCartListMode, row.__cart_quantity, row.__cart_package_unit]); const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] @@ -771,6 +962,9 @@ function Card({ setInputValue(value); setPackageUnit(unit); setPackageEntries(entries || []); + if (isCartListMode) { + onUpdateQuantity?.(String(row.__cart_id), value, unit, entries); + } }; // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달 @@ -806,6 +1000,23 @@ function Card({ } }; + // 장바구니 목록 모드: 개별 삭제 + const handleCartDelete = async (e: React.MouseEvent) => { + e.stopPropagation(); + const cartId = String(row.__cart_id); + if (!cartId) return; + + const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?"); + if (!ok) return; + + try { + await dataApi.deleteRecord("cart_items", cartId); + onDeleteItem?.(cartId); + } catch { + toast.error("삭제에 실패했습니다."); + } + }; + // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11))); const cartLabel = cartAction?.label || "담기"; @@ -815,22 +1026,43 @@ function Card({ onSelect?.(row); }; + // 카드 테두리: 장바구니 목록 모드에서는 선택 상태, 기본 모드에서는 담기 상태 기준 + 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 headerBgClass = isCartListMode + ? isSelected ? "bg-primary/10 dark:bg-primary/20" : "bg-muted/30" + : isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"; + return (
{ if (e.key === "Enter" || e.key === " ") handleCardClick(); }} > + {/* 장바구니 목록 모드: 체크박스 */} + {isCartListMode && ( + { e.stopPropagation(); onToggleSelect?.(); }} + onClick={(e) => e.stopPropagation()} + className="absolute left-2 top-2 z-10 h-4 w-4 rounded border-gray-300" + /> + )} + {/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && ( -
+
{codeValue !== null && (
- {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (각각 독립 ON/OFF) */} - {(inputField?.enabled || cartAction) && ( + {/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */} + {(inputField?.enabled || cartAction || isCartListMode) && (
)} - {/* 담기/취소 버튼 (cartAction 존재 시 항상 표시) */} - {cartAction && ( + {/* 장바구니 목록 모드: 삭제 버튼 */} + {isCartListMode && ( + + )} + + {/* 기본 모드: 담기/취소 버튼 (cartAction 존재 시) */} + {!isCartListMode && cartAction && ( <> {isCarted ? ( @@ -183,7 +186,16 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC /> )} {activeTab === "template" && ( - + isCartListMode ? ( +
+
+

원본 화면의 카드 설정을 자동으로 사용합니다

+

카드 디자인을 변경하려면 원본 화면에서 수정하세요

+
+
+ ) : ( + + ) )}
@@ -299,46 +311,58 @@ function BasicSettingsTab({ } }, [currentMode]); // eslint-disable-line react-hooks/exhaustive-deps + const isCartListMode = !!config.cartListMode?.enabled; + const updateDataSource = (partial: Partial) => { onUpdate({ dataSource: { ...dataSource, ...partial } }); }; return (
- {/* 테이블 선택 */} - -
-
- - { - onUpdate({ - dataSource: { - tableName: val, - joins: undefined, - filters: undefined, - sort: undefined, - limit: undefined, - }, - cardTemplate: DEFAULT_TEMPLATE, - }); - }} - /> -
- - {dataSource.tableName && ( -
- - {dataSource.tableName} -
- )} -
+ {/* 장바구니 목록 모드 */} + + onUpdate({ cartListMode })} + /> - {/* 조인 설정 (테이블 선택 시만 표시) */} - {dataSource.tableName && ( + {/* 테이블 선택 (장바구니 모드 시 숨김) */} + {!isCartListMode && ( + +
+
+ + { + onUpdate({ + dataSource: { + tableName: val, + joins: undefined, + filters: undefined, + sort: undefined, + limit: undefined, + }, + cardTemplate: DEFAULT_TEMPLATE, + }); + }} + /> +
+ + {dataSource.tableName && ( +
+ + {dataSource.tableName} +
+ )} +
+
+ )} + + {/* 조인 설정 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( )} - {/* 정렬 기준 (테이블 선택 시만 표시) */} - {dataSource.tableName && ( + {/* 정렬 기준 (장바구니 모드 시 숨김) */} + {!isCartListMode && dataSource.tableName && ( void; +}) { + const mode: CartListModeConfig = cartListMode || { enabled: false }; + const [screens, setScreens] = useState<{ id: number; name: string }[]>([]); + + useEffect(() => { + screenApi + .getScreens({ size: 500 }) + .then((res) => { + if (res?.data) { + setScreens( + res.data.map((s) => ({ + id: s.screenId, + name: s.screenName || `화면 ${s.screenId}`, + })) + ); + } + }) + .catch(() => {}); + }, []); + + return ( +
+
+ + onUpdate({ ...mode, enabled })} + /> +
+ +

+ 활성화하면 cart_items 테이블에서 데이터를 조회하고, + 원본 화면의 카드 설정을 자동으로 상속합니다. +

+ + {mode.enabled && ( + <> + {/* 원본 화면 선택 */} +
+ + +

+ 품목을 담았던 화면을 선택하면 카드 디자인이 자동으로 적용됩니다. +

+
+ + {/* 장바구니 구분값 */} +
+ + onUpdate({ ...mode, cartType: e.target.value })} + placeholder="예: purchase_inbound" + className="mt-1 h-7 text-xs" + /> +

+ 원본 화면의 담기 버튼에 설정한 구분값과 동일하게 입력하세요. +

+
+ + {/* 상태 필터 */} +
+ + +
+ + )} +
+ ); +} + // ===== 헤더 설정 섹션 ===== function HeaderSettingsSection({ diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx index 738dfa4c..e78782e2 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -63,10 +63,12 @@ PopComponentRegistry.registerComponent({ { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, { key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" }, { key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, + { key: "selected_items", label: "선택된 항목", type: "value", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" }, ], receivable: [ { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, + { key: "confirm_trigger", label: "확정 트리거", type: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index d3c77233..6b10982d 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -608,6 +608,15 @@ export interface CardResponsiveConfig { fields?: Record; } +// ----- 장바구니 목록 모드 설정 ----- + +export interface CartListModeConfig { + enabled: boolean; + sourceScreenId?: number; + cartType?: string; + statusFilter?: string; +} + // ----- pop-card-list 전체 설정 ----- export interface PopCardListConfig { @@ -620,10 +629,11 @@ export interface PopCardListConfig { gridColumns?: number; gridRows?: number; - // 반응형 표시 설정 responsiveDisplay?: CardResponsiveConfig; inputField?: CardInputFieldConfig; packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; + + cartListMode?: CartListModeConfig; }