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": {
|
"mcpServers": {
|
||||||
"agent-orchestrator": {
|
|
||||||
"command": "node",
|
|
||||||
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
|
||||||
},
|
|
||||||
"Framelink Figma MCP": {
|
"Framelink Figma MCP": {
|
||||||
"command": "npx",
|
"command": "npx",
|
||||||
"args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"]
|
"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 entitySearchRoutes, { entityOptionsRouter } from "./routes/entitySearchRoutes"; // 엔티티 검색 및 옵션
|
||||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||||
|
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
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/table-management", entityJoinRoutes); // 🎯 Entity 조인 기능
|
||||||
app.use("/api/screen-management", screenManagementRoutes);
|
app.use("/api/screen-management", screenManagementRoutes);
|
||||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||||
|
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||||
app.use("/api/common-codes", commonCodeRoutes);
|
app.use("/api/common-codes", commonCodeRoutes);
|
||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
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;
|
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
|
// ConnectionEditor
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -75,6 +84,8 @@ export default function ConnectionEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFilterSource = hasFilterSendable(meta);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{hasSendable && (
|
{hasSendable && (
|
||||||
|
|
@ -83,6 +94,7 @@ export default function ConnectionEditor({
|
||||||
meta={meta!}
|
meta={meta!}
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
outgoing={outgoing}
|
outgoing={outgoing}
|
||||||
|
isFilterSource={isFilterSource}
|
||||||
onAddConnection={onAddConnection}
|
onAddConnection={onAddConnection}
|
||||||
onUpdateConnection={onUpdateConnection}
|
onUpdateConnection={onUpdateConnection}
|
||||||
onRemoveConnection={onRemoveConnection}
|
onRemoveConnection={onRemoveConnection}
|
||||||
|
|
@ -92,7 +104,6 @@ export default function ConnectionEditor({
|
||||||
{hasReceivable && (
|
{hasReceivable && (
|
||||||
<ReceiveSection
|
<ReceiveSection
|
||||||
component={component}
|
component={component}
|
||||||
meta={meta!}
|
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
incoming={incoming}
|
incoming={incoming}
|
||||||
/>
|
/>
|
||||||
|
|
@ -105,7 +116,6 @@ export default function ConnectionEditor({
|
||||||
// 대상 컴포넌트에서 정보 추출
|
// 대상 컴포넌트에서 정보 추출
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
/** 화면에 표시 중인 컬럼만 추출 */
|
|
||||||
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||||
if (!comp?.config) return [];
|
if (!comp?.config) return [];
|
||||||
const cfg = comp.config as Record<string, unknown>;
|
const cfg = comp.config as Record<string, unknown>;
|
||||||
|
|
@ -126,7 +136,6 @@ function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): stri
|
||||||
return cols;
|
return cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
|
|
||||||
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||||
if (!comp?.config) return "";
|
if (!comp?.config) return "";
|
||||||
const cfg = comp.config as Record<string, unknown>;
|
const cfg = comp.config as Record<string, unknown>;
|
||||||
|
|
@ -143,6 +152,7 @@ interface SendSectionProps {
|
||||||
meta: ComponentConnectionMeta;
|
meta: ComponentConnectionMeta;
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinitionV5[];
|
||||||
outgoing: PopDataConnection[];
|
outgoing: PopDataConnection[];
|
||||||
|
isFilterSource: boolean;
|
||||||
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
onAddConnection?: (conn: Omit<PopDataConnection, "id">) => void;
|
||||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
onRemoveConnection?: (connectionId: string) => void;
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
|
|
@ -153,6 +163,7 @@ function SendSection({
|
||||||
meta,
|
meta,
|
||||||
allComponents,
|
allComponents,
|
||||||
outgoing,
|
outgoing,
|
||||||
|
isFilterSource,
|
||||||
onAddConnection,
|
onAddConnection,
|
||||||
onUpdateConnection,
|
onUpdateConnection,
|
||||||
onRemoveConnection,
|
onRemoveConnection,
|
||||||
|
|
@ -163,14 +174,14 @@ function SendSection({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||||
<ArrowRight className="h-3 w-3 text-blue-500" />
|
<ArrowRight className="h-3 w-3 text-blue-500" />
|
||||||
이때 (보내기)
|
보내기
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
{/* 기존 연결 목록 */}
|
|
||||||
{outgoing.map((conn) => (
|
{outgoing.map((conn) => (
|
||||||
<div key={conn.id}>
|
<div key={conn.id}>
|
||||||
{editingId === conn.id ? (
|
{editingId === conn.id ? (
|
||||||
<ConnectionForm
|
isFilterSource ? (
|
||||||
|
<FilterConnectionForm
|
||||||
component={component}
|
component={component}
|
||||||
meta={meta}
|
meta={meta}
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
|
|
@ -182,10 +193,23 @@ function SendSection({
|
||||||
onCancel={() => setEditingId(null)}
|
onCancel={() => setEditingId(null)}
|
||||||
submitLabel="수정"
|
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">
|
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||||
<span className="flex-1 truncate text-xs">
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingId(conn.id)}
|
onClick={() => setEditingId(conn.id)}
|
||||||
|
|
@ -206,23 +230,131 @@ function SendSection({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 새 연결 추가 */}
|
{isFilterSource ? (
|
||||||
<ConnectionForm
|
<FilterConnectionForm
|
||||||
component={component}
|
component={component}
|
||||||
meta={meta}
|
meta={meta}
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
onSubmit={(data) => onAddConnection?.(data)}
|
onSubmit={(data) => onAddConnection?.(data)}
|
||||||
submitLabel="연결 추가"
|
submitLabel="연결 추가"
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<SimpleConnectionForm
|
||||||
|
component={component}
|
||||||
|
allComponents={allComponents}
|
||||||
|
onSubmit={(data) => onAddConnection?.(data)}
|
||||||
|
submitLabel="연결 추가"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</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;
|
component: PopComponentDefinitionV5;
|
||||||
meta: ComponentConnectionMeta;
|
meta: ComponentConnectionMeta;
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinitionV5[];
|
||||||
|
|
@ -232,7 +364,7 @@ interface ConnectionFormProps {
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionForm({
|
function FilterConnectionForm({
|
||||||
component,
|
component,
|
||||||
meta,
|
meta,
|
||||||
allComponents,
|
allComponents,
|
||||||
|
|
@ -240,7 +372,7 @@ function ConnectionForm({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
}: ConnectionFormProps) {
|
}: FilterConnectionFormProps) {
|
||||||
const [selectedOutput, setSelectedOutput] = React.useState(
|
const [selectedOutput, setSelectedOutput] = React.useState(
|
||||||
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
initial?.sourceOutput || meta.sendable[0]?.key || ""
|
||||||
);
|
);
|
||||||
|
|
@ -272,32 +404,26 @@ function ConnectionForm({
|
||||||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// 보내는 값 + 받는 컴포넌트가 결정되면 받는 방식 자동 매칭
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
if (!selectedOutput || !targetMeta?.receivable?.length) return;
|
||||||
// 이미 선택된 값이 있으면 건드리지 않음
|
|
||||||
if (selectedTargetInput) return;
|
if (selectedTargetInput) return;
|
||||||
|
|
||||||
const receivables = targetMeta.receivable;
|
const receivables = targetMeta.receivable;
|
||||||
// 1) 같은 key가 있으면 자동 매칭
|
|
||||||
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
const exactMatch = receivables.find((r) => r.key === selectedOutput);
|
||||||
if (exactMatch) {
|
if (exactMatch) {
|
||||||
setSelectedTargetInput(exactMatch.key);
|
setSelectedTargetInput(exactMatch.key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 2) receivable이 1개뿐이면 자동 선택
|
|
||||||
if (receivables.length === 1) {
|
if (receivables.length === 1) {
|
||||||
setSelectedTargetInput(receivables[0].key);
|
setSelectedTargetInput(receivables[0].key);
|
||||||
}
|
}
|
||||||
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
}, [selectedOutput, targetMeta, selectedTargetInput]);
|
||||||
|
|
||||||
// 화면에 표시 중인 컬럼
|
|
||||||
const displayColumns = React.useMemo(
|
const displayColumns = React.useMemo(
|
||||||
() => extractDisplayColumns(targetComp || undefined),
|
() => extractDisplayColumns(targetComp || undefined),
|
||||||
[targetComp]
|
[targetComp]
|
||||||
);
|
);
|
||||||
|
|
||||||
// DB 테이블 전체 컬럼 (비동기 조회)
|
|
||||||
const tableName = React.useMemo(
|
const tableName = React.useMemo(
|
||||||
() => extractTableName(targetComp || undefined),
|
() => extractTableName(targetComp || undefined),
|
||||||
[targetComp]
|
[targetComp]
|
||||||
|
|
@ -324,7 +450,6 @@ function ConnectionForm({
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [tableName]);
|
}, [tableName]);
|
||||||
|
|
||||||
// 표시 컬럼과 데이터 전용 컬럼 분리
|
|
||||||
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||||
const dataOnlyColumns = React.useMemo(
|
const dataOnlyColumns = React.useMemo(
|
||||||
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||||
|
|
@ -388,7 +513,6 @@ function ConnectionForm({
|
||||||
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
<p className="text-[10px] font-medium text-muted-foreground">새 연결 추가</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 보내는 값 */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
<span className="text-[10px] text-muted-foreground">보내는 값</span>
|
||||||
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
<Select value={selectedOutput} onValueChange={setSelectedOutput}>
|
||||||
|
|
@ -405,7 +529,6 @@ function ConnectionForm({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 받는 컴포넌트 */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
<span className="text-[10px] text-muted-foreground">받는 컴포넌트</span>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -429,7 +552,6 @@ function ConnectionForm({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 받는 방식 */}
|
|
||||||
{targetMeta && (
|
{targetMeta && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
<span className="text-[10px] text-muted-foreground">받는 방식</span>
|
||||||
|
|
@ -448,7 +570,6 @@ function ConnectionForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 필터 설정: event 타입 연결이면 숨김 */}
|
|
||||||
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
{selectedTargetInput && !isEventTypeConnection(meta, selectedOutput, targetMeta, selectedTargetInput) && (
|
||||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||||
|
|
@ -460,7 +581,6 @@ function ConnectionForm({
|
||||||
</div>
|
</div>
|
||||||
) : hasAnyColumns ? (
|
) : hasAnyColumns ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* 표시 컬럼 그룹 */}
|
|
||||||
{displayColumns.length > 0 && (
|
{displayColumns.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||||
|
|
@ -482,7 +602,6 @@ function ConnectionForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 데이터 전용 컬럼 그룹 */}
|
|
||||||
{dataOnlyColumns.length > 0 && (
|
{dataOnlyColumns.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{displayColumns.length > 0 && (
|
{displayColumns.length > 0 && (
|
||||||
|
|
@ -522,7 +641,6 @@ function ConnectionForm({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 필터 방식 */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
<p className="text-[10px] text-muted-foreground">필터 방식</p>
|
||||||
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
<Select value={filterMode} onValueChange={(v: any) => setFilterMode(v)}>
|
||||||
|
|
@ -540,7 +658,6 @@ function ConnectionForm({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 제출 버튼 */}
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -556,19 +673,17 @@ function ConnectionForm({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 받기 섹션 (읽기 전용)
|
// 받기 섹션 (읽기 전용: 연결된 소스만 표시)
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ReceiveSectionProps {
|
interface ReceiveSectionProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinitionV5;
|
||||||
meta: ComponentConnectionMeta;
|
|
||||||
allComponents: PopComponentDefinitionV5[];
|
allComponents: PopComponentDefinitionV5[];
|
||||||
incoming: PopDataConnection[];
|
incoming: PopDataConnection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReceiveSection({
|
function ReceiveSection({
|
||||||
component,
|
component,
|
||||||
meta,
|
|
||||||
allComponents,
|
allComponents,
|
||||||
incoming,
|
incoming,
|
||||||
}: ReceiveSectionProps) {
|
}: ReceiveSectionProps) {
|
||||||
|
|
@ -576,28 +691,11 @@ function ReceiveSection({
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="flex items-center gap-1 text-xs font-medium">
|
<Label className="flex items-center gap-1 text-xs font-medium">
|
||||||
<Unlink2 className="h-3 w-3 text-green-500" />
|
<Unlink2 className="h-3 w-3 text-green-500" />
|
||||||
이렇게 (받기)
|
받기
|
||||||
</Label>
|
</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 ? (
|
{incoming.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-1">
|
||||||
<p className="text-[10px] text-muted-foreground">연결된 소스</p>
|
|
||||||
{incoming.map((conn) => {
|
{incoming.map((conn) => {
|
||||||
const sourceComp = allComponents.find(
|
const sourceComp = allComponents.find(
|
||||||
(c) => c.id === conn.sourceComponent
|
(c) => c.id === conn.sourceComponent
|
||||||
|
|
@ -605,9 +703,9 @@ function ReceiveSection({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={conn.id}
|
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">
|
<span className="truncate">
|
||||||
{sourceComp?.label || conn.sourceComponent}
|
{sourceComp?.label || conn.sourceComponent}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -617,7 +715,7 @@ function ReceiveSection({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요.
|
연결된 소스가 없습니다
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -651,5 +749,5 @@ function buildConnectionLabel(
|
||||||
const colInfo = columns && columns.length > 0
|
const colInfo = columns && columns.length > 0
|
||||||
? ` [${columns.join(", ")}]`
|
? ` [${columns.join(", ")}]`
|
||||||
: "";
|
: "";
|
||||||
return `${srcLabel} -> ${tgtLabel}${colInfo}`;
|
return `${srcLabel} → ${tgtLabel}${colInfo}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,21 @@ export default function PopViewerWithModals({
|
||||||
() => layout.dataFlow?.connections ?? [],
|
() => layout.dataFlow?.connections ?? [],
|
||||||
[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({
|
useConnectionResolver({
|
||||||
screenId,
|
screenId,
|
||||||
connections: stableConnections,
|
connections: stableConnections,
|
||||||
|
componentTypes,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 모달 열기/닫기 이벤트 구독
|
// 모달 열기/닫기 이벤트 구독
|
||||||
|
|
|
||||||
|
|
@ -99,10 +99,8 @@ function dbRowToCartItem(dbRow: Record<string, unknown>): CartItemWithId {
|
||||||
function cartItemToDbRecord(
|
function cartItemToDbRecord(
|
||||||
item: CartItemWithId,
|
item: CartItemWithId,
|
||||||
screenId: string,
|
screenId: string,
|
||||||
cartType: string = "pop",
|
|
||||||
selectedColumns?: string[],
|
selectedColumns?: string[],
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
// selectedColumns가 있으면 해당 컬럼만 추출, 없으면 전체 저장
|
|
||||||
const rowData =
|
const rowData =
|
||||||
selectedColumns && selectedColumns.length > 0
|
selectedColumns && selectedColumns.length > 0
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
|
|
@ -111,7 +109,7 @@ function cartItemToDbRecord(
|
||||||
: item.row;
|
: item.row;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cart_type: cartType,
|
cart_type: "",
|
||||||
screen_id: screenId,
|
screen_id: screenId,
|
||||||
source_table: item.sourceTable,
|
source_table: item.sourceTable,
|
||||||
row_key: item.rowKey,
|
row_key: item.rowKey,
|
||||||
|
|
@ -144,7 +142,6 @@ function areItemsEqual(a: CartItemWithId[], b: CartItemWithId[]): boolean {
|
||||||
export function useCartSync(
|
export function useCartSync(
|
||||||
screenId: string,
|
screenId: string,
|
||||||
sourceTable: string,
|
sourceTable: string,
|
||||||
cartType?: string,
|
|
||||||
): UseCartSyncReturn {
|
): UseCartSyncReturn {
|
||||||
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
const [cartItems, setCartItems] = useState<CartItemWithId[]>([]);
|
||||||
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
const [savedItems, setSavedItems] = useState<CartItemWithId[]>([]);
|
||||||
|
|
@ -153,21 +150,18 @@ export function useCartSync(
|
||||||
|
|
||||||
const screenIdRef = useRef(screenId);
|
const screenIdRef = useRef(screenId);
|
||||||
const sourceTableRef = useRef(sourceTable);
|
const sourceTableRef = useRef(sourceTable);
|
||||||
const cartTypeRef = useRef(cartType || "pop");
|
|
||||||
screenIdRef.current = screenId;
|
screenIdRef.current = screenId;
|
||||||
sourceTableRef.current = sourceTable;
|
sourceTableRef.current = sourceTable;
|
||||||
cartTypeRef.current = cartType || "pop";
|
|
||||||
|
|
||||||
// ----- DB에서 장바구니 로드 -----
|
// ----- DB에서 장바구니 로드 -----
|
||||||
const loadFromDb = useCallback(async () => {
|
const loadFromDb = useCallback(async () => {
|
||||||
if (!screenId) return;
|
if (!screenId || !sourceTable) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await dataApi.getTableData("cart_items", {
|
const result = await dataApi.getTableData("cart_items", {
|
||||||
size: 500,
|
size: 500,
|
||||||
filters: {
|
filters: {
|
||||||
screen_id: screenId,
|
screen_id: screenId,
|
||||||
cart_type: cartTypeRef.current,
|
|
||||||
status: "in_cart",
|
status: "in_cart",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -181,7 +175,7 @@ export function useCartSync(
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId, sourceTable]);
|
||||||
|
|
||||||
// 마운트 시 자동 로드
|
// 마운트 시 자동 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -286,18 +280,16 @@ export function useCartSync(
|
||||||
const promises: Promise<unknown>[] = [];
|
const promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
for (const item of toDelete) {
|
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) {
|
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));
|
promises.push(dataApi.createRecord("cart_items", record));
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of toUpdate) {
|
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));
|
promises.push(dataApi.updateRecord("cart_items", item.cartId!, record));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,47 +8,103 @@
|
||||||
* 이벤트 규칙:
|
* 이벤트 규칙:
|
||||||
* 소스: __comp_output__${sourceComponentId}__${outputKey}
|
* 소스: __comp_output__${sourceComponentId}__${outputKey}
|
||||||
* 타겟: __comp_input__${targetComponentId}__${inputKey}
|
* 타겟: __comp_input__${targetComponentId}__${inputKey}
|
||||||
|
*
|
||||||
|
* _auto 모드:
|
||||||
|
* sourceOutput="_auto"인 연결은 소스/타겟의 connectionMeta를 비교하여
|
||||||
|
* key가 같고 category="event"인 쌍을 모두 자동 라우팅한다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { usePopEvent } from "./usePopEvent";
|
import { usePopEvent } from "./usePopEvent";
|
||||||
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout";
|
||||||
|
import {
|
||||||
|
PopComponentRegistry,
|
||||||
|
type ConnectionMetaItem,
|
||||||
|
} from "@/lib/registry/PopComponentRegistry";
|
||||||
|
|
||||||
interface UseConnectionResolverOptions {
|
interface UseConnectionResolverOptions {
|
||||||
screenId: string;
|
screenId: string;
|
||||||
connections: PopDataConnection[];
|
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({
|
export function useConnectionResolver({
|
||||||
screenId,
|
screenId,
|
||||||
connections,
|
connections,
|
||||||
|
componentTypes,
|
||||||
}: UseConnectionResolverOptions): void {
|
}: UseConnectionResolverOptions): void {
|
||||||
const { publish, subscribe } = usePopEvent(screenId);
|
const { publish, subscribe } = usePopEvent(screenId);
|
||||||
|
|
||||||
// 연결 목록을 ref로 저장하여 콜백 안정성 확보
|
|
||||||
const connectionsRef = useRef(connections);
|
const connectionsRef = useRef(connections);
|
||||||
connectionsRef.current = connections;
|
connectionsRef.current = connections;
|
||||||
|
|
||||||
|
const componentTypesRef = useRef(componentTypes);
|
||||||
|
componentTypesRef.current = componentTypes;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connections || connections.length === 0) return;
|
if (!connections || connections.length === 0) return;
|
||||||
|
|
||||||
const unsubscribers: (() => void)[] = [];
|
const unsubscribers: (() => void)[] = [];
|
||||||
|
|
||||||
// 소스별로 그룹핑하여 구독 생성
|
|
||||||
const sourceGroups = new Map<string, PopDataConnection[]>();
|
|
||||||
for (const conn of connections) {
|
for (const conn of connections) {
|
||||||
const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`;
|
const isAutoMode = conn.sourceOutput === "_auto" || !conn.sourceOutput;
|
||||||
const existing = sourceGroups.get(sourceEvent) || [];
|
|
||||||
existing.push(conn);
|
if (isAutoMode && componentTypesRef.current) {
|
||||||
sourceGroups.set(sourceEvent, existing);
|
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}`;
|
||||||
|
|
||||||
for (const [sourceEvent, conns] of sourceGroups) {
|
|
||||||
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
const unsub = subscribe(sourceEvent, (payload: unknown) => {
|
||||||
for (const conn of conns) {
|
|
||||||
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
||||||
|
|
||||||
// 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
|
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
value: payload,
|
value: payload,
|
||||||
filterConfig: conn.filterConfig,
|
filterConfig: conn.filterConfig,
|
||||||
|
|
@ -56,10 +112,10 @@ export function useConnectionResolver({
|
||||||
};
|
};
|
||||||
|
|
||||||
publish(targetEvent, enrichedPayload);
|
publish(targetEvent, enrichedPayload);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
unsubscribers.push(unsub);
|
unsubscribers.push(unsub);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const unsub of unsubscribers) {
|
for (const unsub of unsubscribers) {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface ConnectionMetaItem {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string;
|
type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string;
|
||||||
|
category?: "event" | "filter" | "data";
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,20 @@ import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
|
PackageCheck,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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: 타입 정의
|
// STEP 1: 타입 정의
|
||||||
|
|
@ -118,6 +129,7 @@ export type ButtonPreset =
|
||||||
| "menu"
|
| "menu"
|
||||||
| "modal-open"
|
| "modal-open"
|
||||||
| "cart"
|
| "cart"
|
||||||
|
| "inbound-confirm"
|
||||||
| "custom";
|
| "custom";
|
||||||
|
|
||||||
/** row_data 저장 모드 */
|
/** row_data 저장 모드 */
|
||||||
|
|
@ -141,6 +153,9 @@ export interface PopButtonConfig {
|
||||||
action: ButtonMainAction;
|
action: ButtonMainAction;
|
||||||
followUpActions?: FollowUpAction[];
|
followUpActions?: FollowUpAction[];
|
||||||
cart?: CartButtonConfig;
|
cart?: CartButtonConfig;
|
||||||
|
statusChangeRules?: StatusChangeRule[];
|
||||||
|
/** @deprecated inboundConfirm.statusChangeRules -> statusChangeRules로 이동 */
|
||||||
|
inboundConfirm?: { statusChangeRules?: StatusChangeRule[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -180,6 +195,7 @@ const PRESET_LABELS: Record<ButtonPreset, string> = {
|
||||||
menu: "메뉴 (드롭다운)",
|
menu: "메뉴 (드롭다운)",
|
||||||
"modal-open": "모달 열기",
|
"modal-open": "모달 열기",
|
||||||
cart: "장바구니 저장",
|
cart: "장바구니 저장",
|
||||||
|
"inbound-confirm": "입고 확정",
|
||||||
custom: "직접 설정",
|
custom: "직접 설정",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -270,6 +286,13 @@ const PRESET_DEFAULTS: Record<ButtonPreset, Partial<PopButtonConfig>> = {
|
||||||
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
|
confirm: { enabled: true, message: "장바구니에 담은 항목을 저장하시겠습니까?" },
|
||||||
action: { type: "event" },
|
action: { type: "event" },
|
||||||
},
|
},
|
||||||
|
"inbound-confirm": {
|
||||||
|
label: "입고 확정",
|
||||||
|
variant: "default",
|
||||||
|
icon: "PackageCheck",
|
||||||
|
confirm: { enabled: true, message: "선택한 품목을 입고 확정하시겠습니까?" },
|
||||||
|
action: { type: "event" },
|
||||||
|
},
|
||||||
custom: {
|
custom: {
|
||||||
label: "버튼",
|
label: "버튼",
|
||||||
variant: "default",
|
variant: "default",
|
||||||
|
|
@ -341,6 +364,7 @@ const LUCIDE_ICON_MAP: Record<string, LucideIcon> = {
|
||||||
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
|
PackageCheck,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Lucide 아이콘 동적 렌더링 */
|
/** Lucide 아이콘 동적 렌더링 */
|
||||||
|
|
@ -389,10 +413,13 @@ export function PopButtonComponent({
|
||||||
|
|
||||||
// 장바구니 모드 상태
|
// 장바구니 모드 상태
|
||||||
const isCartMode = config?.preset === "cart";
|
const isCartMode = config?.preset === "cart";
|
||||||
|
const isInboundConfirmMode = config?.preset === "inbound-confirm";
|
||||||
const [cartCount, setCartCount] = useState(0);
|
const [cartCount, setCartCount] = useState(0);
|
||||||
const [cartIsDirty, setCartIsDirty] = useState(false);
|
const [cartIsDirty, setCartIsDirty] = useState(false);
|
||||||
const [cartSaving, setCartSaving] = useState(false);
|
const [cartSaving, setCartSaving] = useState(false);
|
||||||
const [showCartConfirm, setShowCartConfirm] = useState(false);
|
const [showCartConfirm, setShowCartConfirm] = useState(false);
|
||||||
|
const [confirmProcessing, setConfirmProcessing] = useState(false);
|
||||||
|
const [showInboundConfirm, setShowInboundConfirm] = useState(false);
|
||||||
|
|
||||||
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
|
// 장바구니 상태 수신 (카드 목록에서 count/isDirty 전달)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -474,12 +501,99 @@ export function PopButtonComponent({
|
||||||
}
|
}
|
||||||
}, [cartSaving]);
|
}, [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 () => {
|
const handleClick = useCallback(async () => {
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
toast.info(
|
const modeLabel = isCartMode ? "장바구니 저장" : isInboundConfirmMode ? "입고 확정" : ACTION_TYPE_LABELS[config?.action?.type || "save"];
|
||||||
`[디자인 모드] ${isCartMode ? "장바구니 저장" : 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -513,7 +627,7 @@ export function PopButtonComponent({
|
||||||
confirm: config?.confirm,
|
confirm: config?.confirm,
|
||||||
followUpActions: config?.followUpActions,
|
followUpActions: config?.followUpActions,
|
||||||
});
|
});
|
||||||
}, [isDesignMode, isCartMode, config, cartCount, cartIsDirty, execute, handleCartSave]);
|
}, [isDesignMode, isCartMode, isInboundConfirmMode, config, cartCount, cartIsDirty, execute, handleCartSave, handleInboundConfirm]);
|
||||||
|
|
||||||
// 외형
|
// 외형
|
||||||
const buttonLabel = config?.label || label || "버튼";
|
const buttonLabel = config?.label || label || "버튼";
|
||||||
|
|
@ -548,7 +662,7 @@ export function PopButtonComponent({
|
||||||
<Button
|
<Button
|
||||||
variant={variant}
|
variant={variant}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={isLoading || cartSaving}
|
disabled={isLoading || cartSaving || confirmProcessing}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-transform active:scale-95",
|
"transition-transform active:scale-95",
|
||||||
isIconOnly && "px-2",
|
isIconOnly && "px-2",
|
||||||
|
|
@ -610,6 +724,35 @@ export function PopButtonComponent({
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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(); }}>
|
<AlertDialog open={!!pendingConfirm} onOpenChange={(open) => { if (!open) cancelConfirm(); }}>
|
||||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
<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">
|
<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>
|
<p className="mb-1 text-[10px] font-medium text-muted-foreground">자동 설정</p>
|
||||||
<CartMappingRow source="현재 화면 ID" target="screen_id" auto />
|
<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='상태 ("in_cart")' target="status" auto />
|
||||||
<CartMappingRow source="회사 코드" target="company_code" auto />
|
<CartMappingRow source="회사 코드" target="company_code" auto />
|
||||||
<CartMappingRow source="사용자 ID" target="user_id" auto />
|
<CartMappingRow source="사용자 ID" target="user_id" auto />
|
||||||
|
|
@ -1130,6 +1273,17 @@ export function PopButtonConfigPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 변경 규칙 (cart 프리셋 제외 모두 표시) */}
|
||||||
|
{config?.preset !== "cart" && (
|
||||||
|
<>
|
||||||
|
<SectionDivider label="상태 변경 규칙" />
|
||||||
|
<StatusChangeRuleEditor
|
||||||
|
rules={config?.statusChangeRules ?? config?.inboundConfirm?.statusChangeRules ?? []}
|
||||||
|
onUpdate={(rules) => onUpdate({ ...config, statusChangeRules: rules })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 후속 액션 */}
|
{/* 후속 액션 */}
|
||||||
<SectionDivider label="후속 액션" />
|
<SectionDivider label="후속 액션" />
|
||||||
<FollowUpActionsEditor
|
<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({
|
PopComponentRegistry.registerComponent({
|
||||||
id: "pop-button",
|
id: "pop-button",
|
||||||
|
|
@ -1486,11 +1964,14 @@ PopComponentRegistry.registerComponent({
|
||||||
} as PopButtonConfig,
|
} as PopButtonConfig,
|
||||||
connectionMeta: {
|
connectionMeta: {
|
||||||
sendable: [
|
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: [
|
receivable: [
|
||||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
|
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "카드 목록에서 장바구니 변경 시 count/isDirty 수신" },
|
||||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
|
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "카드 목록에서 DB 저장 완료 시 수신" },
|
||||||
|
{ key: "collected_data", label: "수집된 데이터", type: "event", category: "event", description: "컴포넌트에서 수집한 데이터+매핑 응답" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
touchOptimized: true,
|
touchOptimized: true,
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ import type {
|
||||||
CardPresetSpec,
|
CardPresetSpec,
|
||||||
CartItem,
|
CartItem,
|
||||||
PackageEntry,
|
PackageEntry,
|
||||||
CartListModeConfig,
|
CollectDataRequest,
|
||||||
|
CollectedDataResponse,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CARD_IMAGE,
|
DEFAULT_CARD_IMAGE,
|
||||||
|
|
@ -183,27 +184,34 @@ export function PopCardListComponent({
|
||||||
currentColSpan,
|
currentColSpan,
|
||||||
onRequestResize,
|
onRequestResize,
|
||||||
}: PopCardListComponentProps) {
|
}: 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 { subscribe, publish } = usePopEvent(screenId || "default");
|
||||||
const router = useRouter();
|
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 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 [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 장바구니 목록 모드에서는 원본 화면의 템플릿을 사용, 폴백으로 자체 설정
|
// 장바구니 목록 모드: 원본 화면의 설정 전체를 병합 (cardTemplate, inputField, packageConfig, cardSize 등)
|
||||||
const effectiveTemplate = (isCartListMode && inheritedTemplate) ? inheritedTemplate : template;
|
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[]>([]);
|
const [rows, setRows] = useState<RowData[]>([]);
|
||||||
|
|
@ -311,7 +319,7 @@ export function PopCardListComponent({
|
||||||
const missingImageCountRef = useRef(0);
|
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;
|
const spec: CardPresetSpec = CARD_PRESET_SPECS[cardSizeKey] || CARD_PRESET_SPECS.large;
|
||||||
|
|
||||||
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
|
// 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열
|
||||||
|
|
@ -509,36 +517,26 @@ export function PopCardListComponent({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
// 원본 화면 레이아웃에서 cardTemplate 상속
|
// 원본 화면 레이아웃에서 설정 전체 상속 (cardTemplate, inputField, packageConfig, cardSize 등)
|
||||||
if (cartListMode.sourceScreenId) {
|
|
||||||
try {
|
try {
|
||||||
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId);
|
const layoutJson = await screenApi.getLayoutPop(cartListMode.sourceScreenId);
|
||||||
const componentsMap = layoutJson?.components || {};
|
const componentsMap = layoutJson?.components || {};
|
||||||
const componentList = Object.values(componentsMap) as any[];
|
const componentList = Object.values(componentsMap) as any[];
|
||||||
// sourceComponentId > cartType > 첫 번째 pop-card-list 순으로 매칭
|
|
||||||
const matched = cartListMode.sourceComponentId
|
const matched = cartListMode.sourceComponentId
|
||||||
? componentList.find((c: any) => c.id === 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");
|
: componentList.find((c: any) => c.type === "pop-card-list");
|
||||||
if (matched?.config?.cardTemplate) {
|
if (matched?.config) {
|
||||||
setInheritedTemplate(matched.config.cardTemplate);
|
setInheritedConfig(matched.config);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 레이아웃 로드 실패 시 config.cardTemplate 폴백
|
// 레이아웃 로드 실패 시 자체 config 폴백
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// cart_items 조회 (cartType이 있으면 필터, 없으면 전체)
|
|
||||||
const cartFilters: Record<string, unknown> = {
|
const cartFilters: Record<string, unknown> = {
|
||||||
status: cartListMode.statusFilter || "in_cart",
|
status: cartListMode.statusFilter || "in_cart",
|
||||||
};
|
};
|
||||||
if (cartListMode.cartType) {
|
if (cartListMode.sourceScreenId) {
|
||||||
cartFilters.cart_type = cartListMode.cartType;
|
cartFilters.screen_id = String(cartListMode.sourceScreenId);
|
||||||
}
|
}
|
||||||
const result = await dataApi.getTableData("cart_items", {
|
const result = await dataApi.getTableData("cart_items", {
|
||||||
size: 500,
|
size: 500,
|
||||||
|
|
@ -572,10 +570,11 @@ export function PopCardListComponent({
|
||||||
missingImageCountRef.current = 0;
|
missingImageCountRef.current = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 서버에는 = 연산자 필터만 전달, 나머지는 클라이언트 후처리
|
||||||
const filters: Record<string, unknown> = {};
|
const filters: Record<string, unknown> = {};
|
||||||
if (dataSource.filters && dataSource.filters.length > 0) {
|
if (dataSource.filters && dataSource.filters.length > 0) {
|
||||||
dataSource.filters.forEach((f) => {
|
dataSource.filters.forEach((f) => {
|
||||||
if (f.column && f.value) {
|
if (f.column && f.value && (!f.operator || f.operator === "=")) {
|
||||||
filters[f.column] = f.value;
|
filters[f.column] = f.value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -604,7 +603,31 @@ export function PopCardListComponent({
|
||||||
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
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) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "데이터 조회 실패";
|
const message = err instanceof Error ? err.message : "데이터 조회 실패";
|
||||||
setError(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(() => {
|
useEffect(() => {
|
||||||
if (!componentId || !isCartListMode) return;
|
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);
|
publish(`__comp_output__${componentId}__selected_items`, selectedItems);
|
||||||
}, [selectedKeys, filteredRows, componentId, isCartListMode, publish]);
|
}, [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">
|
<div className="flex shrink-0 items-center gap-3 border-b px-3 py-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selectedKeys.size === displayCards.length && displayCards.length > 0}
|
checked={selectedKeys.size === filteredRows.length && filteredRows.length > 0}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (e.target.checked) {
|
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 {
|
} else {
|
||||||
setSelectedKeys(new Set());
|
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">
|
<span className="text-sm text-muted-foreground">
|
||||||
{selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"}
|
{selectedKeys.size > 0 ? `${selectedKeys.size}개 선택됨` : "전체 선택"}
|
||||||
|
|
@ -757,19 +819,20 @@ export function PopCardListComponent({
|
||||||
row={row}
|
row={row}
|
||||||
template={effectiveTemplate}
|
template={effectiveTemplate}
|
||||||
scaled={scaled}
|
scaled={scaled}
|
||||||
inputField={config?.inputField}
|
inputField={effectiveConfig?.inputField}
|
||||||
packageConfig={config?.packageConfig}
|
packageConfig={effectiveConfig?.packageConfig}
|
||||||
cartAction={config?.cartAction}
|
cartAction={effectiveConfig?.cartAction}
|
||||||
publish={publish}
|
publish={publish}
|
||||||
router={router}
|
router={router}
|
||||||
onSelect={handleCardSelect}
|
onSelect={handleCardSelect}
|
||||||
cart={cart}
|
cart={cart}
|
||||||
codeFieldName={effectiveTemplate?.header?.codeField}
|
keyColumnName={effectiveConfig?.cartAction?.keyColumn || "id"}
|
||||||
parentComponentId={componentId}
|
parentComponentId={componentId}
|
||||||
isCartListMode={isCartListMode}
|
isCartListMode={isCartListMode}
|
||||||
isSelected={selectedKeys.has(String(row.__cart_id))}
|
isSelected={selectedKeys.has(String(row.__cart_id ?? ""))}
|
||||||
onToggleSelect={() => {
|
onToggleSelect={() => {
|
||||||
const cartId = String(row.__cart_id);
|
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
|
||||||
|
if (!cartId) return;
|
||||||
setSelectedKeys(prev => {
|
setSelectedKeys(prev => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(cartId)) next.delete(cartId);
|
if (next.has(cartId)) next.delete(cartId);
|
||||||
|
|
@ -859,7 +922,7 @@ function Card({
|
||||||
router,
|
router,
|
||||||
onSelect,
|
onSelect,
|
||||||
cart,
|
cart,
|
||||||
codeFieldName,
|
keyColumnName,
|
||||||
parentComponentId,
|
parentComponentId,
|
||||||
isCartListMode,
|
isCartListMode,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
|
@ -877,7 +940,7 @@ function Card({
|
||||||
router: ReturnType<typeof useRouter>;
|
router: ReturnType<typeof useRouter>;
|
||||||
onSelect?: (row: RowData) => void;
|
onSelect?: (row: RowData) => void;
|
||||||
cart: ReturnType<typeof useCartSync>;
|
cart: ReturnType<typeof useCartSync>;
|
||||||
codeFieldName?: string;
|
keyColumnName?: string;
|
||||||
parentComponentId?: string;
|
parentComponentId?: string;
|
||||||
isCartListMode?: boolean;
|
isCartListMode?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
|
@ -897,8 +960,7 @@ function Card({
|
||||||
const codeValue = header?.codeField ? row[header.codeField] : null;
|
const codeValue = header?.codeField ? row[header.codeField] : null;
|
||||||
const titleValue = header?.titleField ? row[header.titleField] : null;
|
const titleValue = header?.titleField ? row[header.titleField] : null;
|
||||||
|
|
||||||
// 장바구니 상태: codeField 값을 rowKey로 사용
|
const rowKey = keyColumnName && row[keyColumnName] ? String(row[keyColumnName]) : "";
|
||||||
const rowKey = codeFieldName && row[codeFieldName] ? String(row[codeFieldName]) : "";
|
|
||||||
const isCarted = cart.isItemInCart(rowKey);
|
const isCarted = cart.isItemInCart(rowKey);
|
||||||
const existingCartItem = cart.getCartItem(rowKey);
|
const existingCartItem = cart.getCartItem(rowKey);
|
||||||
|
|
||||||
|
|
@ -1012,14 +1074,14 @@ function Card({
|
||||||
// 장바구니 목록 모드: 개별 삭제
|
// 장바구니 목록 모드: 개별 삭제
|
||||||
const handleCartDelete = async (e: React.MouseEvent) => {
|
const handleCartDelete = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const cartId = String(row.__cart_id);
|
const cartId = row.__cart_id != null ? String(row.__cart_id) : "";
|
||||||
if (!cartId) return;
|
if (!cartId) return;
|
||||||
|
|
||||||
const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?");
|
const ok = window.confirm("이 항목을 장바구니에서 삭제하시겠습니까?");
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await dataApi.deleteRecord("cart_items", cartId);
|
await dataApi.updateRecord("cart_items", cartId, { status: "cancelled" });
|
||||||
onDeleteItem?.(cartId);
|
onDeleteItem?.(cartId);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("삭제에 실패했습니다.");
|
toast.error("삭제에 실패했습니다.");
|
||||||
|
|
@ -1058,21 +1120,19 @@ function Card({
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") handleCardClick(); }}
|
||||||
>
|
>
|
||||||
{/* 장바구니 목록 모드: 체크박스 */}
|
{/* 헤더 영역 */}
|
||||||
|
{(codeValue !== null || titleValue !== null || isCartListMode) && (
|
||||||
|
<div className={`border-b ${headerBgClass}`} style={headerStyle}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{isCartListMode && (
|
{isCartListMode && (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={(e) => { e.stopPropagation(); onToggleSelect?.(); }}
|
onChange={(e) => { e.stopPropagation(); onToggleSelect?.(); }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute left-2 top-2 z-10 h-4 w-4 rounded border-gray-300"
|
className="h-4 w-4 shrink-0 rounded border-input"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 헤더 영역 */}
|
|
||||||
{(codeValue !== null || titleValue !== null) && (
|
|
||||||
<div className={`border-b ${headerBgClass}`} style={headerStyle}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{codeValue !== null && (
|
{codeValue !== null && (
|
||||||
<span
|
<span
|
||||||
className="shrink-0 font-medium text-muted-foreground"
|
className="shrink-0 font-medium text-muted-foreground"
|
||||||
|
|
@ -1144,7 +1204,7 @@ function Card({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleInputClick}
|
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">
|
<span className="block text-lg font-bold leading-tight">
|
||||||
{inputValue.toLocaleString()}
|
{inputValue.toLocaleString()}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
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 type { GridMode } from "@/components/pop/designer/types/pop-layout";
|
||||||
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
|
import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -25,8 +25,6 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
PopCardListConfig,
|
PopCardListConfig,
|
||||||
|
|
@ -50,6 +48,8 @@ import type {
|
||||||
CardResponsiveConfig,
|
CardResponsiveConfig,
|
||||||
ResponsiveDisplayMode,
|
ResponsiveDisplayMode,
|
||||||
CartListModeConfig,
|
CartListModeConfig,
|
||||||
|
CardListSaveMapping,
|
||||||
|
CardListSaveMappingEntry,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import {
|
import {
|
||||||
|
|
@ -63,6 +63,7 @@ import {
|
||||||
type TableInfo,
|
type TableInfo,
|
||||||
type ColumnInfo,
|
type ColumnInfo,
|
||||||
} from "../pop-dashboard/utils/dataFetcher";
|
} from "../pop-dashboard/utils/dataFetcher";
|
||||||
|
import { TableCombobox } from "../pop-shared/TableCombobox";
|
||||||
|
|
||||||
// ===== 테이블별 그룹화된 컬럼 =====
|
// ===== 테이블별 그룹화된 컬럼 =====
|
||||||
|
|
||||||
|
|
@ -399,6 +400,42 @@ function BasicSettingsTab({
|
||||||
</CollapsibleSection>
|
</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>
|
<CollapsibleSection title="레이아웃 설정" defaultOpen>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -667,99 +704,7 @@ function CardTemplateTab({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 테이블 검색 Combobox =====
|
// TableCombobox: pop-shared/TableCombobox.tsx에서 import
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 테이블별 그룹화된 컬럼 셀렉트 =====
|
// ===== 테이블별 그룹화된 컬럼 셀렉트 =====
|
||||||
|
|
||||||
|
|
@ -867,7 +812,6 @@ function CollapsibleSection({
|
||||||
interface SourceCardListInfo {
|
interface SourceCardListInfo {
|
||||||
componentId: string;
|
componentId: string;
|
||||||
label: string;
|
label: string;
|
||||||
cartType: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CartListModeSection({
|
function CartListModeSection({
|
||||||
|
|
@ -915,8 +859,7 @@ function CartListModeSection({
|
||||||
.filter((c: any) => c.type === "pop-card-list")
|
.filter((c: any) => c.type === "pop-card-list")
|
||||||
.map((c: any) => ({
|
.map((c: any) => ({
|
||||||
componentId: c.id || "",
|
componentId: c.id || "",
|
||||||
label: c.label || c.config?.cartAction?.cartType || "카드 목록",
|
label: c.label || "카드 목록",
|
||||||
cartType: c.config?.cartAction?.cartType || "",
|
|
||||||
}));
|
}));
|
||||||
setSourceCardLists(cardLists);
|
setSourceCardLists(cardLists);
|
||||||
})
|
})
|
||||||
|
|
@ -928,23 +871,18 @@ function CartListModeSection({
|
||||||
|
|
||||||
const handleScreenChange = (val: string) => {
|
const handleScreenChange = (val: string) => {
|
||||||
const screenId = val === "__none__" ? undefined : Number(val);
|
const screenId = val === "__none__" ? undefined : Number(val);
|
||||||
onUpdate({ ...mode, sourceScreenId: screenId, cartType: undefined });
|
onUpdate({ ...mode, sourceScreenId: screenId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComponentSelect = (val: string) => {
|
const handleComponentSelect = (val: string) => {
|
||||||
if (val === "__none__") {
|
if (val === "__none__") {
|
||||||
onUpdate({ ...mode, cartType: undefined, sourceComponentId: undefined });
|
onUpdate({ ...mode, sourceComponentId: undefined });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const found = val.startsWith("__comp_")
|
const compId = val.startsWith("__comp_") ? val.replace("__comp_", "") : val;
|
||||||
? sourceCardLists.find((c) => c.componentId === val.replace("__comp_", ""))
|
const found = sourceCardLists.find((c) => c.componentId === compId);
|
||||||
: sourceCardLists.find((c) => c.cartType === val);
|
|
||||||
if (found) {
|
if (found) {
|
||||||
onUpdate({
|
onUpdate({ ...mode, sourceComponentId: found.componentId });
|
||||||
...mode,
|
|
||||||
sourceComponentId: found.componentId,
|
|
||||||
cartType: found.cartType || undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1000,11 +938,7 @@ function CartListModeSection({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={
|
value={mode.sourceComponentId ? `__comp_${mode.sourceComponentId}` : "__none__"}
|
||||||
mode.sourceComponentId
|
|
||||||
? (sourceCardLists.find(c => c.componentId === mode.sourceComponentId)?.cartType || `__comp_${mode.sourceComponentId}`)
|
|
||||||
: mode.cartType || "__none__"
|
|
||||||
}
|
|
||||||
onValueChange={handleComponentSelect}
|
onValueChange={handleComponentSelect}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="mt-1 h-7 text-xs">
|
<SelectTrigger className="mt-1 h-7 text-xs">
|
||||||
|
|
@ -1012,19 +946,16 @@ function CartListModeSection({
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__none__">선택 안함</SelectItem>
|
<SelectItem value="__none__">선택 안함</SelectItem>
|
||||||
{sourceCardLists.map((c) => {
|
{sourceCardLists.map((c) => (
|
||||||
const selectValue = c.cartType || `__comp_${c.componentId}`;
|
<SelectItem key={c.componentId} value={`__comp_${c.componentId}`}>
|
||||||
return (
|
{c.label}
|
||||||
<SelectItem key={c.componentId || selectValue} value={selectValue}>
|
|
||||||
{c.label}{c.cartType ? ` (${c.cartType})` : ""}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-[9px] text-muted-foreground">
|
<p className="mt-1 text-[9px] text-muted-foreground">
|
||||||
원본 화면의 카드 디자인과 장바구니 구분값이 자동으로 적용됩니다.
|
원본 화면의 카드 디자인이 자동으로 적용됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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({
|
function CartActionSettingsSection({
|
||||||
|
|
@ -2393,18 +2378,17 @@ function CartActionSettingsSection({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 장바구니 구분값 */}
|
{/* 행 식별 키 컬럼 */}
|
||||||
{saveMode === "cart" && (
|
{saveMode === "cart" && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px]">장바구니 구분값</Label>
|
<Label className="text-[10px]">행 식별 키 컬럼</Label>
|
||||||
<Input
|
<KeyColumnSelect
|
||||||
value={action.cartType || ""}
|
tableName={tableName}
|
||||||
onChange={(e) => update({ cartType: e.target.value })}
|
value={action.keyColumn || "id"}
|
||||||
placeholder="예: purchase_inbound"
|
onValueChange={(v) => update({ keyColumn: v })}
|
||||||
className="mt-1 h-7 text-xs"
|
|
||||||
/>
|
/>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||||
장바구니 화면에서 이 값으로 필터링하여 해당 품목만 표시합니다.
|
각 행을 고유하게 식별하는 컬럼입니다. 기본값: id (UUID)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -2606,3 +2590,517 @@ function ResponsiveDisplayRow({
|
||||||
</div>
|
</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,
|
defaultProps: defaultConfig,
|
||||||
connectionMeta: {
|
connectionMeta: {
|
||||||
sendable: [
|
sendable: [
|
||||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
|
||||||
{ key: "cart_updated", label: "장바구니 상태", type: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
|
||||||
{ key: "cart_save_completed", label: "저장 완료", type: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
|
||||||
{ key: "selected_items", label: "선택된 항목", type: "value", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
{ key: "selected_items", label: "선택된 항목", type: "value", category: "data", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },
|
||||||
|
{ key: "collected_data", label: "수집 응답", type: "event", category: "event", description: "데이터 수집 요청에 대한 응답 (선택 항목 + 매핑)" },
|
||||||
],
|
],
|
||||||
receivable: [
|
receivable: [
|
||||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 카드 목록 필터링" },
|
||||||
{ key: "cart_save_trigger", label: "저장 요청", type: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
{ key: "cart_save_trigger", label: "저장 요청", type: "event", category: "event", description: "장바구니 DB 일괄 저장 트리거 (버튼에서 수신)" },
|
||||||
{ key: "confirm_trigger", label: "확정 트리거", type: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
|
{ key: "confirm_trigger", label: "확정 트리거", type: "event", category: "event", description: "입고 확정 시 수정된 수량 일괄 반영 + 선택 항목 전달" },
|
||||||
|
{ key: "collect_data", label: "수집 요청", type: "event", category: "event", description: "버튼에서 데이터+매핑 수집 요청 수신" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
touchOptimized: true,
|
touchOptimized: true,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export interface ColumnInfo {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
udtName: string;
|
udtName: string;
|
||||||
|
isPrimaryKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== SQL 값 이스케이프 =====
|
// ===== SQL 값 이스케이프 =====
|
||||||
|
|
@ -328,6 +329,7 @@ export async function fetchTableColumns(
|
||||||
name: col.columnName || col.column_name || col.name,
|
name: col.columnName || col.column_name || col.name,
|
||||||
type: col.dataType || col.data_type || col.type || "unknown",
|
type: col.dataType || col.data_type || col.type || "unknown",
|
||||||
udtName: col.dbType || col.udt_name || col.udtName || "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,
|
PopFieldReadSource,
|
||||||
PopFieldAutoGenMapping,
|
PopFieldAutoGenMapping,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
import type { CollectDataRequest, CollectedDataResponse } from "../types";
|
||||||
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
|
import { DEFAULT_FIELD_CONFIG, DEFAULT_SECTION_APPEARANCES } from "./types";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -191,6 +192,35 @@ export function PopFieldComponent({
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe, cfg.readSource, fetchReadSourceData]);
|
}, [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(
|
const handleFieldChange = useCallback(
|
||||||
(fieldName: string, value: unknown) => {
|
(fieldName: string, value: unknown) => {
|
||||||
|
|
|
||||||
|
|
@ -66,16 +66,32 @@ PopComponentRegistry.registerComponent({
|
||||||
key: "value_changed",
|
key: "value_changed",
|
||||||
label: "값 변경",
|
label: "값 변경",
|
||||||
type: "value",
|
type: "value",
|
||||||
|
category: "data",
|
||||||
description: "필드값 변경 시 fieldName + value + allValues 전달",
|
description: "필드값 변경 시 fieldName + value + allValues 전달",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "collected_data",
|
||||||
|
label: "수집 응답",
|
||||||
|
type: "event",
|
||||||
|
category: "event",
|
||||||
|
description: "데이터 수집 요청에 대한 응답 (입력값 + 매핑)",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
receivable: [
|
receivable: [
|
||||||
{
|
{
|
||||||
key: "set_value",
|
key: "set_value",
|
||||||
label: "값 설정",
|
label: "값 설정",
|
||||||
type: "value",
|
type: "value",
|
||||||
|
category: "data",
|
||||||
description: "외부에서 특정 필드 또는 일괄로 값 세팅",
|
description: "외부에서 특정 필드 또는 일괄로 값 세팅",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "collect_data",
|
||||||
|
label: "수집 요청",
|
||||||
|
type: "event",
|
||||||
|
category: "event",
|
||||||
|
description: "버튼에서 데이터+매핑 수집 요청 수신",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
touchOptimized: true,
|
touchOptimized: true,
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@ PopComponentRegistry.registerComponent({
|
||||||
defaultProps: DEFAULT_SEARCH_CONFIG,
|
defaultProps: DEFAULT_SEARCH_CONFIG,
|
||||||
connectionMeta: {
|
connectionMeta: {
|
||||||
sendable: [
|
sendable: [
|
||||||
{ key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
|
{ key: "filter_value", label: "필터 값", type: "filter_value", category: "filter", description: "입력한 검색 조건을 다른 컴포넌트에 전달" },
|
||||||
],
|
],
|
||||||
receivable: [
|
receivable: [
|
||||||
{ key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
{ key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
touchOptimized: true,
|
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,
|
defaultProps: defaultConfig,
|
||||||
connectionMeta: {
|
connectionMeta: {
|
||||||
sendable: [
|
sendable: [
|
||||||
{ key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" },
|
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 행 데이터를 전달" },
|
||||||
],
|
],
|
||||||
receivable: [
|
receivable: [
|
||||||
{ key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
|
{ key: "filter_condition", label: "필터 조건", type: "filter_value", category: "filter", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
touchOptimized: true,
|
touchOptimized: true,
|
||||||
|
|
|
||||||
|
|
@ -509,7 +509,7 @@ export type CartItemStatus = "in_cart" | "confirmed" | "cancelled";
|
||||||
export interface CartItemWithId extends CartItem {
|
export interface CartItemWithId extends CartItem {
|
||||||
cartId?: string; // DB id (UUID, 저장 후 할당)
|
cartId?: string; // DB id (UUID, 저장 후 할당)
|
||||||
sourceTable: string; // 원본 테이블명
|
sourceTable: string; // 원본 테이블명
|
||||||
rowKey: string; // 원본 행 식별키 (codeField 값)
|
rowKey: string; // 원본 행 식별키 (keyColumn 값, 기본 id)
|
||||||
status: CartItemStatus;
|
status: CartItemStatus;
|
||||||
_origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가
|
_origin: CartItemOrigin; // DB에서 로드 vs 로컬에서 추가
|
||||||
memo?: string;
|
memo?: string;
|
||||||
|
|
@ -523,7 +523,7 @@ export type CartSaveMode = "cart" | "direct";
|
||||||
|
|
||||||
export interface CardCartActionConfig {
|
export interface CardCartActionConfig {
|
||||||
saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
|
saveMode: CartSaveMode; // "cart": 장바구니 임시저장, "direct": 대상 테이블 직접 저장
|
||||||
cartType?: string; // 장바구니 구분값 (예: "purchase_inbound")
|
keyColumn?: string; // 행 식별 키 컬럼 (기본: "id")
|
||||||
label?: string; // 담기 라벨 (기본: "담기")
|
label?: string; // 담기 라벨 (기본: "담기")
|
||||||
cancelLabel?: string; // 취소 라벨 (기본: "취소")
|
cancelLabel?: string; // 취소 라벨 (기본: "취소")
|
||||||
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
|
// 하위 호환: 이전 dataFields (사용하지 않지만 기존 데이터 보호)
|
||||||
|
|
@ -614,10 +614,80 @@ export interface CartListModeConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sourceScreenId?: number;
|
sourceScreenId?: number;
|
||||||
sourceComponentId?: string;
|
sourceComponentId?: string;
|
||||||
cartType?: string;
|
|
||||||
statusFilter?: 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 전체 설정 -----
|
// ----- pop-card-list 전체 설정 -----
|
||||||
|
|
||||||
export interface PopCardListConfig {
|
export interface PopCardListConfig {
|
||||||
|
|
@ -637,4 +707,5 @@ export interface PopCardListConfig {
|
||||||
cartAction?: CardCartActionConfig;
|
cartAction?: CardCartActionConfig;
|
||||||
|
|
||||||
cartListMode?: CartListModeConfig;
|
cartListMode?: CartListModeConfig;
|
||||||
|
saveMapping?: CardListSaveMapping;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue