feat(pop-cart): 장바구니 저장 시스템 구현 + 선택적 컬럼 저장
장바구니 담기 -> DB 저장 전체 플로우 구현 및 검증 완료. - useCartSync 훅 신규: DB(cart_items) <-> 로컬 상태 동기화, dirty check, 일괄 저장 - pop-button cart 프리셋: 배지 표시, 저장 트리거, 확인 모달, 3색 데이터 흐름 시각화 - pop-card-list: 담기/취소 UI, cart_save_trigger 수신 시 saveToDb 실행 - 선택적 컬럼 저장: RowDataMode(all/selected) + 연결 기반 자동 컬럼 로딩 - ComponentEditorPanel: allComponents/connections/componentId를 ConfigPanel에 전달 - connectionMeta: cart_save_trigger/cart_updated/cart_save_completed 이벤트 정의 - ConnectionEditor: 이벤트 타입 연결 구분 (데이터 vs 이벤트) - types.ts: CartItemWithId, CartSyncStatus, CartButtonConfig 등 타입 추가 - 접근성: NumberInputModal/PackageUnitModal에 DialogTitle 추가 Made-with: Cursor
This commit is contained in:
parent
7a97603106
commit
0ca031282b
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -213,6 +213,8 @@ export default function ComponentEditorPanel({
|
|||
previewPageIndex={previewPageIndex}
|
||||
onPreviewPage={onPreviewPage}
|
||||
modals={modals}
|
||||
allComponents={allComponents}
|
||||
connections={connections}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 설정 */}
|
||||
{selectedTargetInput && (
|
||||
{/* 필터 설정: event 타입 연결이면 숨김 */}
|
||||
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<boolean>;
|
||||
loadFromDb: () => Promise<void>;
|
||||
resetToSaved: () => void;
|
||||
}
|
||||
|
||||
// ===== DB 행 -> CartItemWithId 변환 =====
|
||||
|
||||
function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
|
||||
let rowData: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
}
|
||||
} 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<string, unknown> {
|
||||
// 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<CartItemWithId[]>([]);
|
||||
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||
const [syncStatus, setSyncStatus] = useState<CartSyncStatus>("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<boolean> => {
|
||||
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<unknown>[] = [];
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<ButtonPreset, string> = {
|
|||
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<ButtonPreset, Partial<PopButtonConfig>> = {
|
|||
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 (
|
||||
<div className="flex items-start gap-1 py-0.5">
|
||||
<span className={cn("min-w-0 flex-1 text-[10px]", auto ? "text-muted-foreground" : "text-foreground")}>
|
||||
{source}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">→</span>
|
||||
<div className="shrink-0 text-right">
|
||||
<code className="rounded bg-muted px-1 font-mono text-[10px] text-foreground">
|
||||
{target}
|
||||
</code>
|
||||
{desc && (
|
||||
<p className="mt-0.5 text-[9px] text-muted-foreground leading-tight">{desc}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */
|
||||
const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||
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<ReturnType<typeof setTimeout> | 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 (
|
||||
<>
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"transition-transform active:scale-95",
|
||||
isIconOnly && "px-2"
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading || cartSaving}
|
||||
className={cn(
|
||||
"transition-transform active:scale-95",
|
||||
isIconOnly && "px-2",
|
||||
cartButtonClass,
|
||||
)}
|
||||
>
|
||||
{(isCartMode ? cartIconName : iconName) && (
|
||||
<DynamicLucideIcon
|
||||
name={isCartMode ? cartIconName : iconName}
|
||||
size={16}
|
||||
className={isIconOnly ? "" : "mr-1.5"}
|
||||
/>
|
||||
)}
|
||||
{!isIconOnly && <span>{buttonLabel}</span>}
|
||||
</Button>
|
||||
|
||||
{/* 장바구니 배지 */}
|
||||
{isCartMode && cartCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2 -right-2 flex items-center justify-center rounded-full text-[10px] font-bold",
|
||||
cartIsDirty
|
||||
? "bg-orange-500 text-white"
|
||||
: "bg-emerald-600 text-white",
|
||||
)}
|
||||
style={{ minWidth: 18, height: 18, padding: "0 4px" }}
|
||||
>
|
||||
{cartCount}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{iconName && (
|
||||
<DynamicLucideIcon
|
||||
name={iconName}
|
||||
size={16}
|
||||
className={isIconOnly ? "" : "mr-1.5"}
|
||||
/>
|
||||
)}
|
||||
{!isIconOnly && <span>{buttonLabel}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */}
|
||||
{/* 장바구니 확인 다이얼로그 */}
|
||||
<AlertDialog open={showCartConfirm} onOpenChange={setShowCartConfirm}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
장바구니 저장
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
{config?.confirm?.message || `${cartCount}개 항목을 장바구니에 저장하시겠습니까?`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowCartConfirm(false);
|
||||
handleCartSave();
|
||||
}}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일반 확인 다이얼로그 */}
|
||||
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
|
|
@ -420,14 +649,117 @@ export function PopButtonComponent({
|
|||
interface PopButtonConfigPanelProps {
|
||||
config: PopButtonConfig;
|
||||
onUpdate: (config: PopButtonConfig) => void;
|
||||
allComponents?: { id: string; type: string; config?: Record<string, unknown> }[];
|
||||
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<string | null>(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<string, unknown> | undefined;
|
||||
const dataSource = cfg?.dataSource as Record<string, unknown> | 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({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 액션 */}
|
||||
<SectionDivider label="메인 액션" />
|
||||
<div className="space-y-2">
|
||||
{/* 액션 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">액션 유형</Label>
|
||||
<Select
|
||||
value={config?.action?.type || "save"}
|
||||
onValueChange={(v) =>
|
||||
updateAction({ type: v as ButtonActionType })
|
||||
}
|
||||
disabled={!isCustom}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isCustom && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* 장바구니 설정 (cart 프리셋 전용) */}
|
||||
{config?.preset === "cart" && (
|
||||
<>
|
||||
<SectionDivider label="장바구니 설정" />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">장바구니 화면 ID</Label>
|
||||
<Input
|
||||
value={config?.cart?.cartScreenId || ""}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
...config,
|
||||
cart: { ...config.cart, cartScreenId: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="저장 후 이동할 POP 화면 ID"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
저장 완료 후 이동할 장바구니 리스트 화면 ID입니다.
|
||||
비어있으면 이동 없이 저장만 합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션별 추가 설정 */}
|
||||
<ActionDetailFields
|
||||
action={config?.action}
|
||||
onUpdate={updateAction}
|
||||
disabled={!isCustom}
|
||||
/>
|
||||
</div>
|
||||
{/* 데이터 저장 흐름 시각화 */}
|
||||
<SectionDivider label="데이터 저장 흐름" />
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
카드 목록에서 "담기" 클릭 시 아래와 같이 <code className="rounded bg-muted px-1 font-mono text-foreground">cart_items</code> 테이블에 저장됩니다.
|
||||
</p>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{/* 사용자 입력 데이터 */}
|
||||
<div className="rounded-md border bg-amber-50/50 px-2.5 py-1.5 dark:bg-amber-950/20">
|
||||
<p className="mb-1 text-[10px] font-medium text-amber-700 dark:text-amber-400">사용자 입력</p>
|
||||
<CartMappingRow source="입력한 수량" target="quantity" />
|
||||
<CartMappingRow source="포장 단위" target="package_unit" />
|
||||
<CartMappingRow source="포장 내역 (JSON)" target="package_entries" />
|
||||
<CartMappingRow source="메모" target="memo" />
|
||||
</div>
|
||||
|
||||
{/* 원본 데이터 */}
|
||||
<div className="rounded-md border bg-blue-50/50 px-2.5 py-1.5 dark:bg-blue-950/20">
|
||||
<p className="mb-1 text-[10px] font-medium text-blue-700 dark:text-blue-400">원본 행 데이터</p>
|
||||
|
||||
{/* 저장 모드 선택 */}
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground">저장 모드:</span>
|
||||
<Select
|
||||
value={config?.cart?.rowDataMode || "all"}
|
||||
onValueChange={(v) =>
|
||||
onUpdate({
|
||||
...config,
|
||||
cart: { ...config.cart, rowDataMode: v as RowDataMode },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[100px] text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">전체 저장</SelectItem>
|
||||
<SelectItem value="selected" className="text-xs">선택 저장</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config?.cart?.rowDataMode === "selected" ? (
|
||||
<>
|
||||
{/* 선택 저장 모드: 컬럼 목록 관리 */}
|
||||
<div className="space-y-1.5">
|
||||
{connectedTableName ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
연결: <code className="rounded bg-muted px-1 font-mono text-foreground">{connectedTableName}</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[9px] text-amber-600 dark:text-amber-400">
|
||||
카드 목록과 연결(cart_save_trigger)하면 컬럼이 자동으로 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{colLoading && (
|
||||
<p className="text-[9px] text-muted-foreground">컬럼 불러오는 중...</p>
|
||||
)}
|
||||
|
||||
{/* 불러온 컬럼 체크박스 */}
|
||||
{loadedColumns.length > 0 && (
|
||||
<div className="max-h-[160px] space-y-0.5 overflow-y-auto rounded border bg-background p-1.5">
|
||||
{loadedColumns.map((col) => {
|
||||
const isChecked = (config?.cart?.selectedColumns || []).includes(col.name);
|
||||
return (
|
||||
<label key={col.name} className="flex cursor-pointer items-center gap-1.5 rounded px-1 py-0.5 hover:bg-muted/50">
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => {
|
||||
const prev = config?.cart?.selectedColumns || [];
|
||||
const next = checked
|
||||
? [...prev, col.name]
|
||||
: prev.filter((c) => c !== col.name);
|
||||
onUpdate({
|
||||
...config,
|
||||
cart: { ...config.cart, selectedColumns: next },
|
||||
});
|
||||
}}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
<span className="text-[10px]">{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="text-[9px] text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선택된 컬럼 요약 */}
|
||||
{(config?.cart?.selectedColumns?.length ?? 0) > 0 ? (
|
||||
<CartMappingRow
|
||||
source={`선택된 ${config!.cart!.selectedColumns!.length}개 컬럼 (JSON)`}
|
||||
target="row_data"
|
||||
desc={config!.cart!.selectedColumns!.join(", ")}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-[9px] text-amber-600 dark:text-amber-400">
|
||||
저장할 컬럼을 선택하세요. 미선택 시 전체 저장됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<CartMappingRow source="행 전체 (JSON)" target="row_data" desc="원본 테이블의 모든 컬럼이 JSON으로 저장" />
|
||||
)}
|
||||
|
||||
<CartMappingRow source="행 식별키 (PK)" target="row_key" />
|
||||
<CartMappingRow source="원본 테이블명" target="source_table" />
|
||||
</div>
|
||||
|
||||
{/* 시스템 자동 */}
|
||||
<div className="rounded-md border bg-muted/30 px-2.5 py-1.5">
|
||||
<p className="mb-1 text-[10px] font-medium text-muted-foreground">자동 설정</p>
|
||||
<CartMappingRow source="현재 화면 ID" target="screen_id" auto />
|
||||
<CartMappingRow source='장바구니 타입 ("pop")' target="cart_type" auto />
|
||||
<CartMappingRow source='상태 ("in_cart")' target="status" auto />
|
||||
<CartMappingRow source="회사 코드" target="company_code" auto />
|
||||
<CartMappingRow source="사용자 ID" target="user_id" auto />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-[10px] leading-relaxed">
|
||||
장바구니 목록 화면에서 <code className="rounded bg-muted px-1 font-mono text-foreground">row_data</code>의 JSON을 풀어서
|
||||
최종 대상 테이블로 매핑할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 메인 액션 (cart 프리셋에서는 숨김) */}
|
||||
{config?.preset !== "cart" && (
|
||||
<>
|
||||
<SectionDivider label="메인 액션" />
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">액션 유형</Label>
|
||||
<Select
|
||||
value={config?.action?.type || "save"}
|
||||
onValueChange={(v) =>
|
||||
updateAction({ type: v as ButtonActionType })
|
||||
}
|
||||
disabled={!isCustom}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ACTION_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key} className="text-xs">
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isCustom && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ActionDetailFields
|
||||
action={config?.action}
|
||||
onUpdate={updateAction}
|
||||
disabled={!isCustom}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 확인 다이얼로그 */}
|
||||
<SectionDivider label="확인 메시지" />
|
||||
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<DialogPrimitive.Content
|
||||
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] w-full max-w-[95vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden border shadow-lg duration-200 sm:max-w-[360px] sm:rounded-lg"
|
||||
>
|
||||
<VisuallyHidden><DialogTitle>수량 입력</DialogTitle></VisuallyHidden>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between bg-blue-500 px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<DialogPrimitive.Content
|
||||
className="bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-1100 w-full max-w-[90vw] translate-x-[-50%] translate-y-[-50%] overflow-hidden rounded-lg border shadow-lg duration-200 sm:max-w-[380px]"
|
||||
>
|
||||
<VisuallyHidden><DialogTitle>포장 단위 선택</DialogTitle></VisuallyHidden>
|
||||
<div className="border-b px-4 py-3 pr-12">
|
||||
<h2 className="text-base font-semibold">📦 포장 단위 선택</h2>
|
||||
<h2 className="text-base font-semibold">포장 단위 선택</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 p-4">
|
||||
|
|
|
|||
|
|
@ -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<string, LucideIcon> = {
|
||||
|
|
@ -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<RowData[]>([]);
|
||||
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<string, unknown> = {};
|
||||
|
|
@ -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 (
|
||||
<Card
|
||||
key={rowKey}
|
||||
|
|
@ -571,10 +594,11 @@ export function PopCardListComponent({
|
|||
packageConfig={config?.packageConfig}
|
||||
cartAction={config?.cartAction}
|
||||
publish={publish}
|
||||
getSharedData={getSharedData}
|
||||
setSharedData={setSharedData}
|
||||
router={router}
|
||||
onSelect={handleCardSelect}
|
||||
cart={cart}
|
||||
codeFieldName={template?.header?.codeField}
|
||||
parentComponentId={componentId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -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: <T = unknown>(key: string) => T | undefined;
|
||||
setSharedData: (key: string, value: unknown) => void;
|
||||
router: ReturnType<typeof useRouter>;
|
||||
onSelect?: (row: RowData) => void;
|
||||
cart: ReturnType<typeof useCartSync>;
|
||||
codeFieldName?: string;
|
||||
parentComponentId?: string;
|
||||
}) {
|
||||
const header = template?.header;
|
||||
const image = template?.image;
|
||||
|
|
@ -677,11 +703,24 @@ function Card({
|
|||
const [packageUnit, setPackageUnit] = useState<string | undefined>(undefined);
|
||||
const [packageEntries, setPackageEntries] = useState<PackageEntry[]>([]);
|
||||
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<CartItem[]>("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<CartItem[]>("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 (
|
||||
<div
|
||||
className="cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:border-2 hover:border-blue-500 hover:shadow-md"
|
||||
className={`cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${
|
||||
isCarted
|
||||
? "border-emerald-500 border-2 hover:border-emerald-600"
|
||||
: "hover:border-2 hover:border-blue-500"
|
||||
}`}
|
||||
style={cardStyle}
|
||||
onClick={handleCardClick}
|
||||
role="button"
|
||||
|
|
@ -789,7 +830,7 @@ function Card({
|
|||
>
|
||||
{/* 헤더 영역 */}
|
||||
{(codeValue !== null || titleValue !== null) && (
|
||||
<div className="border-b bg-muted/30" style={headerStyle}>
|
||||
<div className={`border-b ${isCarted ? "bg-emerald-50 dark:bg-emerald-950/30" : "bg-muted/30"}`} style={headerStyle}>
|
||||
<div className="flex items-center gap-2">
|
||||
{codeValue !== null && (
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -622,10 +622,12 @@ function CardTemplateTab({
|
|||
</CollapsibleSection>
|
||||
|
||||
{/* 담기 버튼 설정 */}
|
||||
<CollapsibleSection title="담기 버튼 (pop-icon)" defaultOpen={false}>
|
||||
<CollapsibleSection title="담기 버튼" defaultOpen={false}>
|
||||
<CartActionSettingsSection
|
||||
cartAction={config.cartAction}
|
||||
onUpdate={(cartAction) => onUpdate({ cartAction })}
|
||||
cardTemplate={template}
|
||||
tableName={dataSource.tableName}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-3">
|
||||
{/* 네비게이션 모드 */}
|
||||
{/* 저장 방식 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">담기 후 이동</Label>
|
||||
<Label className="text-[10px]">저장 방식</Label>
|
||||
<Select
|
||||
value={action.navigateMode}
|
||||
onValueChange={(v) =>
|
||||
update({ navigateMode: v as "none" | "screen" })
|
||||
}
|
||||
value={saveMode}
|
||||
onValueChange={(v) => update({ saveMode: v as "cart" | "direct" })}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음 (토스트만)</SelectItem>
|
||||
<SelectItem value="screen">POP 화면 이동</SelectItem>
|
||||
<SelectItem value="cart" className="text-xs">장바구니에 담기</SelectItem>
|
||||
<SelectItem value="direct" className="text-xs">바로 저장 (향후 지원)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 대상 화면 ID (screen 모드일 때만) */}
|
||||
{action.navigateMode === "screen" && (
|
||||
{/* 장바구니 구분값 */}
|
||||
{saveMode === "cart" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">장바구니 화면 ID</Label>
|
||||
<Label className="text-[10px]">장바구니 구분값</Label>
|
||||
<Input
|
||||
value={action.targetScreenId || ""}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
담기 클릭 시 이동할 POP 화면의 screenId
|
||||
장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 아이콘 타입 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">아이콘 타입</Label>
|
||||
<Select
|
||||
value={action.iconType || "lucide"}
|
||||
onValueChange={(v) =>
|
||||
update({ iconType: v as "lucide" | "emoji" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lucide">Lucide 아이콘</SelectItem>
|
||||
<SelectItem value="emoji">이모지</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 값 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">
|
||||
{action.iconType === "emoji" ? "이모지" : "Lucide 아이콘명"}
|
||||
</Label>
|
||||
<Input
|
||||
value={action.iconValue || ""}
|
||||
onChange={(e) => update({ iconValue: e.target.value })}
|
||||
placeholder={
|
||||
action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart"
|
||||
}
|
||||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
{action.iconType === "lucide" && (
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
PascalCase로 입력 (ShoppingCart, Package, Truck 등)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 담기 라벨 */}
|
||||
<div>
|
||||
<Label className="text-[10px]">담기 라벨</Label>
|
||||
|
|
@ -2249,6 +2234,40 @@ function CartActionSettingsSection({
|
|||
className="mt-1 h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 저장 데이터 정보 (읽기 전용) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px]">저장 데이터 정보</Label>
|
||||
<div className="rounded-md border bg-muted/30 p-2">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
담기 시 <span className="font-medium text-foreground">{tableName || "(테이블 미선택)"}</span>의
|
||||
모든 컬럼 데이터가 JSON으로 저장됩니다.
|
||||
</p>
|
||||
|
||||
{usedFields.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<p className="text-muted-foreground mb-1 text-[10px]">카드에 표시 중인 필드:</p>
|
||||
<div className="space-y-0.5">
|
||||
{usedFields.map((f) => (
|
||||
<div key={`${f.source}-${f.name}`} className="flex items-center gap-1.5 text-[10px]">
|
||||
<span className="inline-block w-8 rounded bg-muted px-1 text-center text-[9px] text-muted-foreground">
|
||||
{f.source}
|
||||
</span>
|
||||
<code className="font-mono text-foreground">{f.name}</code>
|
||||
<span className="text-muted-foreground">- {f.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-[10px]">
|
||||
+ 입력 수량, 포장 단위 등 추가 정보도 함께 저장됩니다.
|
||||
<br />
|
||||
장바구니 목록 화면에서 대상 테이블로 매핑 설정이 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ColumnTypeInfo[]>([]);
|
||||
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) {
|
|||
테이블 목록 로딩...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={mc.tableName || undefined}
|
||||
onValueChange={(v) =>
|
||||
updateModal({ tableName: v, displayColumns: [], searchColumns: [], displayField: "", valueField: "", columnLabels: undefined })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.tableName} value={t.tableName} className="text-xs">
|
||||
{t.displayName || t.tableName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openTableCombo}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{mc.tableName
|
||||
? tables.find((t) => t.tableName === mc.tableName)?.displayName || mc.tableName
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="한글명 또는 영문 테이블명 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-3 text-center text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((t) => (
|
||||
<CommandItem
|
||||
key={t.tableName}
|
||||
value={`${t.displayName || ""} ${t.tableName}`}
|
||||
onSelect={() => {
|
||||
updateModal({
|
||||
tableName: t.tableName,
|
||||
displayColumns: [],
|
||||
searchColumns: [],
|
||||
displayField: "",
|
||||
valueField: "",
|
||||
columnLabels: undefined,
|
||||
});
|
||||
setOpenTableCombo(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mc.tableName === t.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{t.displayName || t.tableName}</span>
|
||||
{t.displayName && t.displayName !== t.tableName && (
|
||||
<span className="text-[9px] text-muted-foreground">{t.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -491,7 +491,7 @@ export interface PackageEntry {
|
|||
totalQuantity: number; // 합계 = packageCount * quantityPerUnit
|
||||
}
|
||||
|
||||
// ----- 담기 버튼 데이터 구조 (추후 API 연동 시 이 shape 그대로 사용) -----
|
||||
// ----- 담기 버튼 데이터 구조 (로컬 상태용) -----
|
||||
|
||||
export interface CartItem {
|
||||
row: Record<string, unknown>; // 카드 원본 행 데이터
|
||||
|
|
@ -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 전체 설정 -----
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue