diff --git a/docker/dev/docker-compose.frontend.mac.yml b/docker/dev/docker-compose.frontend.mac.yml index 6428d481..eda932da 100644 --- a/docker/dev/docker-compose.frontend.mac.yml +++ b/docker/dev/docker-compose.frontend.mac.yml @@ -9,6 +9,7 @@ services: - "9771:3000" environment: - NEXT_PUBLIC_API_URL=http://localhost:8080/api + - SERVER_API_URL=http://pms-backend-mac:8080 - NODE_OPTIONS=--max-old-space-size=8192 - NEXT_TELEMETRY_DISABLED=1 volumes: diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index f605b513..12c21e4f 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -213,6 +213,8 @@ export default function ComponentEditorPanel({ previewPageIndex={previewPageIndex} onPreviewPage={onPreviewPage} modals={modals} + allComponents={allComponents} + connections={connections} /> @@ -404,9 +406,11 @@ interface ComponentSettingsFormProps { previewPageIndex?: number; onPreviewPage?: (pageIndex: number) => void; modals?: PopModalDefinition[]; + allComponents?: PopComponentDefinitionV5[]; + connections?: PopDataConnection[]; } -function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals, allComponents, connections }: ComponentSettingsFormProps) { // PopComponentRegistry에서 configPanel 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const ConfigPanel = registeredComp?.configPanel; @@ -440,6 +444,9 @@ function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIn onPreviewPage={onPreviewPage} previewPageIndex={previewPageIndex} modals={modals} + allComponents={allComponents} + connections={connections} + componentId={component.id} /> ) : (
diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx index 2e92d602..725b4f3f 100644 --- a/frontend/components/pop/designer/panels/ConnectionEditor.tsx +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -272,6 +272,25 @@ function ConnectionForm({ ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta : null; + // 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭 + React.useEffect(() => { + if (!selectedOutput || !targetMeta?.receivable?.length) return; + // 이미 선택된 값이 있으면 건드리지 않음 + if (selectedTargetInput) return; + + const receivables = targetMeta.receivable; + // 1) 같은 key가 있으면 자동 매칭 + const exactMatch = receivables.find((r) => r.key === selectedOutput); + if (exactMatch) { + setSelectedTargetInput(exactMatch.key); + return; + } + // 2) receivable이 1개뿐이면 자동 선택 + if (receivables.length === 1) { + setSelectedTargetInput(receivables[0].key); + } + }, [selectedOutput, targetMeta, selectedTargetInput]); + // 화면에 표시 중인 컬럼 const displayColumns = React.useMemo( () => extractDisplayColumns(targetComp || undefined), @@ -322,6 +341,8 @@ function ConnectionForm({ const handleSubmit = () => { if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; + const isEvent = isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput); + onSubmit({ sourceComponent: component.id, sourceField: "", @@ -330,7 +351,7 @@ function ConnectionForm({ targetField: "", targetInput: selectedTargetInput, filterConfig: - filterColumns.length > 0 + !isEvent && filterColumns.length > 0 ? { targetColumn: filterColumns[0], targetColumns: filterColumns, @@ -427,8 +448,8 @@ function ConnectionForm({
)} - {/* 필터 설정 */} - {selectedTargetInput && ( + {/* 필터 설정: event 타입 연결이면 숨김 */} + {selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (

필터할 컬럼

@@ -607,6 +628,17 @@ function ReceiveSection({ // 유틸 // ======================================== +function isEventTypeConnection( + sourceMeta: ComponentConnectionMeta | undefined, + outputKey: string, + targetMeta: ComponentConnectionMeta | null | undefined, + inputKey: string, +): boolean { + const sourceItem = sourceMeta?.sendable?.find((s) => s.key === outputKey); + const targetItem = targetMeta?.receivable?.find((r) => r.key === inputKey); + return sourceItem?.type === "event" || targetItem?.type === "event"; +} + function buildConnectionLabel( source: PopComponentDefinitionV5, _outputKey: string, diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts index 3a6c792b..7ae7e953 100644 --- a/frontend/hooks/pop/index.ts +++ b/frontend/hooks/pop/index.ts @@ -22,5 +22,9 @@ export type { PendingConfirmState } from "./usePopAction"; // 연결 해석기 export { useConnectionResolver } from "./useConnectionResolver"; +// 장바구니 동기화 훅 +export { useCartSync } from "./useCartSync"; +export type { UseCartSyncReturn } from "./useCartSync"; + // SQL 빌더 유틸 (고급 사용 시) export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; diff --git a/frontend/hooks/pop/useCartSync.ts b/frontend/hooks/pop/useCartSync.ts new file mode 100644 index 00000000..e3b76ed5 --- /dev/null +++ b/frontend/hooks/pop/useCartSync.ts @@ -0,0 +1,338 @@ +/** + * useCartSync - 장바구니 DB 동기화 훅 + * + * DB(cart_items 테이블) <-> 로컬 상태를 동기화하고 변경사항(dirty)을 감지한다. + * + * 동작 방식: + * 1. 마운트 시 DB에서 해당 screen_id + user_id의 장바구니를 로드 + * 2. addItem/removeItem/updateItem은 로컬 상태만 변경 (DB 미반영, dirty 상태) + * 3. saveToDb 호출 시 로컬 상태를 DB에 일괄 반영 (추가/수정/삭제) + * 4. isDirty = 로컬 상태와 DB 마지막 로드 상태의 차이 존재 여부 + * + * 사용 예시: + * ```typescript + * const cart = useCartSync("SCR-001", "item_info"); + * + * // 품목 추가 (로컬만, DB 미반영) + * cart.addItem({ row, quantity: 10 }, "D1710008"); + * + * // DB 저장 (pop-icon 확인 모달에서 호출) + * await cart.saveToDb(); + * ``` + */ + +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import { dataApi } from "@/lib/api/data"; +import type { + CartItem, + CartItemWithId, + CartSyncStatus, + CartItemStatus, +} from "@/lib/registry/pop-components/types"; + +// ===== 반환 타입 ===== + +export interface UseCartSyncReturn { + cartItems: CartItemWithId[]; + savedItems: CartItemWithId[]; + syncStatus: CartSyncStatus; + cartCount: number; + isDirty: boolean; + loading: boolean; + + addItem: (item: CartItem, rowKey: string) => void; + removeItem: (rowKey: string) => void; + updateItemQuantity: (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => void; + isItemInCart: (rowKey: string) => boolean; + getCartItem: (rowKey: string) => CartItemWithId | undefined; + + saveToDb: (selectedColumns?: string[]) => Promise; + loadFromDb: () => Promise; + resetToSaved: () => void; +} + +// ===== DB 행 -> CartItemWithId 변환 ===== + +function dbRowToCartItem(dbRow: Record): CartItemWithId { + 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 = {}; + } + + let packageEntries: CartItem["packageEntries"] | undefined; + try { + const raw = dbRow.package_entries; + if (typeof raw === "string" && raw.trim()) { + packageEntries = JSON.parse(raw); + } else if (Array.isArray(raw)) { + packageEntries = raw; + } + } catch { + packageEntries = undefined; + } + + return { + row: rowData, + quantity: Number(dbRow.quantity) || 0, + packageUnit: (dbRow.package_unit as string) || undefined, + packageEntries, + cartId: (dbRow.id as string) || undefined, + sourceTable: (dbRow.source_table as string) || "", + rowKey: (dbRow.row_key as string) || "", + status: ((dbRow.status as string) || "in_cart") as CartItemStatus, + _origin: "db", + memo: (dbRow.memo as string) || undefined, + }; +} + +// ===== CartItemWithId -> DB 저장용 레코드 변환 ===== + +function cartItemToDbRecord( + item: CartItemWithId, + screenId: string, + cartType: string = "pop", + selectedColumns?: string[], +): Record { + // selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장 + const rowData = + selectedColumns && selectedColumns.length > 0 + ? Object.fromEntries( + Object.entries(item.row).filter(([k]) => selectedColumns.includes(k)), + ) + : item.row; + + return { + cart_type: cartType, + screen_id: screenId, + source_table: item.sourceTable, + row_key: item.rowKey, + row_data: JSON.stringify(rowData), + quantity: String(item.quantity), + unit: "", + package_unit: item.packageUnit || "", + package_entries: item.packageEntries ? JSON.stringify(item.packageEntries) : "", + status: item.status, + memo: item.memo || "", + }; +} + +// ===== dirty check: 두 배열의 내용이 동일한지 비교 ===== + +function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean { + if (a.length !== b.length) return false; + + const serialize = (items: CartItemWithId[]) => + items + .map((item) => `${item.rowKey}:${item.quantity}:${item.packageUnit || ""}:${item.status}`) + .sort() + .join("|"); + + return serialize(a) === serialize(b); +} + +// ===== 훅 본체 ===== + +export function useCartSync( + screenId: string, + sourceTable: string, + cartType?: string, +): UseCartSyncReturn { + const [cartItems, setCartItems] = useState([]); + const [savedItems, setSavedItems] = useState([]); + const [syncStatus, setSyncStatus] = useState("clean"); + const [loading, setLoading] = useState(false); + + const screenIdRef = useRef(screenId); + const sourceTableRef = useRef(sourceTable); + const cartTypeRef = useRef(cartType || "pop"); + screenIdRef.current = screenId; + sourceTableRef.current = sourceTable; + cartTypeRef.current = cartType || "pop"; + + // ----- DB에서 장바구니 로드 ----- + const loadFromDb = useCallback(async () => { + if (!screenId) return; + setLoading(true); + try { + const result = await dataApi.getTableData("cart_items", { + size: 500, + filters: { + screen_id: screenId, + cart_type: cartTypeRef.current, + status: "in_cart", + }, + }); + + const items = (result.data || []).map(dbRowToCartItem); + setSavedItems(items); + setCartItems(items); + setSyncStatus("clean"); + } catch (err) { + console.error("[useCartSync] DB 로드 실패:", err); + } finally { + setLoading(false); + } + }, [screenId]); + + // 마운트 시 자동 로드 + useEffect(() => { + loadFromDb(); + }, [loadFromDb]); + + // ----- dirty 상태 계산 ----- + const isDirty = !areItemsEqual(cartItems, savedItems); + + // isDirty 변경 시 syncStatus 자동 갱신 + useEffect(() => { + if (syncStatus !== "saving") { + setSyncStatus(isDirty ? "dirty" : "clean"); + } + }, [isDirty, syncStatus]); + + // ----- 로컬 조작 (DB 미반영) ----- + + const addItem = useCallback( + (item: CartItem, rowKey: string) => { + setCartItems((prev) => { + const exists = prev.find((i) => i.rowKey === rowKey); + if (exists) { + return prev.map((i) => + i.rowKey === rowKey + ? { ...i, quantity: item.quantity, packageUnit: item.packageUnit, packageEntries: item.packageEntries, row: item.row } + : i, + ); + } + const newItem: CartItemWithId = { + ...item, + cartId: undefined, + sourceTable: sourceTableRef.current, + rowKey, + status: "in_cart", + _origin: "local", + }; + return [...prev, newItem]; + }); + }, + [], + ); + + const removeItem = useCallback((rowKey: string) => { + setCartItems((prev) => prev.filter((i) => i.rowKey !== rowKey)); + }, []); + + const updateItemQuantity = useCallback( + (rowKey: string, quantity: number, packageUnit?: string, packageEntries?: CartItem["packageEntries"]) => { + setCartItems((prev) => + prev.map((i) => + i.rowKey === rowKey + ? { + ...i, + quantity, + ...(packageUnit !== undefined && { packageUnit }), + ...(packageEntries !== undefined && { packageEntries }), + } + : i, + ), + ); + }, + [], + ); + + const isItemInCart = useCallback( + (rowKey: string) => cartItems.some((i) => i.rowKey === rowKey), + [cartItems], + ); + + const getCartItem = useCallback( + (rowKey: string) => cartItems.find((i) => i.rowKey === rowKey), + [cartItems], + ); + + // ----- DB 저장 (일괄) ----- + const saveToDb = useCallback(async (selectedColumns?: string[]): Promise => { + setSyncStatus("saving"); + try { + const currentScreenId = screenIdRef.current; + + // 삭제 대상: savedItems에 있지만 cartItems에 없는 것 + const cartRowKeys = new Set(cartItems.map((i) => i.rowKey)); + const toDelete = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey)); + + // 추가 대상: cartItems에 있지만 cartId가 없는 것 (로컬에서 추가됨) + const toCreate = cartItems.filter((c) => !c.cartId); + + // 수정 대상: 양쪽 다 존재하고 cartId 있으면서 내용이 다른 것 + const savedMap = new Map(savedItems.map((s) => [s.rowKey, s])); + const toUpdate = cartItems.filter((c) => { + if (!c.cartId) return false; + const saved = savedMap.get(c.rowKey); + if (!saved) return false; + return ( + c.quantity !== saved.quantity || + c.packageUnit !== saved.packageUnit || + c.status !== saved.status + ); + }); + + const promises: Promise[] = []; + + for (const item of toDelete) { + promises.push(dataApi.deleteRecord("cart_items", item.cartId!)); + } + + const currentCartType = cartTypeRef.current; + + for (const item of toCreate) { + const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns); + promises.push(dataApi.createRecord("cart_items", record)); + } + + for (const item of toUpdate) { + const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns); + promises.push(dataApi.updateRecord("cart_items", item.cartId!, record)); + } + + await Promise.all(promises); + + // 저장 후 DB에서 다시 로드하여 cartId 등을 최신화 + await loadFromDb(); + return true; + } catch (err) { + console.error("[useCartSync] DB 저장 실패:", err); + setSyncStatus("dirty"); + return false; + } + }, [cartItems, savedItems, loadFromDb]); + + // ----- 로컬 변경 취소 ----- + const resetToSaved = useCallback(() => { + setCartItems(savedItems); + setSyncStatus("clean"); + }, [savedItems]); + + return { + cartItems, + savedItems, + syncStatus, + cartCount: cartItems.length, + isDirty, + loading, + addItem, + removeItem, + updateItemQuantity, + isItemInCart, + getCartItem, + saveToDb, + loadFromDb, + resetToSaved, + }; +} diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx index b5532c30..a9ea0ece 100644 --- a/frontend/lib/registry/pop-components/pop-button.tsx +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback } from "react"; +import React, { useCallback, useState, useEffect, useMemo } from "react"; import { cn } from "@/lib/utils"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; @@ -24,8 +24,10 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { DataFlowAPI } from "@/lib/api/dataflow"; import { usePopAction } from "@/hooks/pop/usePopAction"; import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; import { Save, Trash2, @@ -44,6 +46,8 @@ import { Copy, Settings, ChevronDown, + ShoppingCart, + ShoppingBag, type LucideIcon, } from "lucide-react"; import { toast } from "sonner"; @@ -113,18 +117,30 @@ export type ButtonPreset = | "logout" | "menu" | "modal-open" + | "cart" | "custom"; +/** row_data 저장 모드 */ +export type RowDataMode = "all" | "selected"; + +/** 장바구니 버튼 전용 설정 */ +export interface CartButtonConfig { + cartScreenId?: string; + rowDataMode?: RowDataMode; + selectedColumns?: string[]; +} + /** pop-button 전체 설정 */ export interface PopButtonConfig { label: string; variant: ButtonVariant; - icon?: string; // Lucide 아이콘 이름 + icon?: string; iconOnly?: boolean; preset: ButtonPreset; confirm?: ConfirmConfig; action: ButtonMainAction; followUpActions?: FollowUpAction[]; + cart?: CartButtonConfig; } // ======================================== @@ -163,6 +179,7 @@ const PRESET_LABELS: Record = { logout: "로그아웃", menu: "메뉴 (드롭다운)", "modal-open": "모달 열기", + cart: "장바구니 저장", custom: "직접 설정", }; @@ -201,6 +218,8 @@ const ICON_OPTIONS: { value: string; label: string }[] = [ { value: "Copy", label: "복사 (Copy)" }, { value: "Settings", label: "설정 (Settings)" }, { value: "ChevronDown", label: "아래 화살표 (ChevronDown)" }, + { value: "ShoppingCart", label: "장바구니 (ShoppingCart)" }, + { value: "ShoppingBag", label: "장바구니 담김 (ShoppingBag)" }, ]; /** 프리셋별 기본 설정 */ @@ -244,6 +263,13 @@ const PRESET_DEFAULTS: Record> = { confirm: { enabled: false }, action: { type: "modal", modalMode: "fullscreen" }, }, + cart: { + label: "장바구니 저장", + variant: "default", + icon: "ShoppingCart", + confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" }, + action: { type: "event" }, + }, custom: { label: "버튼", variant: "default", @@ -279,10 +305,42 @@ function SectionDivider({ label }: { label: string }) { ); } +/** 장바구니 데이터 매핑 행 (읽기 전용) */ +function CartMappingRow({ + source, + target, + desc, + auto, +}: { + source: string; + target: string; + desc?: string; + auto?: boolean; +}) { + return ( +
+ + {source} + + +
+ + {target} + + {desc && ( +

{desc}

+ )} +
+
+ ); +} + /** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */ const LUCIDE_ICON_MAP: Record = { Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X, Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown, + ShoppingCart, + ShoppingBag, }; /** Lucide 아이콘 동적 렌더링 */ @@ -309,6 +367,7 @@ interface PopButtonComponentProps { label?: string; isDesignMode?: boolean; screenId?: string; + componentId?: string; } export function PopButtonComponent({ @@ -316,8 +375,8 @@ export function PopButtonComponent({ label, isDesignMode, screenId, + componentId, }: PopButtonComponentProps) { - // usePopAction 훅으로 액션 실행 통합 const { execute, isLoading, @@ -326,23 +385,127 @@ export function PopButtonComponent({ cancelConfirm, } = usePopAction(screenId || ""); - // 확인 메시지 결정 + const { subscribe, publish } = usePopEvent(screenId || "default"); + + // 장바구니 모드 상태 + const isCartMode = config?.preset === "cart"; + const [cartCount, setCartCount] = useState(0); + const [cartIsDirty, setCartIsDirty] = useState(false); + const [cartSaving, setCartSaving] = useState(false); + const [showCartConfirm, setShowCartConfirm] = useState(false); + + // 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달) + useEffect(() => { + if (!isCartMode || !componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__cart_updated`, + (payload: unknown) => { + const data = payload as { value?: { count?: number; isDirty?: boolean } } | undefined; + const inner = data?.value; + if (inner?.count !== undefined) setCartCount(inner.count); + if (inner?.isDirty !== undefined) setCartIsDirty(inner.isDirty); + } + ); + return unsub; + }, [isCartMode, componentId, subscribe]); + + // 저장 완료 수신 (카드 목록에서 saveToDb 완료 후 전달) + const cartScreenIdRef = React.useRef(config?.cart?.cartScreenId); + cartScreenIdRef.current = config?.cart?.cartScreenId; + + useEffect(() => { + if (!isCartMode || !componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__cart_save_completed`, + (payload: unknown) => { + const data = payload as { value?: { success?: boolean } } | undefined; + setCartSaving(false); + if (data?.value?.success) { + setCartIsDirty(false); + const targetScreenId = cartScreenIdRef.current; + if (targetScreenId) { + const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim(); + window.location.href = `/pop/screens/${cleanId}`; + } else { + toast.success("장바구니가 저장되었습니다."); + } + } else { + toast.error("장바구니 저장에 실패했습니다."); + } + } + ); + return unsub; + }, [isCartMode, componentId, subscribe]); + const getConfirmMessage = useCallback((): string => { if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message; if (config?.confirm?.message) return config.confirm.message; return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]; }, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]); + // 장바구니 저장 트리거 (연결 미설정 시 10초 타임아웃으로 복구) + const cartSaveTimeoutRef = React.useRef | null>(null); + + const handleCartSave = useCallback(() => { + if (!componentId) return; + setCartSaving(true); + const selectedCols = + config?.cart?.rowDataMode === "selected" ? config.cart.selectedColumns : undefined; + publish(`__comp_output__${componentId}__cart_save_trigger`, { + selectedColumns: selectedCols, + }); + + if (cartSaveTimeoutRef.current) clearTimeout(cartSaveTimeoutRef.current); + cartSaveTimeoutRef.current = setTimeout(() => { + setCartSaving((prev) => { + if (prev) { + toast.error("장바구니 저장 응답이 없습니다. 연결 설정을 확인하세요."); + } + return false; + }); + }, 10_000); + }, [componentId, publish, config?.cart?.rowDataMode, config?.cart?.selectedColumns]); + + // 저장 완료 시 타임아웃 정리 + useEffect(() => { + if (!cartSaving && cartSaveTimeoutRef.current) { + clearTimeout(cartSaveTimeoutRef.current); + cartSaveTimeoutRef.current = null; + } + }, [cartSaving]); + // 클릭 핸들러 const handleClick = useCallback(async () => { - // 디자인 모드: 실제 실행 안 함 if (isDesignMode) { toast.info( - `[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션` + `[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션` ); return; } + // 장바구니 모드: isDirty 여부에 따라 분기 + if (isCartMode) { + if (cartCount === 0 && !cartIsDirty) { + toast.info("장바구니가 비어 있습니다."); + return; + } + + if (cartIsDirty) { + // 새로 담은 항목이 있음 → 확인 후 저장 + setShowCartConfirm(true); + } else { + // 이미 저장된 상태 → 바로 장바구니 화면 이동 + const targetScreenId = config?.cart?.cartScreenId; + if (targetScreenId) { + const cleanId = targetScreenId.replace(/^.*\/(\d+)$/, "$1").trim(); + window.location.href = `/pop/screens/${cleanId}`; + } else { + toast.info("장바구니 화면이 설정되지 않았습니다."); + } + } + return; + } + const action = config?.action; if (!action) return; @@ -350,7 +513,7 @@ export function PopButtonComponent({ confirm: config?.confirm, followUpActions: config?.followUpActions, }); - }, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]); + }, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]); // 외형 const buttonLabel = config?.label || label || "버튼"; @@ -358,30 +521,96 @@ export function PopButtonComponent({ const iconName = config?.icon || ""; const isIconOnly = config?.iconOnly || false; + // 장바구니 3상태 아이콘: 빈 장바구니 / 저장 완료 / 변경사항 있음 + const cartIconName = useMemo(() => { + if (!isCartMode) return iconName; + if (cartCount === 0 && !cartIsDirty) return "ShoppingCart"; + if (cartCount > 0 && !cartIsDirty) return "ShoppingBag"; + return "ShoppingCart"; + }, [isCartMode, cartCount, cartIsDirty, iconName]); + + // 장바구니 3상태 버튼 색상 + const cartButtonClass = useMemo(() => { + if (!isCartMode) return ""; + if (cartCount > 0 && !cartIsDirty) { + return "bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600"; + } + if (cartIsDirty) { + return "bg-orange-500 hover:bg-orange-600 text-white border-orange-500 animate-pulse"; + } + return ""; + }, [isCartMode, cartCount, cartIsDirty]); + return ( <>
- + + {/* 장바구니 배지 */} + {isCartMode && cartCount > 0 && ( +
+ {cartCount} +
)} - > - {iconName && ( - - )} - {!isIconOnly && {buttonLabel}} - +
- {/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */} + {/* 장바구니 확인 다이얼로그 */} + + + + + 장바구니 저장 + + + {config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`} + + + + + 취소 + + { + setShowCartConfirm(false); + handleCartSave(); + }} + className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" + > + 저장 + + + + + + {/* 일반 확인 다이얼로그 */} { if (!open) cancelConfirm(); }}> @@ -420,14 +649,117 @@ export function PopButtonComponent({ interface PopButtonConfigPanelProps { config: PopButtonConfig; onUpdate: (config: PopButtonConfig) => void; + allComponents?: { id: string; type: string; config?: Record }[]; + connections?: { sourceComponent: string; targetComponent: string; sourceOutput?: string; targetInput?: string }[]; + componentId?: string; } export function PopButtonConfigPanel({ config, onUpdate, + allComponents, + connections, + componentId, }: PopButtonConfigPanelProps) { const isCustom = config?.preset === "custom"; + // 컬럼 불러오기용 상태 + const [loadedColumns, setLoadedColumns] = useState<{ name: string; label: string }[]>([]); + const [colLoading, setColLoading] = useState(false); + const [connectedTableName, setConnectedTableName] = useState(null); + + // 연결된 카드 목록의 테이블명 자동 탐색 + useEffect(() => { + if (config?.preset !== "cart" || !componentId || !connections || !allComponents) { + setConnectedTableName(null); + return; + } + + // 방법 1: 버튼(source) -> 카드목록(target) cart_save_trigger 연결 + let cardListId: string | undefined; + const outConn = connections.find( + (c) => + c.sourceComponent === componentId && + c.sourceOutput === "cart_save_trigger", + ); + if (outConn) { + cardListId = outConn.targetComponent; + } + + // 방법 2: 카드목록(source) -> 버튼(target) cart_updated 연결 (역방향) + if (!cardListId) { + const inConn = connections.find( + (c) => + c.targetComponent === componentId && + (c.sourceOutput === "cart_updated" || c.sourceOutput === "cart_save_completed"), + ); + if (inConn) { + cardListId = inConn.sourceComponent; + } + } + + // 방법 3: 버튼과 연결된 pop-card-list 타입 컴포넌트 탐색 + if (!cardListId) { + const anyConn = connections.find( + (c) => + (c.sourceComponent === componentId || c.targetComponent === componentId), + ); + if (anyConn) { + const otherId = anyConn.sourceComponent === componentId + ? anyConn.targetComponent + : anyConn.sourceComponent; + const otherComp = allComponents.find((c) => c.id === otherId); + if (otherComp?.type === "pop-card-list") { + cardListId = otherId; + } + } + } + + if (!cardListId) { + setConnectedTableName(null); + return; + } + + const cardList = allComponents.find((c) => c.id === cardListId); + const cfg = cardList?.config as Record | undefined; + const dataSource = cfg?.dataSource as Record | undefined; + const tableName = (dataSource?.tableName as string) || (cfg?.tableName as string) || undefined; + setConnectedTableName(tableName || null); + }, [config?.preset, componentId, connections, allComponents]); + + // 선택 저장 모드 + 연결 테이블명이 있으면 컬럼 자동 로드 + useEffect(() => { + if (config?.cart?.rowDataMode !== "selected" || !connectedTableName) { + return; + } + // 이미 같은 테이블의 컬럼이 로드되어 있으면 스킵 + if (loadedColumns.length > 0) return; + + let cancelled = false; + setColLoading(true); + DataFlowAPI.getTableColumns(connectedTableName) + .then((cols) => { + if (cancelled) return; + setLoadedColumns( + cols + .filter((c: { columnName: string }) => + !["id", "created_at", "updated_at", "created_by", "updated_by"].includes(c.columnName), + ) + .map((c: { columnName: string; displayName?: string }) => ({ + name: c.columnName, + label: c.displayName || c.columnName, + })), + ); + }) + .catch(() => { + if (!cancelled) setLoadedColumns([]); + }) + .finally(() => { + if (!cancelled) setColLoading(false); + }); + return () => { cancelled = true; }; + }, [config?.cart?.rowDataMode, connectedTableName, loadedColumns.length]); + // 프리셋 변경 핸들러 const handlePresetChange = (preset: ButtonPreset) => { const defaults = PRESET_DEFAULTS[preset]; @@ -554,44 +886,203 @@ export function PopButtonConfigPanel({ - {/* 메인 액션 */} - -
- {/* 액션 타입 */} -
- - - {!isCustom && ( -

- 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 -

- )} -
+ {/* 장바구니 설정 (cart 프리셋 전용) */} + {config?.preset === "cart" && ( + <> + +
+
+ + + onUpdate({ + ...config, + cart: { ...config.cart, cartScreenId: e.target.value }, + }) + } + placeholder="저장 후 이동할 POP 화면 ID" + className="h-8 text-xs" + /> +

+ 저장 완료 후 이동할 장바구니 리스트 화면 ID입니다. + 비어있으면 이동 없이 저장만 합니다. +

+
+
- {/* 액션별 추가 설정 */} - -
+ {/* 데이터 저장 흐름 시각화 */} + +
+

+ 카드 목록에서 "담기" 클릭 시 아래와 같이 cart_items 테이블에 저장됩니다. +

+ +
+ {/* 사용자 입력 데이터 */} +
+

사용자 입력

+ + + + +
+ + {/* 원본 데이터 */} +
+

원본 행 데이터

+ + {/* 저장 모드 선택 */} +
+ 저장 모드: + +
+ + {config?.cart?.rowDataMode === "selected" ? ( + <> + {/* 선택 저장 모드: 컬럼 목록 관리 */} +
+ {connectedTableName ? ( +

+ 연결: {connectedTableName} +

+ ) : ( +

+ 카드 목록과 연결(cart_save_trigger)하면 컬럼이 자동으로 표시됩니다. +

+ )} + + {colLoading && ( +

컬럼 불러오는 중...

+ )} + + {/* 불러온 컬럼 체크박스 */} + {loadedColumns.length > 0 && ( +
+ {loadedColumns.map((col) => { + const isChecked = (config?.cart?.selectedColumns || []).includes(col.name); + return ( + + ); + })} +
+ )} + + {/* 선택된 컬럼 요약 */} + {(config?.cart?.selectedColumns?.length ?? 0) > 0 ? ( + + ) : ( +

+ 저장할 컬럼을 선택하세요. 미선택 시 전체 저장됩니다. +

+ )} +
+ + ) : ( + + )} + + + +
+ + {/* 시스템 자동 */} +
+

자동 설정

+ + + + + +
+
+ +

+ 장바구니 목록 화면에서 row_data의 JSON을 풀어서 + 최종 대상 테이블로 매핑할 수 있습니다. +

+
+ + )} + + {/* 메인 액션 (cart 프리셋에서는 숨김) */} + {config?.preset !== "cart" && ( + <> + +
+
+ + + {!isCustom && ( +

+ 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 +

+ )} +
+ +
+ + )} {/* 확인 다이얼로그 */} @@ -980,7 +1471,7 @@ function PopButtonPreviewComponent({ PopComponentRegistry.registerComponent({ id: "pop-button", name: "버튼", - description: "액션 버튼 (저장/삭제/API/모달/이벤트)", + description: "액션 버튼 (저장/삭제/API/모달/이벤트/장바구니)", category: "action", icon: "MousePointerClick", component: PopButtonComponent, @@ -993,6 +1484,15 @@ PopComponentRegistry.registerComponent({ confirm: { enabled: false }, action: { type: "save" }, } as PopButtonConfig, + connectionMeta: { + sendable: [ + { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" }, + ], + receivable: [ + { key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" }, + ], + }, touchOptimized: true, supportedDevices: ["mobile", "tablet"], }); diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx index 209234ec..6e818a0c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -6,8 +6,10 @@ import { Dialog, DialogPortal, DialogOverlay, + DialogTitle, } from "@/components/ui/dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { PackageUnitModal, PACKAGE_UNITS, @@ -62,7 +64,7 @@ export function NumberInputModal({ () => entries.reduce((sum, e) => sum + e.totalQuantity, 0), [entries] ); - const remainingQuantity = maxValue - entriesTotal; + const remainingQuantity = Math.max(0, maxValue - entriesTotal); useEffect(() => { if (open) { @@ -81,7 +83,7 @@ export function NumberInputModal({ : step === "package_count" ? 9999 : step === "quantity_per_unit" - ? remainingQuantity > 0 ? remainingQuantity : maxValue + ? Math.max(1, remainingQuantity) : maxValue; const handleNumberClick = (num: string) => { @@ -117,7 +119,7 @@ export function NumberInputModal({ if (step === "quantity_per_unit") { if (numericValue <= 0 || !selectedUnit) return; - const total = packageCount * numericValue; + const total = Math.min(packageCount * numericValue, remainingQuantity); const newEntry: PackageEntry = { unitId: selectedUnit.id, unitLabel: selectedUnit.label, @@ -228,6 +230,7 @@ export function NumberInputModal({ + 수량 입력 {/* 헤더 */}
diff --git a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx index ad050744..bc32805c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx @@ -2,12 +2,14 @@ import React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { X } from "lucide-react"; import { Dialog, DialogPortal, DialogOverlay, DialogClose, + DialogTitle, } from "@/components/ui/dialog"; import type { CustomPackageUnit } from "../types"; @@ -60,8 +62,9 @@ export function PackageUnitModal({ + 포장 단위 선택
-

📦 포장 단위 선택

+

포장 단위 선택

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 9d21a8d4..e3e7dc4c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -35,6 +35,7 @@ import { } from "../types"; import { dataApi } from "@/lib/api/data"; import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { useCartSync } from "@/hooks/pop/useCartSync"; import { NumberInputModal } from "./NumberInputModal"; const LUCIDE_ICON_MAP: Record = { @@ -163,9 +164,14 @@ export function PopCardListComponent({ const dataSource = config?.dataSource; const template = config?.cardTemplate; - const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default"); + const { subscribe, publish } = usePopEvent(screenId || "default"); const router = useRouter(); + // 장바구니 DB 동기화 + const sourceTableName = dataSource?.tableName || ""; + const cartType = config?.cartAction?.cartType; + const cart = useCartSync(screenId || "", sourceTableName, cartType); + // 데이터 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(true); @@ -209,6 +215,35 @@ export function PopCardListComponent({ return unsub; }, [componentId, subscribe]); + // cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용 + const cartRef = useRef(cart); + cartRef.current = cart; + + // "저장 요청" 이벤트 수신: 버튼 컴포넌트가 장바구니 저장을 요청하면 saveToDb 실행 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__cart_save_trigger`, + async (payload: unknown) => { + const data = payload as { value?: { selectedColumns?: string[] } } | undefined; + const ok = await cartRef.current.saveToDb(data?.value?.selectedColumns); + publish(`__comp_output__${componentId}__cart_save_completed`, { + success: ok, + }); + } + ); + return unsub; + }, [componentId, subscribe, publish]); + + // DB 로드 완료 후 초기 장바구니 상태를 버튼에 전달 + useEffect(() => { + if (!componentId || cart.loading) return; + publish(`__comp_output__${componentId}__cart_updated`, { + count: cart.cartCount, + isDirty: cart.isDirty, + }); + }, [componentId, cart.loading, cart.cartCount, cart.isDirty, publish]); + // 카드 선택 시 selected_row 이벤트 발행 const handleCardSelect = useCallback((row: RowData) => { if (!componentId) return; @@ -229,7 +264,9 @@ export function PopCardListComponent({ useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { - const { width, height } = entries[0].contentRect; + const entry = entries[0]; + if (!entry) return; + const { width, height } = entry.contentRect; if (width > 0) setContainerWidth(width); if (height > 0) setContainerHeight(height); }); @@ -239,7 +276,7 @@ export function PopCardListComponent({ // 이미지 URL 없는 항목 카운트 (toast 중복 방지용) const missingImageCountRef = useRef(0); - const toastShownRef = useRef(false); + const cardSizeKey = config?.cardSize || "large"; const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large; @@ -256,7 +293,7 @@ export function PopCardListComponent({ const autoColumns = containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap))) : maxGridColumns; - const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns); + const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns)); // 행 수: 설정값 그대로 사용 (containerHeight 연동 시 피드백 루프 위험) const gridRows = configGridRows; @@ -428,7 +465,6 @@ export function PopCardListComponent({ setLoading(true); setError(null); missingImageCountRef.current = 0; - toastShownRef.current = false; try { const filters: Record = {}; @@ -476,25 +512,11 @@ export function PopCardListComponent({ fetchData(); }, [dataSourceKey]); // eslint-disable-line react-hooks/exhaustive-deps - // 이미지 URL 없는 항목 체크 및 toast 표시 + // 이미지 URL 없는 항목 카운트 (내부 추적용, toast 미표시) useEffect(() => { - if ( - !loading && - rows.length > 0 && - template?.image?.enabled && - template?.image?.imageColumn && - !toastShownRef.current - ) { + if (!loading && rows.length > 0 && template?.image?.enabled && template?.image?.imageColumn) { const imageColumn = template.image.imageColumn; - const missingCount = rows.filter((row) => !row[imageColumn]).length; - - if (missingCount > 0) { - missingImageCountRef.current = missingCount; - toastShownRef.current = true; - toast.warning( - `${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다` - ); - } + missingImageCountRef.current = rows.filter((row) => !row[imageColumn]).length; } }, [loading, rows, template?.image]); @@ -558,9 +580,10 @@ export function PopCardListComponent({ }} > {displayCards.map((row, index) => { - const rowKey = template?.header?.codeField && row[template.header.codeField] + const codeValue = template?.header?.codeField && row[template.header.codeField] ? String(row[template.header.codeField]) - : `card-${index}`; + : null; + const rowKey = codeValue ? `${codeValue}-${index}` : `card-${index}`; return ( ); })} @@ -652,10 +676,11 @@ function Card({ packageConfig, cartAction, publish, - getSharedData, - setSharedData, router, onSelect, + cart, + codeFieldName, + parentComponentId, }: { row: RowData; template?: CardTemplateConfig; @@ -664,10 +689,11 @@ function Card({ packageConfig?: CardPackageConfig; cartAction?: CardCartActionConfig; publish: (eventName: string, payload?: unknown) => void; - getSharedData: (key: string) => T | undefined; - setSharedData: (key: string, value: unknown) => void; router: ReturnType; onSelect?: (row: RowData) => void; + cart: ReturnType; + codeFieldName?: string; + parentComponentId?: string; }) { const header = template?.header; const image = template?.image; @@ -677,11 +703,24 @@ function Card({ const [packageUnit, setPackageUnit] = useState(undefined); const [packageEntries, setPackageEntries] = useState([]); const [isModalOpen, setIsModalOpen] = useState(false); - const [isCarted, setIsCarted] = useState(false); const codeValue = header?.codeField ? row[header.codeField] : null; const titleValue = header?.titleField ? row[header.titleField] : null; + // 장바구니 상태: codeField 값을 rowKey로 사용 + const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : ""; + const isCarted = cart.isItemInCart(rowKey); + const existingCartItem = cart.getCartItem(rowKey); + + // DB에서 로드된 장바구니 품목이면 입력값 복원 + useEffect(() => { + if (existingCartItem && existingCartItem._origin === "db") { + setInputValue(existingCartItem.quantity); + setPackageUnit(existingCartItem.packageUnit); + setPackageEntries(existingCartItem.packageEntries || []); + } + }, [existingCartItem?._origin, existingCartItem?.quantity, existingCartItem?.packageUnit]); + const imageUrl = image?.enabled && image?.imageColumn && row[image.imageColumn] ? String(row[image.imageColumn]) @@ -734,8 +773,10 @@ function Card({ setPackageEntries(entries || []); }; - // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 + // 담기: 로컬 상태에만 추가 + 연결 시스템으로 카운트 전달 const handleCartAdd = () => { + if (!rowKey) return; + const cartItem: CartItem = { row, quantity: inputValue, @@ -743,30 +784,26 @@ function Card({ packageEntries: packageEntries.length > 0 ? packageEntries : undefined, }; - const existing = getSharedData("cart_items") || []; - setSharedData("cart_items", [...existing, cartItem]); - publish("cart_item_added", cartItem); - - setIsCarted(true); - toast.success("장바구니에 담겼습니다."); - - if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) { - router.push(`/pop/screens/${cartAction.targetScreenId}`); + cart.addItem(cartItem, rowKey); + if (parentComponentId) { + publish(`__comp_output__${parentComponentId}__cart_updated`, { + count: cart.cartCount + 1, + isDirty: true, + }); } }; - // 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원 + // 취소: 로컬 상태에서만 제거 + 연결 시스템으로 카운트 전달 const handleCartCancel = () => { - const existing = getSharedData("cart_items") || []; - const rowKey = JSON.stringify(row); - const filtered = existing.filter( - (item) => JSON.stringify(item.row) !== rowKey - ); - setSharedData("cart_items", filtered); - publish("cart_item_removed", { row }); + if (!rowKey) return; - setIsCarted(false); - toast.info("장바구니에서 제거되었습니다."); + cart.removeItem(rowKey); + if (parentComponentId) { + publish(`__comp_output__${parentComponentId}__cart_updated`, { + count: Math.max(0, cart.cartCount - 1), + isDirty: true, + }); + } }; // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) @@ -780,7 +817,11 @@ function Card({ return (
{/* 헤더 영역 */} {(codeValue !== null || titleValue !== null) && ( -
+
{codeValue !== null && ( {/* 담기 버튼 설정 */} - + onUpdate({ cartAction })} + cardTemplate={template} + tableName={dataSource.tableName} /> @@ -2136,14 +2138,16 @@ function LimitSettingsSection({ function CartActionSettingsSection({ cartAction, onUpdate, + cardTemplate, + tableName, }: { cartAction?: CardCartActionConfig; onUpdate: (cartAction: CardCartActionConfig) => void; + cardTemplate?: CardTemplateConfig; + tableName?: string; }) { const action: CardCartActionConfig = cartAction || { - navigateMode: "none", - iconType: "lucide", - iconValue: "ShoppingCart", + saveMode: "cart", label: "담기", cancelLabel: "취소", }; @@ -2152,82 +2156,63 @@ function CartActionSettingsSection({ onUpdate({ ...action, ...partial }); }; + const saveMode = action.saveMode || "cart"; + + // 카드 템플릿에서 사용 중인 필드 목록 수집 + const usedFields = useMemo(() => { + const fields: { name: string; label: string; source: string }[] = []; + + if (cardTemplate?.header?.codeField) { + fields.push({ name: cardTemplate.header.codeField, label: "코드 (헤더)", source: "헤더" }); + } + if (cardTemplate?.header?.titleField) { + fields.push({ name: cardTemplate.header.titleField, label: "제목 (헤더)", source: "헤더" }); + } + if (cardTemplate?.body?.fields) { + for (const f of cardTemplate.body.fields) { + if (f.valueType === "column" && f.columnName) { + fields.push({ name: f.columnName, label: f.label || f.columnName, source: "본문" }); + } + } + } + return fields; + }, [cardTemplate]); + return (
- {/* 네비게이션 모드 */} + {/* 저장 방식 */}
- +
- {/* 대상 화면 ID (screen 모드일 때만) */} - {action.navigateMode === "screen" && ( + {/* 장바구니 구분값 */} + {saveMode === "cart" && (
- + update({ targetScreenId: e.target.value })} - placeholder="예: 15" + value={action.cartType || ""} + onChange={(e) => update({ cartType: e.target.value })} + placeholder="예: purchase_inbound" className="mt-1 h-7 text-xs" />

- 담기 클릭 시 이동할 POP 화면의 screenId + 장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다.

)} - {/* 아이콘 타입 */} -
- - -
- - {/* 아이콘 값 */} -
- - update({ iconValue: e.target.value })} - placeholder={ - action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart" - } - className="mt-1 h-7 text-xs" - /> - {action.iconType === "lucide" && ( -

- PascalCase로 입력 (ShoppingCart, Package, Truck 등) -

- )} -
- {/* 담기 라벨 */}
@@ -2249,6 +2234,40 @@ function CartActionSettingsSection({ className="mt-1 h-7 text-xs" />
+ + {/* 저장 데이터 정보 (읽기 전용) */} +
+ +
+

+ 담기 시 {tableName || "(테이블 미선택)"}의 + 모든 컬럼 데이터가 JSON으로 저장됩니다. +

+ + {usedFields.length > 0 && ( +
+

카드에 표시 중인 필드:

+
+ {usedFields.map((f) => ( +
+ + {f.source} + + {f.name} + - {f.label} +
+ ))} +
+
+ )} + +

+ + 입력 수량, 포장 단위 등 추가 정보도 함께 저장됩니다. +
+ 장바구니 목록 화면에서 대상 테이블로 매핑 설정이 가능합니다. +

+
+
); } 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 aea93555..738dfa4c 100644 --- a/frontend/lib/registry/pop-components/pop-card-list/index.tsx +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -41,9 +41,7 @@ const defaultConfig: PopCardListConfig = { gridRows: 2, // 담기 버튼 기본 설정 cartAction: { - navigateMode: "none", - iconType: "lucide", - iconValue: "ShoppingCart", + saveMode: "cart", label: "담기", cancelLabel: "취소", }, @@ -63,10 +61,12 @@ PopComponentRegistry.registerComponent({ connectionMeta: { sendable: [ { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" }, - { key: "cart_item_added", label: "담기 완료", type: "event", description: "카드 담기 시 해당 행 + 수량 데이터 전달" }, + { key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" }, + { key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" }, ], receivable: [ { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" }, + { key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" }, ], }, touchOptimized: true, diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx index 3993dc48..96507984 100644 --- a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -13,7 +13,20 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react"; +import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2, Check, ChevronsUpDown } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import type { PopSearchConfig, SearchInputType, @@ -379,6 +392,7 @@ function ModalDetailSettings({ cfg, update }: StepProps) { const [columns, setColumns] = useState([]); const [tablesLoading, setTablesLoading] = useState(false); const [columnsLoading, setColumnsLoading] = useState(false); + const [openTableCombo, setOpenTableCombo] = useState(false); useEffect(() => { let cancelled = false; @@ -455,23 +469,62 @@ function ModalDetailSettings({ cfg, update }: StepProps) { 테이블 목록 로딩...
) : ( - + + + + + + + + + 테이블을 찾을 수 없습니다. + + {tables.map((t) => ( + { + updateModal({ + tableName: t.tableName, + displayColumns: [], + searchColumns: [], + displayField: "", + valueField: "", + columnLabels: undefined, + }); + setOpenTableCombo(false); + }} + className="text-xs" + > + +
+ {t.displayName || t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + {t.tableName} + )} +
+
+ ))} +
+
+
+
+
)}
diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts index a9a0a83a..d3c77233 100644 --- a/frontend/lib/registry/pop-components/types.ts +++ b/frontend/lib/registry/pop-components/types.ts @@ -491,7 +491,7 @@ export interface PackageEntry { totalQuantity: number; // 합계 = packageCount * quantityPerUnit } -// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) ----- +// ----- 담기 버튼 데이터 구조 (로컬 상태용) ----- export interface CartItem { row: Record; // 카드 원본 행 데이터 @@ -500,15 +500,39 @@ export interface CartItem { packageEntries?: PackageEntry[]; // 포장 내역 (2단계 계산 시) } +// ----- 장바구니 DB 연동용 확장 타입 ----- + +export type CartSyncStatus = "clean" | "dirty" | "saving"; +export type CartItemOrigin = "db" | "local"; +export type CartItemStatus = "in_cart" | "confirmed" | "cancelled"; + +export interface CartItemWithId extends CartItem { + cartId?: string; // DB id (UUID, 저장 후 할당) + sourceTable: string; // 원본 테이블명 + rowKey: string; // 원본 행 식별키 (codeField 값) + status: CartItemStatus; + _origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가 + memo?: string; +} + + // ----- 담기 버튼 액션 설정 (pop-icon 스타일 + 장바구니 연동) ----- + +export type CartSaveMode = "cart" | "direct"; + export interface CardCartActionConfig { - navigateMode: "none" | "screen"; // 담기 후 이동 모드 - targetScreenId?: string; // 장바구니 POP 화면 ID (screen 모드) - iconType?: "lucide" | "emoji"; // 아이콘 타입 - iconValue?: string; // Lucide 아이콘명 또는 이모지 값 - label?: string; // 담기 라벨 (기본: "담기") - cancelLabel?: string; // 취소 라벨 (기본: "취소") + saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장 + cartType?: string; // 장바구니 구분값 (예: "purchase_inbound") + label?: string; // 담기 라벨 (기본: "담기") + cancelLabel?: string; // 취소 라벨 (기본: "취소") + // 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호) + dataFields?: { sourceField: string; targetField?: string; label?: string }[]; + // 하위 호환: 기존 필드 (사용하지 않지만 기존 데이터 보호) + navigateMode?: "none" | "screen"; + targetScreenId?: string; + iconType?: "lucide" | "emoji"; + iconValue?: string; } // ----- pop-card-list 전체 설정 ----- diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index f6f1907e..22b80896 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -24,7 +24,7 @@ const nextConfig = { // 로컬 개발: http://127.0.0.1:8080 사용 async rewrites() { // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - const backendUrl = process.env.SERVER_API_URL || "http://pms-backend-mac:8080"; + const backendUrl = process.env.SERVER_API_URL || "http://localhost:8080"; return [ { source: "/api/:path*", @@ -50,7 +50,7 @@ const nextConfig = { // 환경 변수 (런타임에 읽기) env: { // Docker 컨테이너 내부에서는 컨테이너 이름으로 통신 - NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://pms-backend-mac:8080/api", + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080/api", }, };