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:
SeongHyun Kim 2026-03-03 15:30:07 +09:00
parent 220e05d2ae
commit e3ae8d273c
20 changed files with 2147 additions and 329 deletions

View File

@ -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"]

View File

@ -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);

View File

@ -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;

View File

@ -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}`;
} }

View File

@ -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,
}); });
// 모달 열기/닫기 이벤트 구독 // 모달 열기/닫기 이벤트 구독

View File

@ -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));
} }

View File

@ -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) {

View File

@ -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;
} }

View File

@ -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"> -&gt;</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"> -&gt;</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,

View File

@ -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()}

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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",
})); }));
} }
} }

View File

@ -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) => {

View File

@ -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,

View File

@ -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,

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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,

View File

@ -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;
} }