feat(pop): 컴포넌트 연결 단순화 + 상태 변경 규칙 UI 개선 + 조회 키 설정
컴포넌트 연결 단순화 - ConnectionEditor: 이벤트 연결 시 "어디로" Select 1개로 단순화 - useConnectionResolver: 호환 이벤트 자동 라우팅 (_auto 모드) - connectionMeta에 category(event/filter/data) 필드 추가 상태 변경 규칙 UI 개선 - StatusChangeRule 타입 통합, 모든 버튼 프리셋에서 사용 가능 - TableCombobox/ColumnCombobox 공용 컴포넌트 추출 (pop-shared/) - 테이블/컬럼 드롭다운, 고정값/조건부 값 설정 UI - 입고확정 API 신규 (popActionRoutes.ts, 동적 상태 변경 처리) 조회 키 자동/수동 설정 - 대상 테이블 기반 자동 판단 (cart_items -> id, 그 외 -> row_key -> PK) - 수동 모드: 카드 항목 필드와 대상 PK 컬럼을 직접 지정 가능 - PK 컬럼명 동적 표시 (isPrimaryKey 정보 활용)
This commit is contained in:
parent
220e05d2ae
commit
e3ae8d273c
|
|
@ -1,9 +1,5 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"agent-orchestrator": {
|
||||
"command": "node",
|
||||
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
||||
},
|
||||
"Framelink Figma MCP": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙
|
|||
import entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
|
|
@ -238,6 +239,7 @@ app.use("/api/table-management", tableManagementRoutes);
|
|||
app.use("/api/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||
app.use("/api/screen-management", screenManagementRoutes);
|
||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||
app.use("/api/common-codes", commonCodeRoutes);
|
||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||
app.use("/api/files", fileRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,280 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// SQL 인젝션 방지: 테이블명/컬럼명 패턴 검증
|
||||
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
function isSafeIdentifier(name: string): boolean {
|
||||
return SAFE_IDENTIFIER.test(name);
|
||||
}
|
||||
|
||||
interface MappingInfo {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
interface StatusConditionRule {
|
||||
whenColumn: string;
|
||||
operator: string;
|
||||
whenValue: string;
|
||||
thenValue: string;
|
||||
}
|
||||
|
||||
interface ConditionalValueRule {
|
||||
conditions: StatusConditionRule[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
interface StatusChangeRuleBody {
|
||||
targetTable: string;
|
||||
targetColumn: string;
|
||||
lookupMode?: "auto" | "manual";
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
valueType: "fixed" | "conditional";
|
||||
fixedValue?: string;
|
||||
conditionalValue?: ConditionalValueRule;
|
||||
// 하위호환: 기존 형식
|
||||
value?: string;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
interface ExecuteActionBody {
|
||||
action: string;
|
||||
data: {
|
||||
items?: Record<string, unknown>[];
|
||||
fieldValues?: Record<string, unknown>;
|
||||
};
|
||||
mappings?: {
|
||||
cardList?: MappingInfo | null;
|
||||
field?: MappingInfo | null;
|
||||
};
|
||||
statusChanges?: StatusChangeRuleBody[];
|
||||
}
|
||||
|
||||
function resolveStatusValue(
|
||||
valueType: string,
|
||||
fixedValue: string,
|
||||
conditionalValue: ConditionalValueRule | undefined,
|
||||
item: Record<string, unknown>
|
||||
): string {
|
||||
if (valueType !== "conditional" || !conditionalValue) return fixedValue;
|
||||
|
||||
for (const cond of conditionalValue.conditions) {
|
||||
const actual = String(item[cond.whenColumn] ?? "");
|
||||
const expected = cond.whenValue;
|
||||
let match = false;
|
||||
|
||||
switch (cond.operator) {
|
||||
case "=": match = actual === expected; break;
|
||||
case "!=": match = actual !== expected; break;
|
||||
case ">": match = parseFloat(actual) > parseFloat(expected); break;
|
||||
case "<": match = parseFloat(actual) < parseFloat(expected); break;
|
||||
case ">=": match = parseFloat(actual) >= parseFloat(expected); break;
|
||||
case "<=": match = parseFloat(actual) <= parseFloat(expected); break;
|
||||
default: match = actual === expected;
|
||||
}
|
||||
|
||||
if (match) return cond.thenValue;
|
||||
}
|
||||
|
||||
return conditionalValue.defaultValue ?? fixedValue;
|
||||
}
|
||||
|
||||
router.post("/execute-action", authenticateToken, async (req: Request, res: Response) => {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||
}
|
||||
|
||||
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
|
||||
const items = data?.items ?? [];
|
||||
const fieldValues = data?.fieldValues ?? {};
|
||||
|
||||
logger.info("[pop/execute-action] 요청", {
|
||||
action,
|
||||
companyCode,
|
||||
userId,
|
||||
itemCount: items.length,
|
||||
hasFieldValues: Object.keys(fieldValues).length > 0,
|
||||
hasMappings: !!mappings,
|
||||
statusChangeCount: statusChanges?.length ?? 0,
|
||||
});
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
let processedCount = 0;
|
||||
let insertedCount = 0;
|
||||
|
||||
if (action === "inbound-confirm") {
|
||||
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
||||
const cardMapping = mappings?.cardList;
|
||||
const fieldMapping = mappings?.field;
|
||||
|
||||
if (cardMapping?.targetTable && Object.keys(cardMapping.columnMapping).length > 0) {
|
||||
if (!isSafeIdentifier(cardMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${cardMapping.targetTable}`);
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
||||
for (const [sourceField, targetColumn] of Object.entries(cardMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(item[sourceField] ?? null);
|
||||
}
|
||||
|
||||
if (fieldMapping?.targetTable === cardMapping.targetTable) {
|
||||
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
if (columns.includes(`"${targetColumn}"`)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(fieldValues[sourceField] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
|
||||
logger.info("[pop/execute-action] INSERT 실행", {
|
||||
table: cardMapping.targetTable,
|
||||
columnCount: columns.length,
|
||||
});
|
||||
|
||||
await client.query(sql, values);
|
||||
insertedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fieldMapping?.targetTable &&
|
||||
Object.keys(fieldMapping.columnMapping).length > 0 &&
|
||||
fieldMapping.targetTable !== cardMapping?.targetTable
|
||||
) {
|
||||
if (!isSafeIdentifier(fieldMapping.targetTable)) {
|
||||
throw new Error(`유효하지 않은 테이블명: ${fieldMapping.targetTable}`);
|
||||
}
|
||||
|
||||
const columns: string[] = ["company_code"];
|
||||
const values: unknown[] = [companyCode];
|
||||
|
||||
for (const [sourceField, targetColumn] of Object.entries(fieldMapping.columnMapping)) {
|
||||
if (!isSafeIdentifier(targetColumn)) continue;
|
||||
columns.push(`"${targetColumn}"`);
|
||||
values.push(fieldValues[sourceField] ?? null);
|
||||
}
|
||||
|
||||
if (columns.length > 1) {
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const sql = `INSERT INTO "${fieldMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||
await client.query(sql, values);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 상태 변경 규칙 실행 (설정 기반)
|
||||
if (statusChanges && statusChanges.length > 0) {
|
||||
for (const rule of statusChanges) {
|
||||
if (!rule.targetTable || !rule.targetColumn) continue;
|
||||
if (!isSafeIdentifier(rule.targetTable) || !isSafeIdentifier(rule.targetColumn)) {
|
||||
logger.warn("[pop/execute-action] 유효하지 않은 식별자, 건너뜀", { table: rule.targetTable, column: rule.targetColumn });
|
||||
continue;
|
||||
}
|
||||
|
||||
const valueType = rule.valueType ?? "fixed";
|
||||
const fixedValue = rule.fixedValue ?? rule.value ?? "";
|
||||
const lookupMode = rule.lookupMode ?? "auto";
|
||||
|
||||
// 조회 키 결정: 아이템 필드(itemField) -> 대상 테이블 PK 컬럼(pkColumn)
|
||||
let itemField: string;
|
||||
let pkColumn: string;
|
||||
|
||||
if (lookupMode === "manual" && rule.manualItemField && rule.manualPkColumn) {
|
||||
if (!isSafeIdentifier(rule.manualPkColumn)) {
|
||||
logger.warn("[pop/execute-action] 수동 PK 컬럼 유효하지 않음", { manualPkColumn: rule.manualPkColumn });
|
||||
continue;
|
||||
}
|
||||
itemField = rule.manualItemField;
|
||||
pkColumn = rule.manualPkColumn;
|
||||
logger.info("[pop/execute-action] 수동 조회 키", { itemField, pkColumn, table: rule.targetTable });
|
||||
} else if (rule.targetTable === "cart_items") {
|
||||
itemField = "__cart_id";
|
||||
pkColumn = "id";
|
||||
} else {
|
||||
itemField = "__cart_row_key";
|
||||
const pkResult = await client.query(
|
||||
`SELECT a.attname FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = $1::regclass AND i.indisprimary`,
|
||||
[rule.targetTable]
|
||||
);
|
||||
pkColumn = pkResult.rows[0]?.attname || "id";
|
||||
}
|
||||
|
||||
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
|
||||
if (lookupValues.length === 0) {
|
||||
logger.warn("[pop/execute-action] 조회 키 값 없음, 건너뜀", { table: rule.targetTable, itemField });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (valueType === "fixed") {
|
||||
const placeholders = lookupValues.map((_, i) => `$${i + 3}`).join(", ");
|
||||
const sql = `UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" IN (${placeholders})`;
|
||||
await client.query(sql, [fixedValue, companyCode, ...lookupValues]);
|
||||
processedCount += lookupValues.length;
|
||||
} else {
|
||||
for (let i = 0; i < lookupValues.length; i++) {
|
||||
const item = items[i] ?? {};
|
||||
const resolvedValue = resolveStatusValue(valueType, fixedValue, rule.conditionalValue, item);
|
||||
await client.query(
|
||||
`UPDATE "${rule.targetTable}" SET "${rule.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||
[resolvedValue, companyCode, lookupValues[i]]
|
||||
);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("[pop/execute-action] 상태 변경 실행", {
|
||||
table: rule.targetTable, column: rule.targetColumn, lookupMode, itemField, pkColumn, count: lookupValues.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("[pop/execute-action] 완료", {
|
||||
action,
|
||||
companyCode,
|
||||
processedCount,
|
||||
insertedCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
|
||||
data: { processedCount, insertedCount },
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("[pop/execute-action] 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "처리 중 오류가 발생했습니다.",
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -36,6 +36,15 @@ interface ConnectionEditorProps {
|
|||
onRemoveConnection?: (connectionId: string) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 소스 컴포넌트에 filter 타입 sendable이 있는지 판단
|
||||
// ========================================
|
||||
|
||||
function hasFilterSendable(meta: ComponentConnectionMeta | undefined): boolean {
|
||||
if (!meta?.sendable) return false;
|
||||
return meta.sendable.some((s) => s.category === "filter" || s.type === "filter_value");
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ConnectionEditor
|
||||
// ========================================
|
||||
|
|
@ -75,6 +84,8 @@ export default function ConnectionEditor({
|
|||
);
|
||||
}
|
||||
|
||||
const isFilterSource = hasFilterSendable(meta);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{hasSendable && (
|
||||
|
|
@ -83,6 +94,7 @@ export default function ConnectionEditor({
|
|||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
outgoing={outgoing}
|
||||
isFilterSource={isFilterSource}
|
||||
onAddConnection={onAddConnection}
|
||||
onUpdateConnection={onUpdateConnection}
|
||||
onRemoveConnection={onRemoveConnection}
|
||||
|
|
@ -92,7 +104,6 @@ export default function ConnectionEditor({
|
|||
{hasReceivable && (
|
||||
<ReceiveSection
|
||||
component={component}
|
||||
meta={meta!}
|
||||
allComponents={allComponents}
|
||||
incoming={incoming}
|
||||
/>
|
||||
|
|
@ -105,7 +116,6 @@ export default function ConnectionEditor({
|
|||
// 대상 컴포넌트에서 정보 추출
|
||||
// ========================================
|
||||
|
||||
/** 화면에 표시 중인 컬럼만 추출 */
|
||||
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||
if (!comp?.config) return [];
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
|
|
@ -126,7 +136,6 @@ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): stri
|
|||
return cols;
|
||||
}
|
||||
|
||||
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
|
||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||
if (!comp?.config) return "";
|
||||
const cfg = comp.config as Record<string, unknown>;
|
||||
|
|
@ -143,6 +152,7 @@ interface SendSectionProps {
|
|||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
outgoing: PopDataConnection[];
|
||||
isFilterSource: boolean;
|
||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||
onRemoveConnection?: (connectionId: string) => void;
|
||||
|
|
@ -153,6 +163,7 @@ function SendSection({
|
|||
meta,
|
||||
allComponents,
|
||||
outgoing,
|
||||
isFilterSource,
|
||||
onAddConnection,
|
||||
onUpdateConnection,
|
||||
onRemoveConnection,
|
||||
|
|
@ -163,29 +174,42 @@ function SendSection({
|
|||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<ArrowRight className="h-3 w-3 text-blue-500" />
|
||||
이때 (보내기)
|
||||
보내기
|
||||
</Label>
|
||||
|
||||
{/* 기존 연결 목록 */}
|
||||
{outgoing.map((conn) => (
|
||||
<div key={conn.id}>
|
||||
{editingId === conn.id ? (
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
initial={conn}
|
||||
onSubmit={(data) => {
|
||||
onUpdateConnection?.(conn.id, data);
|
||||
setEditingId(null);
|
||||
}}
|
||||
onCancel={() => setEditingId(null)}
|
||||
submitLabel="수정"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||
<span className="flex-1 truncate text-xs">
|
||||
{conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`}
|
||||
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditingId(conn.id)}
|
||||
|
|
@ -206,23 +230,131 @@ function SendSection({
|
|||
</div>
|
||||
))}
|
||||
|
||||
{/* 새 연결 추가 */}
|
||||
<ConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
{isFilterSource ? (
|
||||
<FilterConnectionForm
|
||||
component={component}
|
||||
meta={meta}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
) : (
|
||||
<SimpleConnectionForm
|
||||
component={component}
|
||||
allComponents={allComponents}
|
||||
onSubmit={(data) => onAddConnection?.(data)}
|
||||
submitLabel="연결 추가"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 연결 폼 (추가/수정 공용)
|
||||
// 단순 연결 폼 (이벤트 타입: "어디로" 1개만)
|
||||
// ========================================
|
||||
|
||||
interface ConnectionFormProps {
|
||||
interface SimpleConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
initial?: PopDataConnection;
|
||||
onSubmit: (data: Omit<PopDataConnection, "id">) => void;
|
||||
onCancel?: () => void;
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
function SimpleConnectionForm({
|
||||
component,
|
||||
allComponents,
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: SimpleConnectionFormProps) {
|
||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||
initial?.targetComponent || ""
|
||||
);
|
||||
|
||||
const targetCandidates = allComponents.filter((c) => {
|
||||
if (c.id === component.id) return false;
|
||||
const reg = PopComponentRegistry.getComponent(c.type);
|
||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedTargetId) return;
|
||||
|
||||
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||
const srcLabel = component.label || component.id;
|
||||
const tgtLabel = targetComp?.label || targetComp?.id || "?";
|
||||
|
||||
onSubmit({
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
sourceOutput: "_auto",
|
||||
targetComponent: selectedTargetId,
|
||||
targetField: "",
|
||||
targetInput: "_auto",
|
||||
label: `${srcLabel} → ${tgtLabel}`,
|
||||
});
|
||||
|
||||
if (!initial) {
|
||||
setSelectedTargetId("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-dashed p-3">
|
||||
{onCancel && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">연결 수정</p>
|
||||
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!onCancel && (
|
||||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
||||
<Select
|
||||
value={selectedTargetId}
|
||||
onValueChange={setSelectedTargetId}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetCandidates.map((c) => (
|
||||
<SelectItem key={c.id} value={c.id} className="text-xs">
|
||||
{c.label || c.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedTargetId}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{!initial && <Plus className="mr-1 h-3 w-3" />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 필터 연결 폼 (검색 컴포넌트용: 기존 UI 유지)
|
||||
// ========================================
|
||||
|
||||
interface FilterConnectionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
|
|
@ -232,7 +364,7 @@ interface ConnectionFormProps {
|
|||
submitLabel: string;
|
||||
}
|
||||
|
||||
function ConnectionForm({
|
||||
function FilterConnectionForm({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
|
|
@ -240,7 +372,7 @@ function ConnectionForm({
|
|||
onSubmit,
|
||||
onCancel,
|
||||
submitLabel,
|
||||
}: ConnectionFormProps) {
|
||||
}: FilterConnectionFormProps) {
|
||||
const [selectedOutput, setSelectedOutput] = React.useState(
|
||||
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
||||
);
|
||||
|
|
@ -272,32 +404,26 @@ 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),
|
||||
[targetComp]
|
||||
);
|
||||
|
||||
// DB 테이블 전체 컬럼 (비동기 조회)
|
||||
const tableName = React.useMemo(
|
||||
() => extractTableName(targetComp || undefined),
|
||||
[targetComp]
|
||||
|
|
@ -324,7 +450,6 @@ function ConnectionForm({
|
|||
return () => { cancelled = true; };
|
||||
}, [tableName]);
|
||||
|
||||
// 표시 컬럼과 데이터 전용 컬럼 분리
|
||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||
const dataOnlyColumns = React.useMemo(
|
||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||
|
|
@ -388,7 +513,6 @@ function ConnectionForm({
|
|||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||
)}
|
||||
|
||||
{/* 보내는 값 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
||||
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
||||
|
|
@ -405,7 +529,6 @@ function ConnectionForm({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 컴포넌트 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||
<Select
|
||||
|
|
@ -429,7 +552,6 @@ function ConnectionForm({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 받는 방식 */}
|
||||
{targetMeta && (
|
||||
<div className="space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||
|
|
@ -448,7 +570,6 @@ function ConnectionForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 설정: 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>
|
||||
|
|
@ -460,7 +581,6 @@ function ConnectionForm({
|
|||
</div>
|
||||
) : hasAnyColumns ? (
|
||||
<div className="space-y-2">
|
||||
{/* 표시 컬럼 그룹 */}
|
||||
{displayColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||
|
|
@ -482,7 +602,6 @@ function ConnectionForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전용 컬럼 그룹 */}
|
||||
{dataOnlyColumns.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{displayColumns.length > 0 && (
|
||||
|
|
@ -522,7 +641,6 @@ function ConnectionForm({
|
|||
</p>
|
||||
)}
|
||||
|
||||
{/* 필터 방식 */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||
|
|
@ -540,7 +658,6 @@ function ConnectionForm({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 제출 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -556,19 +673,17 @@ function ConnectionForm({
|
|||
}
|
||||
|
||||
// ========================================
|
||||
// 받기 섹션 (읽기 전용)
|
||||
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
|
||||
// ========================================
|
||||
|
||||
interface ReceiveSectionProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
meta: ComponentConnectionMeta;
|
||||
allComponents: PopComponentDefinitionV5[];
|
||||
incoming: PopDataConnection[];
|
||||
}
|
||||
|
||||
function ReceiveSection({
|
||||
component,
|
||||
meta,
|
||||
allComponents,
|
||||
incoming,
|
||||
}: ReceiveSectionProps) {
|
||||
|
|
@ -576,28 +691,11 @@ function ReceiveSection({
|
|||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||
<Unlink2 className="h-3 w-3 text-green-500" />
|
||||
이렇게 (받기)
|
||||
받기
|
||||
</Label>
|
||||
|
||||
<div className="space-y-1">
|
||||
{meta.receivable.map((r) => (
|
||||
<div
|
||||
key={r.key}
|
||||
className="rounded bg-green-50/50 px-3 py-2 text-xs text-gray-600"
|
||||
>
|
||||
<span className="font-medium">{r.label}</span>
|
||||
{r.description && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{incoming.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-muted-foreground">연결된 소스</p>
|
||||
<div className="space-y-1">
|
||||
{incoming.map((conn) => {
|
||||
const sourceComp = allComponents.find(
|
||||
(c) => c.id === conn.sourceComponent
|
||||
|
|
@ -605,9 +703,9 @@ function ReceiveSection({
|
|||
return (
|
||||
<div
|
||||
key={conn.id}
|
||||
className="flex items-center gap-2 rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
className="flex items-center gap-2 rounded border bg-green-50/50 px-3 py-2 text-xs"
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 text-muted-foreground" />
|
||||
<ArrowRight className="h-3 w-3 text-green-500" />
|
||||
<span className="truncate">
|
||||
{sourceComp?.label || conn.sourceComponent}
|
||||
</span>
|
||||
|
|
@ -617,7 +715,7 @@ function ReceiveSection({
|
|||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
|
||||
연결된 소스가 없습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -651,5 +749,5 @@ function buildConnectionLabel(
|
|||
const colInfo = columns && columns.length > 0
|
||||
? ` [${columns.join(", ")}]`
|
||||
: "";
|
||||
return `${srcLabel} -> ${tgtLabel}${colInfo}`;
|
||||
return `${srcLabel} → ${tgtLabel}${colInfo}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,9 +69,21 @@ export default function PopViewerWithModals({
|
|||
() => layout.dataFlow?.connections ?? [],
|
||||
[layout.dataFlow?.connections]
|
||||
);
|
||||
|
||||
const componentTypes = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (layout.components) {
|
||||
for (const comp of Object.values(layout.components)) {
|
||||
map.set(comp.id, comp.type);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [layout.components]);
|
||||
|
||||
useConnectionResolver({
|
||||
screenId,
|
||||
connections: stableConnections,
|
||||
componentTypes,
|
||||
});
|
||||
|
||||
// 모달 열기/닫기 이벤트 구독
|
||||
|
|
|
|||
|
|
@ -99,10 +99,8 @@ function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
|
|||
function cartItemToDbRecord(
|
||||
item: CartItemWithId,
|
||||
screenId: string,
|
||||
cartType: string = "pop",
|
||||
selectedColumns?: string[],
|
||||
): Record<string, unknown> {
|
||||
// selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장
|
||||
const rowData =
|
||||
selectedColumns && selectedColumns.length > 0
|
||||
? Object.fromEntries(
|
||||
|
|
@ -111,7 +109,7 @@ function cartItemToDbRecord(
|
|||
: item.row;
|
||||
|
||||
return {
|
||||
cart_type: cartType,
|
||||
cart_type: "",
|
||||
screen_id: screenId,
|
||||
source_table: item.sourceTable,
|
||||
row_key: item.rowKey,
|
||||
|
|
@ -144,7 +142,6 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
|
|||
export function useCartSync(
|
||||
screenId: string,
|
||||
sourceTable: string,
|
||||
cartType?: string,
|
||||
): UseCartSyncReturn {
|
||||
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
||||
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||
|
|
@ -153,21 +150,18 @@ export function useCartSync(
|
|||
|
||||
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;
|
||||
if (!screenId || !sourceTable) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await dataApi.getTableData("cart_items", {
|
||||
size: 500,
|
||||
filters: {
|
||||
screen_id: screenId,
|
||||
cart_type: cartTypeRef.current,
|
||||
status: "in_cart",
|
||||
},
|
||||
});
|
||||
|
|
@ -181,7 +175,7 @@ export function useCartSync(
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [screenId]);
|
||||
}, [screenId, sourceTable]);
|
||||
|
||||
// 마운트 시 자동 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -286,18 +280,16 @@ export function useCartSync(
|
|||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
for (const item of toDelete) {
|
||||
promises.push(dataApi.deleteRecord("cart_items", item.cartId!));
|
||||
promises.push(dataApi.updateRecord("cart_items", item.cartId!, { status: "cancelled" }));
|
||||
}
|
||||
|
||||
const currentCartType = cartTypeRef.current;
|
||||
|
||||
for (const item of toCreate) {
|
||||
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns);
|
||||
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
|
||||
promises.push(dataApi.createRecord("cart_items", record));
|
||||
}
|
||||
|
||||
for (const item of toUpdate) {
|
||||
const record = cartItemToDbRecord(item, currentScreenId, currentCartType, selectedColumns);
|
||||
const record = cartItemToDbRecord(item, currentScreenId, selectedColumns);
|
||||
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,47 +8,103 @@
|
|||
* 이벤트 규칙:
|
||||
* 소스: __comp_output__${sourceComponentId}__${outputKey}
|
||||
* 타겟: __comp_input__${targetComponentId}__${inputKey}
|
||||
*
|
||||
* _auto 모드:
|
||||
* sourceOutput="_auto"인 연결은 소스/타겟의 connectionMeta를 비교하여
|
||||
* key가 같고 category="event"인 쌍을 모두 자동 라우팅한다.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePopEvent } from "./usePopEvent";
|
||||
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
||||
import {
|
||||
PopComponentRegistry,
|
||||
type ConnectionMetaItem,
|
||||
} from "@/lib/registry/PopComponentRegistry";
|
||||
|
||||
interface UseConnectionResolverOptions {
|
||||
screenId: string;
|
||||
connections: PopDataConnection[];
|
||||
componentTypes?: Map<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스/타겟의 connectionMeta에서 자동 매칭 가능한 이벤트 쌍을 찾는다.
|
||||
* 규칙: category="event"이고 key가 동일한 쌍
|
||||
*/
|
||||
function getAutoMatchPairs(
|
||||
sourceType: string,
|
||||
targetType: string
|
||||
): { sourceKey: string; targetKey: string }[] {
|
||||
const sourceDef = PopComponentRegistry.getComponent(sourceType);
|
||||
const targetDef = PopComponentRegistry.getComponent(targetType);
|
||||
|
||||
if (!sourceDef?.connectionMeta?.sendable || !targetDef?.connectionMeta?.receivable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pairs: { sourceKey: string; targetKey: string }[] = [];
|
||||
|
||||
for (const s of sourceDef.connectionMeta.sendable) {
|
||||
if (s.category !== "event") continue;
|
||||
for (const r of targetDef.connectionMeta.receivable) {
|
||||
if (r.category !== "event") continue;
|
||||
if (s.key === r.key) {
|
||||
pairs.push({ sourceKey: s.key, targetKey: r.key });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
}
|
||||
|
||||
export function useConnectionResolver({
|
||||
screenId,
|
||||
connections,
|
||||
componentTypes,
|
||||
}: UseConnectionResolverOptions): void {
|
||||
const { publish, subscribe } = usePopEvent(screenId);
|
||||
|
||||
// 연결 목록을 ref로 저장하여 콜백 안정성 확보
|
||||
const connectionsRef = useRef(connections);
|
||||
connectionsRef.current = connections;
|
||||
|
||||
const componentTypesRef = useRef(componentTypes);
|
||||
componentTypesRef.current = componentTypes;
|
||||
|
||||
useEffect(() => {
|
||||
if (!connections || connections.length === 0) return;
|
||||
|
||||
const unsubscribers: (() => void)[] = [];
|
||||
|
||||
// 소스별로 그룹핑하여 구독 생성
|
||||
const sourceGroups = new Map<string, PopDataConnection[]>();
|
||||
for (const conn of connections) {
|
||||
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
|
||||
const existing = sourceGroups.get(sourceEvent) || [];
|
||||
existing.push(conn);
|
||||
sourceGroups.set(sourceEvent, existing);
|
||||
}
|
||||
const isAutoMode = conn.sourceOutput === "_auto" || !conn.sourceOutput;
|
||||
|
||||
for (const [sourceEvent, conns] of sourceGroups) {
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
for (const conn of conns) {
|
||||
if (isAutoMode && componentTypesRef.current) {
|
||||
const sourceType = componentTypesRef.current.get(conn.sourceComponent);
|
||||
const targetType = componentTypesRef.current.get(conn.targetComponent);
|
||||
|
||||
if (!sourceType || !targetType) continue;
|
||||
|
||||
const pairs = getAutoMatchPairs(sourceType, targetType);
|
||||
|
||||
for (const pair of pairs) {
|
||||
const sourceEvent = `__comp_output__${conn.sourceComponent}__${pair.sourceKey}`;
|
||||
const targetEvent = `__comp_input__${conn.targetComponent}__${pair.targetKey}`;
|
||||
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
publish(targetEvent, {
|
||||
value: payload,
|
||||
_connectionId: conn.id,
|
||||
});
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
} else {
|
||||
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
|
||||
|
||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
||||
|
||||
// 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
|
||||
const enrichedPayload = {
|
||||
value: payload,
|
||||
filterConfig: conn.filterConfig,
|
||||
|
|
@ -56,9 +112,9 @@ export function useConnectionResolver({
|
|||
};
|
||||
|
||||
publish(targetEvent, enrichedPayload);
|
||||
}
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
});
|
||||
unsubscribers.push(unsub);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export interface ConnectionMetaItem {
|
|||
key: string;
|
||||
label: string;
|
||||
type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string;
|
||||
category?: "event" | "filter" | "data";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,9 +48,20 @@ import {
|
|||
ChevronDown,
|
||||
ShoppingCart,
|
||||
ShoppingBag,
|
||||
PackageCheck,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { CollectedDataResponse, StatusChangeRule } from "./types";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { TableCombobox } from "./pop-shared/TableCombobox";
|
||||
import { ColumnCombobox } from "./pop-shared/ColumnCombobox";
|
||||
import {
|
||||
fetchTableList,
|
||||
fetchTableColumns,
|
||||
type TableInfo,
|
||||
type ColumnInfo,
|
||||
} from "./pop-dashboard/utils/dataFetcher";
|
||||
|
||||
// ========================================
|
||||
// STEP 1: 타입 정의
|
||||
|
|
@ -118,6 +129,7 @@ export type ButtonPreset =
|
|||
| "menu"
|
||||
| "modal-open"
|
||||
| "cart"
|
||||
| "inbound-confirm"
|
||||
| "custom";
|
||||
|
||||
/** row_data 저장 모드 */
|
||||
|
|
@ -141,6 +153,9 @@ export interface PopButtonConfig {
|
|||
action: ButtonMainAction;
|
||||
followUpActions?: FollowUpAction[];
|
||||
cart?: CartButtonConfig;
|
||||
statusChangeRules?: StatusChangeRule[];
|
||||
/** @deprecated inboundConfirm.statusChangeRules -> statusChangeRules로 이동 */
|
||||
inboundConfirm?: { statusChangeRules?: StatusChangeRule[] };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
@ -180,6 +195,7 @@ const PRESET_LABELS: Record<ButtonPreset, string> = {
|
|||
menu: "메뉴 (드롭다운)",
|
||||
"modal-open": "모달 열기",
|
||||
cart: "장바구니 저장",
|
||||
"inbound-confirm": "입고 확정",
|
||||
custom: "직접 설정",
|
||||
};
|
||||
|
||||
|
|
@ -270,6 +286,13 @@ const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
|
|||
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
|
||||
action: { type: "event" },
|
||||
},
|
||||
"inbound-confirm": {
|
||||
label: "입고 확정",
|
||||
variant: "default",
|
||||
icon: "PackageCheck",
|
||||
confirm: { enabled: true, message: "선택한 품목을 입고 확정하시겠습니까?" },
|
||||
action: { type: "event" },
|
||||
},
|
||||
custom: {
|
||||
label: "버튼",
|
||||
variant: "default",
|
||||
|
|
@ -341,6 +364,7 @@ const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
|||
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
||||
ShoppingCart,
|
||||
ShoppingBag,
|
||||
PackageCheck,
|
||||
};
|
||||
|
||||
/** Lucide 아이콘 동적 렌더링 */
|
||||
|
|
@ -389,10 +413,13 @@ export function PopButtonComponent({
|
|||
|
||||
// 장바구니 모드 상태
|
||||
const isCartMode = config?.preset === "cart";
|
||||
const isInboundConfirmMode = config?.preset === "inbound-confirm";
|
||||
const [cartCount, setCartCount] = useState(0);
|
||||
const [cartIsDirty, setCartIsDirty] = useState(false);
|
||||
const [cartSaving, setCartSaving] = useState(false);
|
||||
const [showCartConfirm, setShowCartConfirm] = useState(false);
|
||||
const [confirmProcessing, setConfirmProcessing] = useState(false);
|
||||
const [showInboundConfirm, setShowInboundConfirm] = useState(false);
|
||||
|
||||
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
|
||||
useEffect(() => {
|
||||
|
|
@ -474,12 +501,99 @@ export function PopButtonComponent({
|
|||
}
|
||||
}, [cartSaving]);
|
||||
|
||||
// 입고 확정: 데이터 수집 → API 호출
|
||||
const handleInboundConfirm = useCallback(async () => {
|
||||
if (!componentId) return;
|
||||
setConfirmProcessing(true);
|
||||
|
||||
try {
|
||||
// 동기적 이벤트 수집 (connectionResolver가 동기 중계)
|
||||
const responses: CollectedDataResponse[] = [];
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__collected_data`,
|
||||
(payload: unknown) => {
|
||||
const enriched = payload as { value?: CollectedDataResponse };
|
||||
if (enriched?.value) {
|
||||
responses.push(enriched.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
publish(`__comp_output__${componentId}__collect_data`, {
|
||||
requestId: crypto.randomUUID(),
|
||||
action: "inbound-confirm",
|
||||
});
|
||||
|
||||
unsub();
|
||||
|
||||
if (responses.length === 0) {
|
||||
toast.error("연결된 컴포넌트에서 데이터를 수집할 수 없습니다. 연결 설정을 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const cardListData = responses.find(r => r.componentType === "pop-card-list");
|
||||
const fieldData = responses.find(r => r.componentType === "pop-field");
|
||||
|
||||
const selectedItems = cardListData?.data?.items ?? [];
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error("확정할 항목을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldValues = fieldData?.data?.values ?? {};
|
||||
|
||||
const statusChangeRules = config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? [];
|
||||
|
||||
const cardListMapping = cardListData?.mapping ?? null;
|
||||
const fieldMapping = fieldData?.mapping ?? null;
|
||||
|
||||
const result = await apiClient.post("/api/pop/execute-action", {
|
||||
action: "inbound-confirm",
|
||||
data: {
|
||||
items: selectedItems,
|
||||
fieldValues,
|
||||
},
|
||||
mappings: {
|
||||
cardList: cardListMapping,
|
||||
field: fieldMapping,
|
||||
},
|
||||
statusChanges: statusChangeRules,
|
||||
});
|
||||
|
||||
if (result.data?.success) {
|
||||
toast.success(`${selectedItems.length}건 입고 확정 완료`);
|
||||
publish(`__comp_output__${componentId}__action_completed`, {
|
||||
action: "inbound-confirm",
|
||||
success: true,
|
||||
count: selectedItems.length,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.data?.message || "입고 확정에 실패했습니다.");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "입고 확정 중 오류가 발생했습니다.";
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setConfirmProcessing(false);
|
||||
setShowInboundConfirm(false);
|
||||
}
|
||||
}, [componentId, subscribe, publish, config?.statusChangeRules, config?.inboundConfirm?.statusChangeRules]);
|
||||
|
||||
// 클릭 핸들러
|
||||
const handleClick = useCallback(async () => {
|
||||
if (isDesignMode) {
|
||||
toast.info(
|
||||
`[디자인 모드] ${isCartMode ? "장바구니 저장" : ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션`
|
||||
);
|
||||
const modeLabel = isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"];
|
||||
toast.info(`[디자인 모드] ${modeLabel} 액션`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 입고 확정 모드: confirm 다이얼로그 후 데이터 수집 → API 호출
|
||||
if (isInboundConfirmMode) {
|
||||
if (config?.confirm?.enabled !== false) {
|
||||
setShowInboundConfirm(true);
|
||||
} else {
|
||||
await handleInboundConfirm();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -513,7 +627,7 @@ export function PopButtonComponent({
|
|||
confirm: config?.confirm,
|
||||
followUpActions: config?.followUpActions,
|
||||
});
|
||||
}, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]);
|
||||
}, [isDesignMode, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm]);
|
||||
|
||||
// 외형
|
||||
const buttonLabel = config?.label || label || "버튼";
|
||||
|
|
@ -548,7 +662,7 @@ export function PopButtonComponent({
|
|||
<Button
|
||||
variant={variant}
|
||||
onClick={handleClick}
|
||||
disabled={isLoading || cartSaving}
|
||||
disabled={isLoading || cartSaving || confirmProcessing}
|
||||
className={cn(
|
||||
"transition-transform active:scale-95",
|
||||
isIconOnly && "px-2",
|
||||
|
|
@ -610,6 +724,35 @@ export function PopButtonComponent({
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 입고 확정 확인 다이얼로그 */}
|
||||
<AlertDialog open={showInboundConfirm} onOpenChange={setShowInboundConfirm}>
|
||||
<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 || "선택한 품목을 입고 확정하시겠습니까?"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel
|
||||
disabled={confirmProcessing}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => { handleInboundConfirm(); }}
|
||||
disabled={confirmProcessing}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{confirmProcessing ? "처리 중..." : "확정"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일반 확인 다이얼로그 */}
|
||||
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
|
|
@ -1029,7 +1172,7 @@ export function PopButtonConfigPanel({
|
|||
<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='장바구니 타입 (미사용)' target="cart_type" auto />
|
||||
<CartMappingRow source='상태 ("in_cart")' target="status" auto />
|
||||
<CartMappingRow source="회사 코드" target="company_code" auto />
|
||||
<CartMappingRow source="사용자 ID" target="user_id" auto />
|
||||
|
|
@ -1130,6 +1273,17 @@ export function PopButtonConfigPanel({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 상태 변경 규칙 (cart 프리셋 제외 모두 표시) */}
|
||||
{config?.preset !== "cart" && (
|
||||
<>
|
||||
<SectionDivider label="상태 변경 규칙" />
|
||||
<StatusChangeRuleEditor
|
||||
rules={config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? []}
|
||||
onUpdate={(rules) => onUpdate({ ...config, statusChangeRules: rules })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 후속 액션 */}
|
||||
<SectionDivider label="후속 액션" />
|
||||
<FollowUpActionsEditor
|
||||
|
|
@ -1467,6 +1621,330 @@ function PopButtonPreviewComponent({
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 상태 변경 규칙 편집기
|
||||
// ========================================
|
||||
|
||||
const KNOWN_ITEM_FIELDS = [
|
||||
{ value: "__cart_id", label: "__cart_id (카드 항목 ID)" },
|
||||
{ value: "__cart_row_key", label: "__cart_row_key (원본 PK 값)" },
|
||||
{ value: "id", label: "id" },
|
||||
{ value: "row_key", label: "row_key" },
|
||||
];
|
||||
|
||||
function StatusChangeRuleEditor({
|
||||
rules,
|
||||
onUpdate,
|
||||
}: {
|
||||
rules: StatusChangeRule[];
|
||||
onUpdate: (rules: StatusChangeRule[]) => void;
|
||||
}) {
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columnsMap, setColumnsMap] = useState<Record<string, ColumnInfo[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchTableList().then(setTables);
|
||||
}, []);
|
||||
|
||||
const loadColumns = (tableName: string) => {
|
||||
if (!tableName || columnsMap[tableName]) return;
|
||||
fetchTableColumns(tableName).then((cols) => {
|
||||
setColumnsMap((prev) => ({ ...prev, [tableName]: cols }));
|
||||
});
|
||||
};
|
||||
|
||||
const updateRule = (idx: number, partial: Partial<StatusChangeRule>) => {
|
||||
const next = [...rules];
|
||||
next[idx] = { ...next[idx], ...partial };
|
||||
onUpdate(next);
|
||||
};
|
||||
|
||||
const removeRule = (idx: number) => {
|
||||
const next = [...rules];
|
||||
next.splice(idx, 1);
|
||||
onUpdate(next);
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
onUpdate([
|
||||
...rules,
|
||||
{
|
||||
targetTable: "",
|
||||
targetColumn: "",
|
||||
valueType: "fixed",
|
||||
fixedValue: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-1">
|
||||
{rules.map((rule, idx) => (
|
||||
<SingleRuleEditor
|
||||
key={idx}
|
||||
rule={rule}
|
||||
idx={idx}
|
||||
tables={tables}
|
||||
columns={columnsMap[rule.targetTable] ?? []}
|
||||
onLoadColumns={loadColumns}
|
||||
onUpdate={(partial) => updateRule(idx, partial)}
|
||||
onRemove={() => removeRule(idx)}
|
||||
/>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="w-full text-xs" onClick={addRule}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SingleRuleEditor({
|
||||
rule,
|
||||
idx,
|
||||
tables,
|
||||
columns,
|
||||
onLoadColumns,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: {
|
||||
rule: StatusChangeRule;
|
||||
idx: number;
|
||||
tables: TableInfo[];
|
||||
columns: ColumnInfo[];
|
||||
onLoadColumns: (tableName: string) => void;
|
||||
onUpdate: (partial: Partial<StatusChangeRule>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (rule.targetTable) onLoadColumns(rule.targetTable);
|
||||
}, [rule.targetTable]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const conditions = rule.conditionalValue?.conditions ?? [];
|
||||
const defaultValue = rule.conditionalValue?.defaultValue ?? "";
|
||||
|
||||
const updateCondition = (cIdx: number, partial: Partial<(typeof conditions)[0]>) => {
|
||||
const next = [...conditions];
|
||||
next[cIdx] = { ...next[cIdx], ...partial };
|
||||
onUpdate({
|
||||
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
|
||||
});
|
||||
};
|
||||
|
||||
const removeCondition = (cIdx: number) => {
|
||||
const next = [...conditions];
|
||||
next.splice(cIdx, 1);
|
||||
onUpdate({
|
||||
conditionalValue: { ...rule.conditionalValue, conditions: next, defaultValue },
|
||||
});
|
||||
};
|
||||
|
||||
const addCondition = () => {
|
||||
onUpdate({
|
||||
conditionalValue: {
|
||||
...rule.conditionalValue,
|
||||
conditions: [...conditions, { whenColumn: "", operator: "=" as const, whenValue: "", thenValue: "" }],
|
||||
defaultValue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded border border-border p-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">규칙 {idx + 1}</span>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onRemove}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 대상 테이블 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">대상 테이블</Label>
|
||||
<TableCombobox
|
||||
tables={tables}
|
||||
value={rule.targetTable}
|
||||
onSelect={(v) => onUpdate({ targetTable: v, targetColumn: "" })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 변경 컬럼 */}
|
||||
{rule.targetTable && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">변경 컬럼</Label>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={rule.targetColumn}
|
||||
onSelect={(v) => onUpdate({ targetColumn: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조회 키 */}
|
||||
{rule.targetColumn && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-[10px]">조회 키</Label>
|
||||
<Select
|
||||
value={rule.lookupMode ?? "auto"}
|
||||
onValueChange={(v) => onUpdate({ lookupMode: v as "auto" | "manual" })}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-16 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-[10px]">자동</SelectItem>
|
||||
<SelectItem value="manual" className="text-[10px]">수동</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{(rule.lookupMode ?? "auto") === "auto" ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{rule.targetTable === "cart_items"
|
||||
? `카드 항목.__cart_id → ${rule.targetTable}.id`
|
||||
: `카드 항목.row_key → ${rule.targetTable}.${columns.find(c => c.isPrimaryKey)?.name ?? "PK(조회중)"}`}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Select
|
||||
value={rule.manualItemField ?? ""}
|
||||
onValueChange={(v) => onUpdate({ manualItemField: v })}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
||||
<SelectValue placeholder="카드 항목 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KNOWN_ITEM_FIELDS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value} className="text-[10px]">{f.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">→</span>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={rule.manualPkColumn ?? ""}
|
||||
onSelect={(v) => onUpdate({ manualPkColumn: v })}
|
||||
placeholder="대상 PK 컬럼"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 변경 값 타입 */}
|
||||
{rule.targetColumn && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">변경 값</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex items-center gap-1 text-[10px]">
|
||||
<input
|
||||
type="radio"
|
||||
name={`valueType-${idx}`}
|
||||
checked={rule.valueType === "fixed"}
|
||||
onChange={() => onUpdate({ valueType: "fixed" })}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
고정값
|
||||
</label>
|
||||
<label className="flex items-center gap-1 text-[10px]">
|
||||
<input
|
||||
type="radio"
|
||||
name={`valueType-${idx}`}
|
||||
checked={rule.valueType === "conditional"}
|
||||
onChange={() => onUpdate({ valueType: "conditional" })}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
조건부
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 고정값 */}
|
||||
{rule.valueType === "fixed" && (
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
value={rule.fixedValue ?? ""}
|
||||
onChange={(e) => onUpdate({ fixedValue: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
placeholder="변경할 값 입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조건부 */}
|
||||
{rule.valueType === "conditional" && (
|
||||
<div className="space-y-2 rounded border border-dashed border-muted-foreground/30 p-2">
|
||||
{conditions.map((cond, cIdx) => (
|
||||
<div key={cIdx} className="space-y-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">만약</span>
|
||||
<ColumnCombobox
|
||||
columns={columns}
|
||||
value={cond.whenColumn}
|
||||
onSelect={(v) => updateCondition(cIdx, { whenColumn: v })}
|
||||
placeholder="컬럼"
|
||||
/>
|
||||
<Select
|
||||
value={cond.operator}
|
||||
onValueChange={(v) => updateCondition(cIdx, { operator: v as "=" | "!=" | ">" | "<" | ">=" | "<=" })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-14 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["=", "!=", ">", "<", ">=", "<="].map((op) => (
|
||||
<SelectItem key={op} value={op} className="text-xs">{op}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={cond.whenValue}
|
||||
onChange={(e) => updateCondition(cIdx, { whenValue: e.target.value })}
|
||||
className="h-7 w-16 text-[10px]"
|
||||
placeholder="값"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => removeCondition(cIdx)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pl-4">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">이면 -></span>
|
||||
<Input
|
||||
value={cond.thenValue}
|
||||
onChange={(e) => updateCondition(cIdx, { thenValue: e.target.value })}
|
||||
className="h-7 text-[10px]"
|
||||
placeholder="변경할 값"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="w-full text-[10px]" onClick={addCondition}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건 추가
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">그 외 -></span>
|
||||
<Input
|
||||
value={defaultValue}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
conditionalValue: { ...rule.conditionalValue, conditions, defaultValue: e.target.value },
|
||||
})
|
||||
}
|
||||
className="h-7 text-[10px]"
|
||||
placeholder="기본값"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 레지스트리 등록
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-button",
|
||||
|
|
@ -1486,11 +1964,14 @@ PopComponentRegistry.registerComponent({
|
|||
} as PopButtonConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 일괄 저장 요청 (카드 목록에 전달)" },
|
||||
{ key: "collect_data", label: "데이터 수집 요청", type: "event", category: "event", description: "연결된 컴포넌트에 데이터+매핑 수집 요청" },
|
||||
{ key: "action_completed", label: "액션 완료", type: "event", category: "event", description: "확정/저장 완료 후 결과 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
|
||||
{ key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ import type {
|
|||
CardPresetSpec,
|
||||
CartItem,
|
||||
PackageEntry,
|
||||
CartListModeConfig,
|
||||
CollectDataRequest,
|
||||
CollectedDataResponse,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_CARD_IMAGE,
|
||||
|
|
@ -183,27 +184,34 @@ export function PopCardListComponent({
|
|||
currentColSpan,
|
||||
onRequestResize,
|
||||
}: PopCardListComponentProps) {
|
||||
const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal";
|
||||
const maxGridColumns = config?.gridColumns || 2;
|
||||
const configGridRows = config?.gridRows || 3;
|
||||
const dataSource = config?.dataSource;
|
||||
const template = config?.cardTemplate;
|
||||
|
||||
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 isCartListMode = config?.cartListMode?.enabled === true;
|
||||
const [inheritedTemplate, setInheritedTemplate] = useState<CardTemplateConfig | null>(null);
|
||||
const [inheritedConfig, setInheritedConfig] = useState<Partial<PopCardListConfig> | null>(null);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
// 장바구니 목록 모드에서는 원본 화면의 템플릿을 사용, 폴백으로 자체 설정
|
||||
const effectiveTemplate = (isCartListMode && inheritedTemplate) ? inheritedTemplate : template;
|
||||
// 장바구니 목록 모드: 원본 화면의 설정 전체를 병합 (cardTemplate, inputField, packageConfig, cardSize 등)
|
||||
const effectiveConfig = useMemo<PopCardListConfig | undefined>(() => {
|
||||
if (!isCartListMode || !inheritedConfig) return config;
|
||||
return {
|
||||
...config,
|
||||
...inheritedConfig,
|
||||
cartListMode: config?.cartListMode,
|
||||
dataSource: config?.dataSource,
|
||||
} as PopCardListConfig;
|
||||
}, [config, inheritedConfig, isCartListMode]);
|
||||
|
||||
const isHorizontalMode = (effectiveConfig?.scrollDirection || "vertical") === "horizontal";
|
||||
const maxGridColumns = effectiveConfig?.gridColumns || 2;
|
||||
const configGridRows = effectiveConfig?.gridRows || 3;
|
||||
const dataSource = effectiveConfig?.dataSource;
|
||||
const effectiveTemplate = effectiveConfig?.cardTemplate;
|
||||
|
||||
// 장바구니 DB 동기화 (장바구니 목록 모드에서는 비활성화)
|
||||
const sourceTableName = (!isCartListMode && dataSource?.tableName) || "";
|
||||
const cart = useCartSync(screenId || "", sourceTableName);
|
||||
|
||||
// 데이터 상태
|
||||
const [rows, setRows] = useState<RowData[]>([]);
|
||||
|
|
@ -311,7 +319,7 @@ export function PopCardListComponent({
|
|||
const missingImageCountRef = useRef(0);
|
||||
|
||||
|
||||
const cardSizeKey = config?.cardSize || "large";
|
||||
const cardSizeKey = effectiveConfig?.cardSize || "large";
|
||||
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
|
||||
|
||||
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
|
||||
|
|
@ -509,36 +517,26 @@ export function PopCardListComponent({
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 원본 화면 레이아웃에서 cardTemplate 상속
|
||||
if (cartListMode.sourceScreenId) {
|
||||
try {
|
||||
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId);
|
||||
const componentsMap = layoutJson?.components || {};
|
||||
const componentList = Object.values(componentsMap) as any[];
|
||||
// sourceComponentId > cartType > 첫 번째 pop-card-list 순으로 매칭
|
||||
const matched = cartListMode.sourceComponentId
|
||||
? componentList.find((c: any) => c.id === cartListMode.sourceComponentId)
|
||||
: cartListMode.cartType
|
||||
? componentList.find(
|
||||
(c: any) =>
|
||||
c.type === "pop-card-list" &&
|
||||
c.config?.cartAction?.cartType === cartListMode.cartType
|
||||
)
|
||||
: componentList.find((c: any) => c.type === "pop-card-list");
|
||||
if (matched?.config?.cardTemplate) {
|
||||
setInheritedTemplate(matched.config.cardTemplate);
|
||||
}
|
||||
} catch {
|
||||
// 레이아웃 로드 실패 시 config.cardTemplate 폴백
|
||||
// 원본 화면 레이아웃에서 설정 전체 상속 (cardTemplate, inputField, packageConfig, cardSize 등)
|
||||
try {
|
||||
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId);
|
||||
const componentsMap = layoutJson?.components || {};
|
||||
const componentList = Object.values(componentsMap) as any[];
|
||||
const matched = cartListMode.sourceComponentId
|
||||
? componentList.find((c: any) => c.id === cartListMode.sourceComponentId)
|
||||
: componentList.find((c: any) => c.type === "pop-card-list");
|
||||
if (matched?.config) {
|
||||
setInheritedConfig(matched.config);
|
||||
}
|
||||
} catch {
|
||||
// 레이아웃 로드 실패 시 자체 config 폴백
|
||||
}
|
||||
|
||||
// cart_items 조회 (cartType이 있으면 필터, 없으면 전체)
|
||||
const cartFilters: Record<string, unknown> = {
|
||||
status: cartListMode.statusFilter || "in_cart",
|
||||
};
|
||||
if (cartListMode.cartType) {
|
||||
cartFilters.cart_type = cartListMode.cartType;
|
||||
if (cartListMode.sourceScreenId) {
|
||||
cartFilters.screen_id = String(cartListMode.sourceScreenId);
|
||||
}
|
||||
const result = await dataApi.getTableData("cart_items", {
|
||||
size: 500,
|
||||
|
|
@ -572,10 +570,11 @@ export function PopCardListComponent({
|
|||
missingImageCountRef.current = 0;
|
||||
|
||||
try {
|
||||
// 서버에는 = 연산자 필터만 전달, 나머지는 클라이언트 후처리
|
||||
const filters: Record<string, unknown> = {};
|
||||
if (dataSource.filters && dataSource.filters.length > 0) {
|
||||
dataSource.filters.forEach((f) => {
|
||||
if (f.column && f.value) {
|
||||
if (f.column && f.value && (!f.operator || f.operator === "=")) {
|
||||
filters[f.column] = f.value;
|
||||
}
|
||||
});
|
||||
|
|
@ -604,7 +603,31 @@ export function PopCardListComponent({
|
|||
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
||||
});
|
||||
|
||||
setRows(result.data || []);
|
||||
let fetchedRows = result.data || [];
|
||||
|
||||
// 서버에서 처리하지 못한 연산자 필터 클라이언트 후처리
|
||||
const clientFilters = (dataSource.filters || []).filter(
|
||||
(f) => f.column && f.value && f.operator && f.operator !== "="
|
||||
);
|
||||
if (clientFilters.length > 0) {
|
||||
fetchedRows = fetchedRows.filter((row) =>
|
||||
clientFilters.every((f) => {
|
||||
const cellVal = row[f.column];
|
||||
const filterVal = f.value;
|
||||
switch (f.operator) {
|
||||
case "!=": return String(cellVal ?? "") !== filterVal;
|
||||
case ">": return Number(cellVal) > Number(filterVal);
|
||||
case ">=": return Number(cellVal) >= Number(filterVal);
|
||||
case "<": return Number(cellVal) < Number(filterVal);
|
||||
case "<=": return Number(cellVal) <= Number(filterVal);
|
||||
case "like": return String(cellVal ?? "").toLowerCase().includes(filterVal.toLowerCase());
|
||||
default: return true;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
setRows(fetchedRows);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "데이터 조회 실패";
|
||||
setError(message);
|
||||
|
|
@ -654,10 +677,49 @@ export function PopCardListComponent({
|
|||
}));
|
||||
}, []);
|
||||
|
||||
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → 선택 항목 + 매핑 응답
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__collect_data`,
|
||||
(payload: unknown) => {
|
||||
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
|
||||
|
||||
const selectedItems = isCartListMode
|
||||
? filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? "")))
|
||||
: rows;
|
||||
|
||||
// CardListSaveMapping → SaveMapping 변환
|
||||
const sm = config?.saveMapping;
|
||||
const mapping = sm?.targetTable && sm.mappings.length > 0
|
||||
? {
|
||||
targetTable: sm.targetTable,
|
||||
columnMapping: Object.fromEntries(
|
||||
sm.mappings
|
||||
.filter(m => m.sourceField && m.targetColumn)
|
||||
.map(m => [m.sourceField, m.targetColumn])
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
const response: CollectedDataResponse = {
|
||||
requestId: request?.requestId ?? "",
|
||||
componentId: componentId,
|
||||
componentType: "pop-card-list",
|
||||
data: { items: selectedItems },
|
||||
mapping,
|
||||
};
|
||||
|
||||
publish(`__comp_output__${componentId}__collected_data`, response);
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]);
|
||||
|
||||
// 장바구니 목록 모드: 선택 항목 이벤트 발행
|
||||
useEffect(() => {
|
||||
if (!componentId || !isCartListMode) return;
|
||||
const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id)));
|
||||
const selectedItems = filteredRows.filter(r => selectedKeys.has(String(r.__cart_id ?? "")));
|
||||
publish(`__comp_output__${componentId}__selected_items`, selectedItems);
|
||||
}, [selectedKeys, filteredRows, componentId, isCartListMode, publish]);
|
||||
|
||||
|
|
@ -720,15 +782,15 @@ export function PopCardListComponent({
|
|||
<div className="flex shrink-0 items-center gap-3 border-b px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.size === displayCards.length && displayCards.length > 0}
|
||||
checked={selectedKeys.size === filteredRows.length && filteredRows.length > 0}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedKeys(new Set(displayCards.map(r => String(r.__cart_id))));
|
||||
setSelectedKeys(new Set(filteredRows.map(r => String(r.__cart_id ?? ""))));
|
||||
} else {
|
||||
setSelectedKeys(new Set());
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"}
|
||||
|
|
@ -757,19 +819,20 @@ export function PopCardListComponent({
|
|||
row={row}
|
||||
template={effectiveTemplate}
|
||||
scaled={scaled}
|
||||
inputField={config?.inputField}
|
||||
packageConfig={config?.packageConfig}
|
||||
cartAction={config?.cartAction}
|
||||
inputField={effectiveConfig?.inputField}
|
||||
packageConfig={effectiveConfig?.packageConfig}
|
||||
cartAction={effectiveConfig?.cartAction}
|
||||
publish={publish}
|
||||
router={router}
|
||||
onSelect={handleCardSelect}
|
||||
cart={cart}
|
||||
codeFieldName={effectiveTemplate?.header?.codeField}
|
||||
keyColumnName={effectiveConfig?.cartAction?.keyColumn || "id"}
|
||||
parentComponentId={componentId}
|
||||
isCartListMode={isCartListMode}
|
||||
isSelected={selectedKeys.has(String(row.__cart_id))}
|
||||
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
|
||||
onToggleSelect={() => {
|
||||
const cartId = String(row.__cart_id);
|
||||
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
|
||||
if (!cartId) return;
|
||||
setSelectedKeys(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(cartId)) next.delete(cartId);
|
||||
|
|
@ -859,7 +922,7 @@ function Card({
|
|||
router,
|
||||
onSelect,
|
||||
cart,
|
||||
codeFieldName,
|
||||
keyColumnName,
|
||||
parentComponentId,
|
||||
isCartListMode,
|
||||
isSelected,
|
||||
|
|
@ -877,7 +940,7 @@ function Card({
|
|||
router: ReturnType<typeof useRouter>;
|
||||
onSelect?: (row: RowData) => void;
|
||||
cart: ReturnType<typeof useCartSync>;
|
||||
codeFieldName?: string;
|
||||
keyColumnName?: string;
|
||||
parentComponentId?: string;
|
||||
isCartListMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
|
|
@ -897,8 +960,7 @@ function Card({
|
|||
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 rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
|
||||
const isCarted = cart.isItemInCart(rowKey);
|
||||
const existingCartItem = cart.getCartItem(rowKey);
|
||||
|
||||
|
|
@ -1012,14 +1074,14 @@ function Card({
|
|||
// 장바구니 목록 모드: 개별 삭제
|
||||
const handleCartDelete = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const cartId = String(row.__cart_id);
|
||||
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
|
||||
if (!cartId) return;
|
||||
|
||||
const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?");
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
await dataApi.deleteRecord("cart_items", cartId);
|
||||
await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" });
|
||||
onDeleteItem?.(cartId);
|
||||
} catch {
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
|
|
@ -1058,21 +1120,19 @@ function Card({
|
|||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
|
||||
>
|
||||
{/* 장바구니 목록 모드: 체크박스 */}
|
||||
{isCartListMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => { e.stopPropagation(); onToggleSelect?.(); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute left-2 top-2 z-10 h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 헤더 영역 */}
|
||||
{(codeValue !== null || titleValue !== null) && (
|
||||
{(codeValue !== null || titleValue !== null || isCartListMode) && (
|
||||
<div className={`border-b ${headerBgClass}`} style={headerStyle}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCartListMode && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => { e.stopPropagation(); onToggleSelect?.(); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 shrink-0 rounded border-input"
|
||||
/>
|
||||
)}
|
||||
{codeValue !== null && (
|
||||
<span
|
||||
className="shrink-0 font-medium text-muted-foreground"
|
||||
|
|
@ -1144,7 +1204,7 @@ function Card({
|
|||
<button
|
||||
type="button"
|
||||
onClick={handleInputClick}
|
||||
className="rounded-lg border-2 border-gray-300 bg-white px-2 py-1.5 text-center hover:border-primary active:bg-gray-50"
|
||||
className="rounded-lg border-2 border-input bg-background px-2 py-1.5 text-center hover:border-primary active:bg-muted"
|
||||
>
|
||||
<span className="block text-lg font-bold leading-tight">
|
||||
{inputValue.toLocaleString()}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
|
||||
import type { GridMode } from "@/components/pop/designer/types/pop-layout";
|
||||
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -25,8 +25,6 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
PopCardListConfig,
|
||||
|
|
@ -50,6 +48,8 @@ import type {
|
|||
CardResponsiveConfig,
|
||||
ResponsiveDisplayMode,
|
||||
CartListModeConfig,
|
||||
CardListSaveMapping,
|
||||
CardListSaveMappingEntry,
|
||||
} from "../types";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import {
|
||||
|
|
@ -63,6 +63,7 @@ import {
|
|||
type TableInfo,
|
||||
type ColumnInfo,
|
||||
} from "../pop-dashboard/utils/dataFetcher";
|
||||
import { TableCombobox } from "../pop-shared/TableCombobox";
|
||||
|
||||
// ===== 테이블별 그룹화된 컬럼 =====
|
||||
|
||||
|
|
@ -399,6 +400,42 @@ function BasicSettingsTab({
|
|||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* 필터 기준 (장바구니 모드 시 숨김) */}
|
||||
{!isCartListMode && dataSource.tableName && (
|
||||
<CollapsibleSection
|
||||
title="필터 기준"
|
||||
badge={
|
||||
dataSource.filters && dataSource.filters.length > 0
|
||||
? `${dataSource.filters.length}개`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FilterCriteriaSection
|
||||
dataSource={dataSource}
|
||||
columnGroups={columnGroups}
|
||||
onUpdate={updateDataSource}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* 저장 매핑 (장바구니 모드일 때만) */}
|
||||
{isCartListMode && (
|
||||
<CollapsibleSection
|
||||
title="저장 매핑"
|
||||
badge={
|
||||
config.saveMapping?.mappings && config.saveMapping.mappings.length > 0
|
||||
? `${config.saveMapping.mappings.length}개`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SaveMappingSection
|
||||
saveMapping={config.saveMapping}
|
||||
onUpdate={(saveMapping) => onUpdate({ saveMapping })}
|
||||
cartListMode={config.cartListMode}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* 레이아웃 설정 */}
|
||||
<CollapsibleSection title="레이아웃 설정" defaultOpen>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -667,99 +704,7 @@ function CardTemplateTab({
|
|||
);
|
||||
}
|
||||
|
||||
// ===== 테이블 검색 Combobox =====
|
||||
|
||||
function TableCombobox({
|
||||
tables,
|
||||
value,
|
||||
onSelect,
|
||||
}: {
|
||||
tables: TableInfo[];
|
||||
value: string;
|
||||
onSelect: (tableName: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
const found = tables.find((t) => t.tableName === value);
|
||||
return found ? (found.displayName || found.tableName) : "";
|
||||
}, [tables, value]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return tables;
|
||||
const q = search.toLowerCase();
|
||||
return tables.filter(
|
||||
(t) =>
|
||||
t.tableName.toLowerCase().includes(q) ||
|
||||
(t.displayName && t.displayName.toLowerCase().includes(q))
|
||||
);
|
||||
}, [tables, search]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{value ? selectedLabel : "테이블을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="테이블명 또는 한글명 검색..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-4 text-center text-xs">
|
||||
검색 결과가 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
onSelect(table.tableName);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3.5 w-3.5",
|
||||
value === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName || table.tableName}</span>
|
||||
{table.displayName && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{table.tableName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
// TableCombobox: pop-shared/TableCombobox.tsx에서 import
|
||||
|
||||
// ===== 테이블별 그룹화된 컬럼 셀렉트 =====
|
||||
|
||||
|
|
@ -867,7 +812,6 @@ function CollapsibleSection({
|
|||
interface SourceCardListInfo {
|
||||
componentId: string;
|
||||
label: string;
|
||||
cartType: string;
|
||||
}
|
||||
|
||||
function CartListModeSection({
|
||||
|
|
@ -915,8 +859,7 @@ function CartListModeSection({
|
|||
.filter((c: any) => c.type === "pop-card-list")
|
||||
.map((c: any) => ({
|
||||
componentId: c.id || "",
|
||||
label: c.label || c.config?.cartAction?.cartType || "카드 목록",
|
||||
cartType: c.config?.cartAction?.cartType || "",
|
||||
label: c.label || "카드 목록",
|
||||
}));
|
||||
setSourceCardLists(cardLists);
|
||||
})
|
||||
|
|
@ -928,23 +871,18 @@ function CartListModeSection({
|
|||
|
||||
const handleScreenChange = (val: string) => {
|
||||
const screenId = val === "__none__" ? undefined : Number(val);
|
||||
onUpdate({ ...mode, sourceScreenId: screenId, cartType: undefined });
|
||||
onUpdate({ ...mode, sourceScreenId: screenId });
|
||||
};
|
||||
|
||||
const handleComponentSelect = (val: string) => {
|
||||
if (val === "__none__") {
|
||||
onUpdate({ ...mode, cartType: undefined, sourceComponentId: undefined });
|
||||
onUpdate({ ...mode, sourceComponentId: undefined });
|
||||
return;
|
||||
}
|
||||
const found = val.startsWith("__comp_")
|
||||
? sourceCardLists.find((c) => c.componentId === val.replace("__comp_", ""))
|
||||
: sourceCardLists.find((c) => c.cartType === val);
|
||||
const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val;
|
||||
const found = sourceCardLists.find((c) => c.componentId === compId);
|
||||
if (found) {
|
||||
onUpdate({
|
||||
...mode,
|
||||
sourceComponentId: found.componentId,
|
||||
cartType: found.cartType || undefined,
|
||||
});
|
||||
onUpdate({ ...mode, sourceComponentId: found.componentId });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1000,11 +938,7 @@ function CartListModeSection({
|
|||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={
|
||||
mode.sourceComponentId
|
||||
? (sourceCardLists.find(c => c.componentId === mode.sourceComponentId)?.cartType || `__comp_${mode.sourceComponentId}`)
|
||||
: mode.cartType || "__none__"
|
||||
}
|
||||
value={mode.sourceComponentId ? `__comp_${mode.sourceComponentId}` : "__none__"}
|
||||
onValueChange={handleComponentSelect}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
|
|
@ -1012,19 +946,16 @@ function CartListModeSection({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||
{sourceCardLists.map((c) => {
|
||||
const selectValue = c.cartType || `__comp_${c.componentId}`;
|
||||
return (
|
||||
<SelectItem key={c.componentId || selectValue} value={selectValue}>
|
||||
{c.label}{c.cartType ? ` (${c.cartType})` : ""}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{sourceCardLists.map((c) => (
|
||||
<SelectItem key={c.componentId} value={`__comp_${c.componentId}`}>
|
||||
{c.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||
원본 화면의 카드 디자인과 장바구니 구분값이 자동으로 적용됩니다.
|
||||
원본 화면의 카드 디자인이 자동으로 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2329,6 +2260,60 @@ function LimitSettingsSection({
|
|||
);
|
||||
}
|
||||
|
||||
// ===== 행 식별 키 컬럼 선택 =====
|
||||
|
||||
function KeyColumnSelect({
|
||||
tableName,
|
||||
value,
|
||||
onValueChange,
|
||||
}: {
|
||||
tableName?: string;
|
||||
value: string;
|
||||
onValueChange: (v: string) => void;
|
||||
}) {
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableName) {
|
||||
fetchTableColumns(tableName).then(setColumns);
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}, [tableName]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const unique: ColumnInfo[] = [];
|
||||
const hasId = columns.some((c) => c.name === "id");
|
||||
if (!hasId) {
|
||||
unique.push({ name: "id", type: "uuid", udtName: "uuid" });
|
||||
seen.add("id");
|
||||
}
|
||||
for (const c of columns) {
|
||||
if (!seen.has(c.name)) {
|
||||
seen.add(c.name);
|
||||
unique.push(c);
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
}, [columns]);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||
<SelectValue placeholder="id" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((c) => (
|
||||
<SelectItem key={c.name} value={c.name} className="text-xs">
|
||||
{c.name === "id" ? "id (UUID, 기본)" : c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 담기 버튼 설정 섹션 =====
|
||||
|
||||
function CartActionSettingsSection({
|
||||
|
|
@ -2393,18 +2378,17 @@ function CartActionSettingsSection({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 장바구니 구분값 */}
|
||||
{/* 행 식별 키 컬럼 */}
|
||||
{saveMode === "cart" && (
|
||||
<div>
|
||||
<Label className="text-[10px]">장바구니 구분값</Label>
|
||||
<Input
|
||||
value={action.cartType || ""}
|
||||
onChange={(e) => update({ cartType: e.target.value })}
|
||||
placeholder="예: purchase_inbound"
|
||||
className="mt-1 h-7 text-xs"
|
||||
<Label className="text-[10px]">행 식별 키 컬럼</Label>
|
||||
<KeyColumnSelect
|
||||
tableName={tableName}
|
||||
value={action.keyColumn || "id"}
|
||||
onValueChange={(v) => update({ keyColumn: v })}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다.
|
||||
각 행을 고유하게 식별하는 컬럼입니다. 기본값: id (UUID)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -2606,3 +2590,517 @@ function ResponsiveDisplayRow({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 필터 기준 섹션 (columnGroups 기반) =====
|
||||
|
||||
const FILTER_OPERATORS: { value: FilterOperator; label: string }[] = [
|
||||
{ value: "=", label: "=" },
|
||||
{ value: "!=", label: "!=" },
|
||||
{ value: ">", label: ">" },
|
||||
{ value: "<", label: "<" },
|
||||
{ value: ">=", label: ">=" },
|
||||
{ value: "<=", label: "<=" },
|
||||
{ value: "like", label: "포함" },
|
||||
];
|
||||
|
||||
function FilterCriteriaSection({
|
||||
dataSource,
|
||||
columnGroups,
|
||||
onUpdate,
|
||||
}: {
|
||||
dataSource: CardListDataSource;
|
||||
columnGroups: ColumnGroup[];
|
||||
onUpdate: (partial: Partial<CardListDataSource>) => void;
|
||||
}) {
|
||||
const filters = dataSource.filters || [];
|
||||
|
||||
const addFilter = () => {
|
||||
const newFilter: CardColumnFilter = { column: "", operator: "=", value: "" };
|
||||
onUpdate({ filters: [...filters, newFilter] });
|
||||
};
|
||||
|
||||
const updateFilter = (index: number, updated: CardColumnFilter) => {
|
||||
const next = [...filters];
|
||||
next[index] = updated;
|
||||
onUpdate({ filters: next });
|
||||
};
|
||||
|
||||
const deleteFilter = (index: number) => {
|
||||
const next = filters.filter((_, i) => i !== index);
|
||||
onUpdate({ filters: next.length > 0 ? next : undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
데이터 조회 시 적용할 필터 조건입니다.
|
||||
</p>
|
||||
|
||||
{filters.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">필터 조건이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filters.map((filter, index) => (
|
||||
<div key={index} className="flex items-center gap-1 rounded-md border bg-card p-1.5">
|
||||
<div className="flex-1">
|
||||
<GroupedColumnSelect
|
||||
columnGroups={columnGroups}
|
||||
value={filter.column || undefined}
|
||||
onValueChange={(val) => updateFilter(index, { ...filter, column: val || "" })}
|
||||
placeholder="컬럼 선택"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filter.operator}
|
||||
onValueChange={(val) =>
|
||||
updateFilter(index, { ...filter, operator: val as FilterOperator })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={filter.value}
|
||||
onChange={(e) => updateFilter(index, { ...filter, value: e.target.value })}
|
||||
placeholder="값"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-destructive"
|
||||
onClick={() => deleteFilter(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" className="w-full text-xs" onClick={addFilter}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
필터 추가
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 저장 매핑 섹션 (장바구니 -> 대상 테이블) =====
|
||||
|
||||
const CART_META_FIELDS = [
|
||||
{ value: "__cart_quantity", label: "입력 수량" },
|
||||
{ value: "__cart_package_unit", label: "포장 단위" },
|
||||
{ value: "__cart_package_entries", label: "포장 내역" },
|
||||
{ value: "__cart_memo", label: "메모" },
|
||||
{ value: "__cart_row_key", label: "원본 키" },
|
||||
];
|
||||
|
||||
interface CardDisplayedField {
|
||||
sourceField: string;
|
||||
label: string;
|
||||
badge: string;
|
||||
}
|
||||
|
||||
function SaveMappingSection({
|
||||
saveMapping,
|
||||
onUpdate,
|
||||
cartListMode,
|
||||
}: {
|
||||
saveMapping?: CardListSaveMapping;
|
||||
onUpdate: (mapping: CardListSaveMapping) => void;
|
||||
cartListMode?: CartListModeConfig;
|
||||
}) {
|
||||
const mapping: CardListSaveMapping = saveMapping || { targetTable: "", mappings: [] };
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
|
||||
const [sourceColumns, setSourceColumns] = useState<ColumnInfo[]>([]);
|
||||
const [sourceTableName, setSourceTableName] = useState("");
|
||||
const [cardDisplayedFields, setCardDisplayedFields] = useState<CardDisplayedField[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTableList().then(setTables);
|
||||
}, []);
|
||||
|
||||
// 원본 화면에서 테이블 컬럼 + 카드 템플릿 필드 추출
|
||||
useEffect(() => {
|
||||
if (!cartListMode?.sourceScreenId) {
|
||||
setSourceColumns([]);
|
||||
setSourceTableName("");
|
||||
setCardDisplayedFields([]);
|
||||
return;
|
||||
}
|
||||
|
||||
screenApi
|
||||
.getLayoutPop(cartListMode.sourceScreenId)
|
||||
.then((layoutJson: any) => {
|
||||
const componentsMap = layoutJson?.components || {};
|
||||
const componentList = Object.values(componentsMap) as any[];
|
||||
|
||||
const matched = cartListMode.sourceComponentId
|
||||
? componentList.find((c: any) => c.id === cartListMode.sourceComponentId)
|
||||
: componentList.find((c: any) => c.type === "pop-card-list");
|
||||
|
||||
const tableName = matched?.config?.dataSource?.tableName;
|
||||
if (tableName) {
|
||||
setSourceTableName(tableName);
|
||||
fetchTableColumns(tableName).then(setSourceColumns);
|
||||
}
|
||||
|
||||
// 카드 템플릿에서 표시 중인 필드 추출
|
||||
const cardTemplate = matched?.config?.cardTemplate;
|
||||
const inputFieldConfig = matched?.config?.inputField;
|
||||
const packageConfig = matched?.config?.packageConfig;
|
||||
const displayed: CardDisplayedField[] = [];
|
||||
|
||||
if (cardTemplate?.header?.codeField) {
|
||||
displayed.push({
|
||||
sourceField: cardTemplate.header.codeField,
|
||||
label: cardTemplate.header.codeField,
|
||||
badge: "헤더",
|
||||
});
|
||||
}
|
||||
if (cardTemplate?.header?.titleField) {
|
||||
displayed.push({
|
||||
sourceField: cardTemplate.header.titleField,
|
||||
label: cardTemplate.header.titleField,
|
||||
badge: "헤더",
|
||||
});
|
||||
}
|
||||
for (const f of cardTemplate?.body?.fields || []) {
|
||||
if (f.valueType === "column" && f.columnName) {
|
||||
displayed.push({
|
||||
sourceField: f.columnName,
|
||||
label: f.label || f.columnName,
|
||||
badge: "본문",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (inputFieldConfig?.enabled) {
|
||||
displayed.push({
|
||||
sourceField: "__cart_quantity",
|
||||
label: "입력 수량",
|
||||
badge: "입력",
|
||||
});
|
||||
}
|
||||
if (packageConfig?.enabled) {
|
||||
displayed.push({
|
||||
sourceField: "__cart_package_unit",
|
||||
label: "포장 단위",
|
||||
badge: "포장",
|
||||
});
|
||||
displayed.push({
|
||||
sourceField: "__cart_package_entries",
|
||||
label: "포장 내역",
|
||||
badge: "포장",
|
||||
});
|
||||
}
|
||||
|
||||
setCardDisplayedFields(displayed);
|
||||
})
|
||||
.catch(() => {
|
||||
setSourceColumns([]);
|
||||
setSourceTableName("");
|
||||
setCardDisplayedFields([]);
|
||||
});
|
||||
}, [cartListMode?.sourceScreenId, cartListMode?.sourceComponentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mapping.targetTable) {
|
||||
fetchTableColumns(mapping.targetTable).then(setTargetColumns);
|
||||
} else {
|
||||
setTargetColumns([]);
|
||||
}
|
||||
}, [mapping.targetTable]);
|
||||
|
||||
// 카드에 표시된 필드 set (빠른 조회용)
|
||||
const cardFieldSet = useMemo(
|
||||
() => new Set(cardDisplayedFields.map((f) => f.sourceField)),
|
||||
[cardDisplayedFields]
|
||||
);
|
||||
|
||||
const getSourceFieldLabel = (field: string) => {
|
||||
const cardField = cardDisplayedFields.find((f) => f.sourceField === field);
|
||||
if (cardField) return cardField.label;
|
||||
const meta = CART_META_FIELDS.find((f) => f.value === field);
|
||||
if (meta) return meta.label;
|
||||
return field;
|
||||
};
|
||||
|
||||
const getFieldBadge = (field: string) => {
|
||||
const cardField = cardDisplayedFields.find((f) => f.sourceField === field);
|
||||
return cardField?.badge || null;
|
||||
};
|
||||
|
||||
const isCartMeta = (field: string) => field.startsWith("__cart_");
|
||||
|
||||
const getSourceTableDisplayName = () => {
|
||||
if (!sourceTableName) return "원본 데이터";
|
||||
const found = tables.find((t) => t.tableName === sourceTableName);
|
||||
return found?.displayName || sourceTableName;
|
||||
};
|
||||
|
||||
const mappedSourceFields = useMemo(
|
||||
() => new Set(mapping.mappings.map((m) => m.sourceField)),
|
||||
[mapping.mappings]
|
||||
);
|
||||
|
||||
// 카드에 표시된 필드 중 아직 매핑되지 않은 것
|
||||
const unmappedCardFields = useMemo(
|
||||
() => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)),
|
||||
[cardDisplayedFields, mappedSourceFields]
|
||||
);
|
||||
|
||||
// 카드에 없고 매핑도 안 된 원본 컬럼
|
||||
const availableExtraSourceFields = useMemo(
|
||||
() => sourceColumns.filter((col) => !cardFieldSet.has(col.name) && !mappedSourceFields.has(col.name)),
|
||||
[sourceColumns, cardFieldSet, mappedSourceFields]
|
||||
);
|
||||
|
||||
// 카드에 없고 매핑도 안 된 장바구니 메타
|
||||
const availableExtraCartFields = useMemo(
|
||||
() => CART_META_FIELDS.filter((f) => !cardFieldSet.has(f.value) && !mappedSourceFields.has(f.value)),
|
||||
[cardFieldSet, mappedSourceFields]
|
||||
);
|
||||
|
||||
// 대상 테이블 선택 -> 카드 표시 필드 전체 자동 매핑
|
||||
const updateTargetTable = (targetTable: string) => {
|
||||
fetchTableColumns(targetTable).then((targetCols) => {
|
||||
setTargetColumns(targetCols);
|
||||
|
||||
const targetNameSet = new Set(targetCols.map((c) => c.name));
|
||||
const autoMappings: CardListSaveMappingEntry[] = [];
|
||||
|
||||
for (const field of cardDisplayedFields) {
|
||||
autoMappings.push({
|
||||
sourceField: field.sourceField,
|
||||
targetColumn: targetNameSet.has(field.sourceField) ? field.sourceField : "",
|
||||
});
|
||||
}
|
||||
|
||||
onUpdate({ targetTable, mappings: autoMappings });
|
||||
});
|
||||
};
|
||||
|
||||
const addFieldMapping = (sourceField: string) => {
|
||||
const matched = targetColumns.find((tc) => tc.name === sourceField);
|
||||
onUpdate({
|
||||
...mapping,
|
||||
mappings: [
|
||||
...mapping.mappings,
|
||||
{ sourceField, targetColumn: matched?.name || "" },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const updateEntry = (index: number, updated: CardListSaveMappingEntry) => {
|
||||
const next = [...mapping.mappings];
|
||||
next[index] = updated;
|
||||
onUpdate({ ...mapping, mappings: next });
|
||||
};
|
||||
|
||||
const deleteEntry = (index: number) => {
|
||||
const next = mapping.mappings.filter((_, i) => i !== index);
|
||||
onUpdate({ ...mapping, mappings: next });
|
||||
};
|
||||
|
||||
const autoMatchedCount = mapping.mappings.filter((m) => m.targetColumn).length;
|
||||
|
||||
// 매핑 행 렌더링 (공용)
|
||||
const renderMappingRow = (entry: CardListSaveMappingEntry, index: number) => {
|
||||
const badge = getFieldBadge(entry.sourceField);
|
||||
return (
|
||||
<div
|
||||
key={`${entry.sourceField}-${index}`}
|
||||
className="flex items-center gap-1.5 rounded-md border bg-card px-2 py-1.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-xs font-medium">
|
||||
{getSourceFieldLabel(entry.sourceField)}
|
||||
</span>
|
||||
{badge && (
|
||||
<span className="shrink-0 rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isCartMeta(entry.sourceField) ? (
|
||||
!badge && <span className="text-[9px] text-muted-foreground">장바구니</span>
|
||||
) : (
|
||||
<span className="truncate text-[9px] text-muted-foreground">
|
||||
{entry.sourceField}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">→</span>
|
||||
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={entry.targetColumn || "__none__"}
|
||||
onValueChange={(val) =>
|
||||
updateEntry(index, {
|
||||
...entry,
|
||||
targetColumn: val === "__none__" ? "" : val,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="대상 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">매핑 안함</SelectItem>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0 text-destructive"
|
||||
onClick={() => deleteEntry(index)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 매핑 목록을 카드필드 / 추가필드로 분리
|
||||
const cardMappings: { entry: CardListSaveMappingEntry; index: number }[] = [];
|
||||
const extraMappings: { entry: CardListSaveMappingEntry; index: number }[] = [];
|
||||
mapping.mappings.forEach((entry, index) => {
|
||||
if (cardFieldSet.has(entry.sourceField)) {
|
||||
cardMappings.push({ entry, index });
|
||||
} else {
|
||||
extraMappings.push({ entry, index });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
대상 테이블을 선택하면 카드에 배치된 필드가 자동으로 매핑됩니다.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<Label className="text-[10px] text-muted-foreground">대상 테이블</Label>
|
||||
<TableCombobox
|
||||
tables={tables}
|
||||
value={mapping.targetTable}
|
||||
onSelect={updateTargetTable}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!mapping.targetTable ? (
|
||||
<div className="rounded-md border border-dashed bg-muted/30 p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">대상 테이블을 먼저 선택하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 자동 매핑 안내 */}
|
||||
{autoMatchedCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-primary/20 bg-primary/5 px-2.5 py-1.5">
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<span className="text-[10px] text-primary">
|
||||
이름 일치 {autoMatchedCount}개 필드 자동 매핑
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- 카드에 표시된 필드 --- */}
|
||||
{(cardMappings.length > 0 || unmappedCardFields.length > 0) && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||
카드에 표시된 필드
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{cardMappings.map(({ entry, index }) => renderMappingRow(entry, index))}
|
||||
|
||||
{/* 카드 필드 중 매핑 안 된 것 -> 칩으로 추가 */}
|
||||
{unmappedCardFields.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{unmappedCardFields.map((f) => (
|
||||
<button
|
||||
key={f.sourceField}
|
||||
type="button"
|
||||
onClick={() => addFieldMapping(f.sourceField)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-primary/30 bg-primary/5 px-2 py-1 text-[10px] text-primary transition-colors hover:bg-primary/10"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
{f.label}
|
||||
<span className="rounded bg-primary/10 px-0.5 text-[8px]">{f.badge}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- 추가로 저장할 필드 --- */}
|
||||
{(extraMappings.length > 0 || availableExtraSourceFields.length > 0 || availableExtraCartFields.length > 0) && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
||||
추가 저장 필드
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{extraMappings.map(({ entry, index }) => renderMappingRow(entry, index))}
|
||||
|
||||
{/* 추가 가능한 필드 칩 */}
|
||||
{(availableExtraSourceFields.length > 0 || availableExtraCartFields.length > 0) && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{availableExtraSourceFields.map((col) => (
|
||||
<button
|
||||
key={col.name}
|
||||
type="button"
|
||||
onClick={() => addFieldMapping(col.name)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-input px-2 py-1 text-[10px] transition-colors hover:bg-accent"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
{col.name}
|
||||
</button>
|
||||
))}
|
||||
{availableExtraCartFields.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
onClick={() => addFieldMapping(f.value)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-dashed border-input px-2 py-1 text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,15 +60,17 @@ PopComponentRegistry.registerComponent({
|
|||
defaultProps: defaultConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||
{ key: "selected_items", label: "선택된 항목", type: "value", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||
{ key: "selected_items", label: "선택된 항목", type: "value", category: "data", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
||||
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||
{ key: "confirm_trigger", label: "확정 트리거", type: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
|
||||
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export interface ColumnInfo {
|
|||
name: string;
|
||||
type: string;
|
||||
udtName: string;
|
||||
isPrimaryKey?: boolean;
|
||||
}
|
||||
|
||||
// ===== SQL 값 이스케이프 =====
|
||||
|
|
@ -328,6 +329,7 @@ export async function fetchTableColumns(
|
|||
name: col.columnName || col.column_name || col.name,
|
||||
type: col.dataType || col.data_type || col.type || "unknown",
|
||||
udtName: col.dbType || col.udt_name || col.udtName || "unknown",
|
||||
isPrimaryKey: col.isPrimaryKey === true || col.isPrimaryKey === "true" || col.is_primary_key === true || col.is_primary_key === "true",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
PopFieldReadSource,
|
||||
PopFieldAutoGenMapping,
|
||||
} from "./types";
|
||||
import type { CollectDataRequest, CollectedDataResponse } from "../types";
|
||||
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
|
||||
|
||||
// ========================================
|
||||
|
|
@ -191,6 +192,35 @@ export function PopFieldComponent({
|
|||
return unsub;
|
||||
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
|
||||
|
||||
// 데이터 수집 요청 수신: 버튼에서 collect_data 요청 → allValues + saveConfig 응답
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__collect_data`,
|
||||
(payload: unknown) => {
|
||||
const request = (payload as Record<string, unknown>)?.value as CollectDataRequest | undefined;
|
||||
|
||||
const response: CollectedDataResponse = {
|
||||
requestId: request?.requestId ?? "",
|
||||
componentId: componentId,
|
||||
componentType: "pop-field",
|
||||
data: { values: allValues },
|
||||
mapping: cfg.saveConfig?.tableName
|
||||
? {
|
||||
targetTable: cfg.saveConfig.tableName,
|
||||
columnMapping: Object.fromEntries(
|
||||
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
|
||||
),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
publish(`__comp_output__${componentId}__collected_data`, response);
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe, publish, allValues, cfg.saveConfig]);
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback(
|
||||
(fieldName: string, value: unknown) => {
|
||||
|
|
|
|||
|
|
@ -66,16 +66,32 @@ PopComponentRegistry.registerComponent({
|
|||
key: "value_changed",
|
||||
label: "값 변경",
|
||||
type: "value",
|
||||
category: "data",
|
||||
description: "필드값 변경 시 fieldName + value + allValues 전달",
|
||||
},
|
||||
{
|
||||
key: "collected_data",
|
||||
label: "수집 응답",
|
||||
type: "event",
|
||||
category: "event",
|
||||
description: "데이터 수집 요청에 대한 응답 (입력값 + 매핑)",
|
||||
},
|
||||
],
|
||||
receivable: [
|
||||
{
|
||||
key: "set_value",
|
||||
label: "값 설정",
|
||||
type: "value",
|
||||
category: "data",
|
||||
description: "외부에서 특정 필드 또는 일괄로 값 세팅",
|
||||
},
|
||||
{
|
||||
key: "collect_data",
|
||||
label: "수집 요청",
|
||||
type: "event",
|
||||
category: "event",
|
||||
description: "버튼에서 데이터+매핑 수집 요청 수신",
|
||||
},
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ PopComponentRegistry.registerComponent({
|
|||
defaultProps: DEFAULT_SEARCH_CONFIG,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
|
||||
{ key: "filter_value", label: "필터 값", type: "filter_value", category: "filter", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
||||
{ key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnInfo } from "../pop-dashboard/utils/dataFetcher";
|
||||
|
||||
interface ColumnComboboxProps {
|
||||
columns: ColumnInfo[];
|
||||
value: string;
|
||||
onSelect: (columnName: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function ColumnCombobox({
|
||||
columns,
|
||||
value,
|
||||
onSelect,
|
||||
placeholder = "컬럼을 선택하세요",
|
||||
}: ColumnComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return columns;
|
||||
const q = search.toLowerCase();
|
||||
return columns.filter((c) => c.name.toLowerCase().includes(q));
|
||||
}, [columns, search]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{value || placeholder}
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="컬럼명 검색..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-4 text-center text-xs">
|
||||
검색 결과가 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
onSelect={() => {
|
||||
onSelect(col.name);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3.5 w-3.5",
|
||||
value === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{col.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.type}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { TableInfo } from "../pop-dashboard/utils/dataFetcher";
|
||||
|
||||
interface TableComboboxProps {
|
||||
tables: TableInfo[];
|
||||
value: string;
|
||||
onSelect: (tableName: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function TableCombobox({
|
||||
tables,
|
||||
value,
|
||||
onSelect,
|
||||
placeholder = "테이블을 선택하세요",
|
||||
}: TableComboboxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const selectedLabel = useMemo(() => {
|
||||
const found = tables.find((t) => t.tableName === value);
|
||||
return found ? (found.displayName || found.tableName) : "";
|
||||
}, [tables, value]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return tables;
|
||||
const q = search.toLowerCase();
|
||||
return tables.filter(
|
||||
(t) =>
|
||||
t.tableName.toLowerCase().includes(q) ||
|
||||
(t.displayName && t.displayName.toLowerCase().includes(q))
|
||||
);
|
||||
}, [tables, search]);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{value ? selectedLabel : placeholder}
|
||||
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder="테이블명 또는 한글명 검색..."
|
||||
className="text-xs"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-4 text-center text-xs">
|
||||
검색 결과가 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filtered.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={() => {
|
||||
onSelect(table.tableName);
|
||||
setOpen(false);
|
||||
setSearch("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3.5 w-3.5",
|
||||
value === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName || table.tableName}</span>
|
||||
{table.displayName && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{table.tableName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
@ -35,10 +35,10 @@ PopComponentRegistry.registerComponent({
|
|||
defaultProps: defaultConfig,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" },
|
||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 행 데이터를 전달" },
|
||||
],
|
||||
receivable: [
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
|
||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
|
|
|
|||
|
|
@ -509,7 +509,7 @@ export type CartItemStatus = "in_cart" | "confirmed" | "cancelled";
|
|||
export interface CartItemWithId extends CartItem {
|
||||
cartId?: string; // DB id (UUID, 저장 후 할당)
|
||||
sourceTable: string; // 원본 테이블명
|
||||
rowKey: string; // 원본 행 식별키 (codeField 값)
|
||||
rowKey: string; // 원본 행 식별키 (keyColumn 값, 기본 id)
|
||||
status: CartItemStatus;
|
||||
_origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가
|
||||
memo?: string;
|
||||
|
|
@ -523,7 +523,7 @@ export type CartSaveMode = "cart" | "direct";
|
|||
|
||||
export interface CardCartActionConfig {
|
||||
saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
|
||||
cartType?: string; // 장바구니 구분값 (예: "purchase_inbound")
|
||||
keyColumn?: string; // 행 식별 키 컬럼 (기본: "id")
|
||||
label?: string; // 담기 라벨 (기본: "담기")
|
||||
cancelLabel?: string; // 취소 라벨 (기본: "취소")
|
||||
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
|
||||
|
|
@ -614,10 +614,80 @@ export interface CartListModeConfig {
|
|||
enabled: boolean;
|
||||
sourceScreenId?: number;
|
||||
sourceComponentId?: string;
|
||||
cartType?: string;
|
||||
statusFilter?: string;
|
||||
}
|
||||
|
||||
// ----- 데이터 수집 패턴 (pop-button ↔ 컴포넌트 간 요청-응답) -----
|
||||
|
||||
export interface CollectDataRequest {
|
||||
requestId: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface CollectedDataResponse {
|
||||
requestId: string;
|
||||
componentId: string;
|
||||
componentType: string;
|
||||
data: {
|
||||
items?: Record<string, unknown>[];
|
||||
values?: Record<string, unknown>;
|
||||
};
|
||||
mapping?: SaveMapping | null;
|
||||
}
|
||||
|
||||
export interface SaveMapping {
|
||||
targetTable: string;
|
||||
columnMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StatusChangeRule {
|
||||
targetTable: string;
|
||||
targetColumn: string;
|
||||
lookupMode?: "auto" | "manual";
|
||||
manualItemField?: string;
|
||||
manualPkColumn?: string;
|
||||
valueType: "fixed" | "conditional";
|
||||
fixedValue?: string;
|
||||
conditionalValue?: ConditionalValue;
|
||||
}
|
||||
|
||||
export interface ConditionalValue {
|
||||
conditions: StatusCondition[];
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export interface StatusCondition {
|
||||
whenColumn: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=";
|
||||
whenValue: string;
|
||||
thenValue: string;
|
||||
}
|
||||
|
||||
export interface ExecuteActionPayload {
|
||||
inserts: {
|
||||
table: string;
|
||||
records: Record<string, unknown>[];
|
||||
}[];
|
||||
statusChanges: {
|
||||
table: string;
|
||||
column: string;
|
||||
value: string;
|
||||
where: Record<string, unknown>;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ----- 저장 매핑 (장바구니 -> 대상 테이블) -----
|
||||
|
||||
export interface CardListSaveMappingEntry {
|
||||
sourceField: string;
|
||||
targetColumn: string;
|
||||
}
|
||||
|
||||
export interface CardListSaveMapping {
|
||||
targetTable: string;
|
||||
mappings: CardListSaveMappingEntry[];
|
||||
}
|
||||
|
||||
// ----- pop-card-list 전체 설정 -----
|
||||
|
||||
export interface PopCardListConfig {
|
||||
|
|
@ -637,4 +707,5 @@ export interface PopCardListConfig {
|
|||
cartAction?: CardCartActionConfig;
|
||||
|
||||
cartListMode?: CartListModeConfig;
|
||||
saveMapping?: CardListSaveMapping;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue