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 (
<>
-
+
- {/* 확인 다이얼로그 (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) {
테이블 목록 로딩...
) : (
-
+
+
+
+ {mc.tableName
+ ? tables.find((t) => t.tableName === mc.tableName)?.displayName || mc.tableName
+ : "테이블 선택"}
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다.
+
+ {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",
},
};