Merge branch 'ksh-v2-work'
This commit is contained in:
commit
a81cb7ca19
|
|
@ -2,6 +2,7 @@ import { Router, Request, Response } from "express";
|
||||||
import { getPool } from "../database/db";
|
import { getPool } from "../database/db";
|
||||||
import logger from "../utils/logger";
|
import logger from "../utils/logger";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { numberingRuleService } from "../services/numberingRuleService";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
|
@ -12,9 +13,26 @@ function isSafeIdentifier(name: string): boolean {
|
||||||
return SAFE_IDENTIFIER.test(name);
|
return SAFE_IDENTIFIER.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutoGenMappingInfo {
|
||||||
|
numberingRuleId: string;
|
||||||
|
targetColumn: string;
|
||||||
|
showResultModal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HiddenMappingInfo {
|
||||||
|
valueSource: "json_extract" | "db_column" | "static";
|
||||||
|
targetColumn: string;
|
||||||
|
staticValue?: string;
|
||||||
|
sourceJsonColumn?: string;
|
||||||
|
sourceJsonKey?: string;
|
||||||
|
sourceDbColumn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MappingInfo {
|
interface MappingInfo {
|
||||||
targetTable: string;
|
targetTable: string;
|
||||||
columnMapping: Record<string, string>;
|
columnMapping: Record<string, string>;
|
||||||
|
autoGenMappings?: AutoGenMappingInfo[];
|
||||||
|
hiddenMappings?: HiddenMappingInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatusConditionRule {
|
interface StatusConditionRule {
|
||||||
|
|
@ -44,7 +62,8 @@ interface StatusChangeRuleBody {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExecuteActionBody {
|
interface ExecuteActionBody {
|
||||||
action: string;
|
action?: string;
|
||||||
|
tasks?: TaskBody[];
|
||||||
data: {
|
data: {
|
||||||
items?: Record<string, unknown>[];
|
items?: Record<string, unknown>[];
|
||||||
fieldValues?: Record<string, unknown>;
|
fieldValues?: Record<string, unknown>;
|
||||||
|
|
@ -54,6 +73,36 @@ interface ExecuteActionBody {
|
||||||
field?: MappingInfo | null;
|
field?: MappingInfo | null;
|
||||||
};
|
};
|
||||||
statusChanges?: StatusChangeRuleBody[];
|
statusChanges?: StatusChangeRuleBody[];
|
||||||
|
cartChanges?: {
|
||||||
|
toCreate?: Record<string, unknown>[];
|
||||||
|
toUpdate?: Record<string, unknown>[];
|
||||||
|
toDelete?: (string | number)[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskBody {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
targetTable?: string;
|
||||||
|
targetColumn?: string;
|
||||||
|
operationType?: "assign" | "add" | "subtract" | "multiply" | "divide" | "conditional" | "db-conditional";
|
||||||
|
valueSource?: "fixed" | "linked" | "reference";
|
||||||
|
fixedValue?: string;
|
||||||
|
sourceField?: string;
|
||||||
|
referenceTable?: string;
|
||||||
|
referenceColumn?: string;
|
||||||
|
referenceJoinKey?: string;
|
||||||
|
conditionalValue?: ConditionalValueRule;
|
||||||
|
// db-conditional 전용 (DB 컬럼 간 비교 후 값 판정)
|
||||||
|
compareColumn?: string;
|
||||||
|
compareOperator?: "=" | "!=" | ">" | "<" | ">=" | "<=";
|
||||||
|
compareWith?: string;
|
||||||
|
dbThenValue?: string;
|
||||||
|
dbElseValue?: string;
|
||||||
|
lookupMode?: "auto" | "manual";
|
||||||
|
manualItemField?: string;
|
||||||
|
manualPkColumn?: string;
|
||||||
|
cartScreenId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveStatusValue(
|
function resolveStatusValue(
|
||||||
|
|
@ -96,26 +145,300 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
return res.status(401).json({ success: false, message: "인증 정보가 없습니다." });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { action, data, mappings, statusChanges } = req.body as ExecuteActionBody;
|
const { action, tasks, data, mappings, statusChanges, cartChanges } = req.body as ExecuteActionBody;
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
const fieldValues = data?.fieldValues ?? {};
|
const fieldValues = data?.fieldValues ?? {};
|
||||||
|
|
||||||
logger.info("[pop/execute-action] 요청", {
|
logger.info("[pop/execute-action] 요청", {
|
||||||
action,
|
action: action ?? "task-list",
|
||||||
companyCode,
|
companyCode,
|
||||||
userId,
|
userId,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
hasFieldValues: Object.keys(fieldValues).length > 0,
|
hasFieldValues: Object.keys(fieldValues).length > 0,
|
||||||
hasMappings: !!mappings,
|
hasMappings: !!mappings,
|
||||||
statusChangeCount: statusChanges?.length ?? 0,
|
statusChangeCount: statusChanges?.length ?? 0,
|
||||||
|
taskCount: tasks?.length ?? 0,
|
||||||
|
hasCartChanges: !!cartChanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.query("BEGIN");
|
await client.query("BEGIN");
|
||||||
|
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
let insertedCount = 0;
|
let insertedCount = 0;
|
||||||
|
let deletedCount = 0;
|
||||||
|
const generatedCodes: Array<{ targetColumn: string; code: string; showResultModal?: boolean }> = [];
|
||||||
|
|
||||||
if (action === "inbound-confirm") {
|
// ======== v2: tasks 배열 기반 처리 ========
|
||||||
|
if (tasks && tasks.length > 0) {
|
||||||
|
for (const task of tasks) {
|
||||||
|
switch (task.type) {
|
||||||
|
case "data-save": {
|
||||||
|
// 매핑 기반 INSERT (기존 inbound-confirm 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allHidden = [
|
||||||
|
...(fieldMapping?.hiddenMappings ?? []),
|
||||||
|
...(cardMapping?.hiddenMappings ?? []),
|
||||||
|
];
|
||||||
|
for (const hm of allHidden) {
|
||||||
|
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
|
||||||
|
if (columns.includes(`"${hm.targetColumn}"`)) continue;
|
||||||
|
let value: unknown = null;
|
||||||
|
if (hm.valueSource === "static") {
|
||||||
|
value = hm.staticValue ?? null;
|
||||||
|
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
||||||
|
const jsonCol = item[hm.sourceJsonColumn];
|
||||||
|
if (typeof jsonCol === "object" && jsonCol !== null) {
|
||||||
|
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
|
||||||
|
} else if (typeof jsonCol === "string") {
|
||||||
|
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||||
|
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
|
||||||
|
}
|
||||||
|
columns.push(`"${hm.targetColumn}"`);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allAutoGen = [
|
||||||
|
...(fieldMapping?.autoGenMappings ?? []),
|
||||||
|
...(cardMapping?.autoGenMappings ?? []),
|
||||||
|
];
|
||||||
|
for (const ag of allAutoGen) {
|
||||||
|
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||||
|
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||||
|
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||||
|
try {
|
||||||
|
const generatedCode = await numberingRuleService.allocateCode(
|
||||||
|
ag.numberingRuleId, companyCode, { ...fieldValues, ...item },
|
||||||
|
);
|
||||||
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
|
values.push(generatedCode);
|
||||||
|
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error("[pop/execute-action] 채번 실패", { ruleId: ag.numberingRuleId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (columns.length > 1) {
|
||||||
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||||
|
values,
|
||||||
|
);
|
||||||
|
insertedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "data-update": {
|
||||||
|
if (!task.targetTable || !task.targetColumn) break;
|
||||||
|
if (!isSafeIdentifier(task.targetTable) || !isSafeIdentifier(task.targetColumn)) break;
|
||||||
|
|
||||||
|
const opType = task.operationType ?? "assign";
|
||||||
|
const valSource = task.valueSource ?? "fixed";
|
||||||
|
const lookupMode = task.lookupMode ?? "auto";
|
||||||
|
|
||||||
|
let itemField: string;
|
||||||
|
let pkColumn: string;
|
||||||
|
|
||||||
|
if (lookupMode === "manual" && task.manualItemField && task.manualPkColumn) {
|
||||||
|
if (!isSafeIdentifier(task.manualPkColumn)) break;
|
||||||
|
itemField = task.manualItemField;
|
||||||
|
pkColumn = task.manualPkColumn;
|
||||||
|
} else if (task.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`,
|
||||||
|
[task.targetTable],
|
||||||
|
);
|
||||||
|
pkColumn = pkResult.rows[0]?.attname || "id";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookupValues = items.map((item) => item[itemField] ?? item[itemField.replace(/^__cart_/, "")]).filter(Boolean);
|
||||||
|
if (lookupValues.length === 0) break;
|
||||||
|
|
||||||
|
if (opType === "conditional" && task.conditionalValue) {
|
||||||
|
for (let i = 0; i < lookupValues.length; i++) {
|
||||||
|
const item = items[i] ?? {};
|
||||||
|
const resolved = resolveStatusValue("conditional", task.fixedValue ?? "", task.conditionalValue, item);
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = $1 WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||||
|
[resolved, companyCode, lookupValues[i]],
|
||||||
|
);
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
} else if (opType === "db-conditional") {
|
||||||
|
// DB 컬럼 간 비교 후 값 판정 (CASE WHEN col_a >= col_b THEN '완료' ELSE '진행중')
|
||||||
|
if (!task.compareColumn || !task.compareOperator || !task.compareWith) break;
|
||||||
|
if (!isSafeIdentifier(task.compareColumn) || !isSafeIdentifier(task.compareWith)) break;
|
||||||
|
|
||||||
|
const thenVal = task.dbThenValue ?? "";
|
||||||
|
const elseVal = task.dbElseValue ?? "";
|
||||||
|
const op = task.compareOperator;
|
||||||
|
const validOps = ["=", "!=", ">", "<", ">=", "<="];
|
||||||
|
if (!validOps.includes(op)) break;
|
||||||
|
|
||||||
|
const caseSql = `CASE WHEN COALESCE("${task.compareColumn}"::numeric, 0) ${op} COALESCE("${task.compareWith}"::numeric, 0) THEN $1 ELSE $2 END`;
|
||||||
|
|
||||||
|
const placeholders = lookupValues.map((_, i) => `$${i + 4}`).join(", ");
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "${task.targetTable}" SET "${task.targetColumn}" = ${caseSql} WHERE company_code = $3 AND "${pkColumn}" IN (${placeholders})`,
|
||||||
|
[thenVal, elseVal, companyCode, ...lookupValues],
|
||||||
|
);
|
||||||
|
processedCount += lookupValues.length;
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < lookupValues.length; i++) {
|
||||||
|
const item = items[i] ?? {};
|
||||||
|
let value: unknown;
|
||||||
|
|
||||||
|
if (valSource === "linked") {
|
||||||
|
value = item[task.sourceField ?? ""] ?? null;
|
||||||
|
} else {
|
||||||
|
value = task.fixedValue ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let setSql: string;
|
||||||
|
if (opType === "add") {
|
||||||
|
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) + $1::numeric`;
|
||||||
|
} else if (opType === "subtract") {
|
||||||
|
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) - $1::numeric`;
|
||||||
|
} else if (opType === "multiply") {
|
||||||
|
setSql = `"${task.targetColumn}" = COALESCE("${task.targetColumn}"::numeric, 0) * $1::numeric`;
|
||||||
|
} else if (opType === "divide") {
|
||||||
|
setSql = `"${task.targetColumn}" = CASE WHEN $1::numeric = 0 THEN COALESCE("${task.targetColumn}"::numeric, 0) ELSE COALESCE("${task.targetColumn}"::numeric, 0) / $1::numeric END`;
|
||||||
|
} else {
|
||||||
|
setSql = `"${task.targetColumn}" = $1`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "${task.targetTable}" SET ${setSql} WHERE company_code = $2 AND "${pkColumn}" = $3`,
|
||||||
|
[value, companyCode, lookupValues[i]],
|
||||||
|
);
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("[pop/execute-action] data-update 실행", {
|
||||||
|
table: task.targetTable,
|
||||||
|
column: task.targetColumn,
|
||||||
|
opType,
|
||||||
|
count: lookupValues.length,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "data-delete": {
|
||||||
|
if (!task.targetTable) break;
|
||||||
|
if (!isSafeIdentifier(task.targetTable)) break;
|
||||||
|
|
||||||
|
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`,
|
||||||
|
[task.targetTable],
|
||||||
|
);
|
||||||
|
const pkCol = pkResult.rows[0]?.attname || "id";
|
||||||
|
const deleteKeys = items.map((item) => item[pkCol] ?? item["id"]).filter(Boolean);
|
||||||
|
|
||||||
|
if (deleteKeys.length > 0) {
|
||||||
|
const placeholders = deleteKeys.map((_, i) => `$${i + 2}`).join(", ");
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM "${task.targetTable}" WHERE company_code = $1 AND "${pkCol}" IN (${placeholders})`,
|
||||||
|
[companyCode, ...deleteKeys],
|
||||||
|
);
|
||||||
|
deletedCount += deleteKeys.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "cart-save": {
|
||||||
|
// cartChanges 처리 (M-9에서 확장)
|
||||||
|
if (!cartChanges) break;
|
||||||
|
const { toCreate, toUpdate, toDelete } = cartChanges;
|
||||||
|
|
||||||
|
if (toCreate && toCreate.length > 0) {
|
||||||
|
for (const item of toCreate) {
|
||||||
|
const cols = Object.keys(item).filter(isSafeIdentifier);
|
||||||
|
if (cols.length === 0) continue;
|
||||||
|
const allCols = ["company_code", ...cols.map((c) => `"${c}"`)];
|
||||||
|
const allVals = [companyCode, ...cols.map((c) => item[c])];
|
||||||
|
const placeholders = allVals.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
await client.query(
|
||||||
|
`INSERT INTO "cart_items" (${allCols.join(", ")}) VALUES (${placeholders})`,
|
||||||
|
allVals,
|
||||||
|
);
|
||||||
|
insertedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toUpdate && toUpdate.length > 0) {
|
||||||
|
for (const item of toUpdate) {
|
||||||
|
const id = item.id;
|
||||||
|
if (!id) continue;
|
||||||
|
const cols = Object.keys(item).filter((c) => c !== "id" && isSafeIdentifier(c));
|
||||||
|
if (cols.length === 0) continue;
|
||||||
|
const setClauses = cols.map((c, i) => `"${c}" = $${i + 3}`).join(", ");
|
||||||
|
await client.query(
|
||||||
|
`UPDATE "cart_items" SET ${setClauses} WHERE id = $1 AND company_code = $2`,
|
||||||
|
[id, companyCode, ...cols.map((c) => item[c])],
|
||||||
|
);
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDelete && toDelete.length > 0) {
|
||||||
|
const placeholders = toDelete.map((_, i) => `$${i + 2}`).join(", ");
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM "cart_items" WHERE company_code = $1 AND id IN (${placeholders})`,
|
||||||
|
[companyCode, ...toDelete],
|
||||||
|
);
|
||||||
|
deletedCount += toDelete.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("[pop/execute-action] cart-save 실행", {
|
||||||
|
created: toCreate?.length ?? 0,
|
||||||
|
updated: toUpdate?.length ?? 0,
|
||||||
|
deleted: toDelete?.length ?? 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn("[pop/execute-action] 프론트 전용 작업 타입, 백엔드 무시", { type: task.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ======== v1 레거시: action 기반 처리 ========
|
||||||
|
else if (action === "inbound-confirm") {
|
||||||
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
// 1. 매핑 기반 INSERT (장바구니 데이터 -> 대상 테이블)
|
||||||
const cardMapping = mappings?.cardList;
|
const cardMapping = mappings?.cardList;
|
||||||
const fieldMapping = mappings?.field;
|
const fieldMapping = mappings?.field;
|
||||||
|
|
@ -144,6 +467,64 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 숨은 필드 매핑 처리 (고정값 / JSON추출 / DB컬럼)
|
||||||
|
const allHidden = [
|
||||||
|
...(fieldMapping?.hiddenMappings ?? []),
|
||||||
|
...(cardMapping?.hiddenMappings ?? []),
|
||||||
|
];
|
||||||
|
for (const hm of allHidden) {
|
||||||
|
if (!hm.targetColumn || !isSafeIdentifier(hm.targetColumn)) continue;
|
||||||
|
if (columns.includes(`"${hm.targetColumn}"`)) continue;
|
||||||
|
|
||||||
|
let value: unknown = null;
|
||||||
|
if (hm.valueSource === "static") {
|
||||||
|
value = hm.staticValue ?? null;
|
||||||
|
} else if (hm.valueSource === "json_extract" && hm.sourceJsonColumn && hm.sourceJsonKey) {
|
||||||
|
const jsonCol = item[hm.sourceJsonColumn];
|
||||||
|
if (typeof jsonCol === "object" && jsonCol !== null) {
|
||||||
|
value = (jsonCol as Record<string, unknown>)[hm.sourceJsonKey] ?? null;
|
||||||
|
} else if (typeof jsonCol === "string") {
|
||||||
|
try { value = JSON.parse(jsonCol)[hm.sourceJsonKey] ?? null; } catch { /* skip */ }
|
||||||
|
}
|
||||||
|
} else if (hm.valueSource === "db_column" && hm.sourceDbColumn) {
|
||||||
|
value = item[hm.sourceDbColumn] ?? fieldValues[hm.sourceDbColumn] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
columns.push(`"${hm.targetColumn}"`);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 채번 규칙 실행: field + cardList의 autoGenMappings에서 코드 발급
|
||||||
|
const allAutoGen = [
|
||||||
|
...(fieldMapping?.autoGenMappings ?? []),
|
||||||
|
...(cardMapping?.autoGenMappings ?? []),
|
||||||
|
];
|
||||||
|
for (const ag of allAutoGen) {
|
||||||
|
if (!ag.numberingRuleId || !ag.targetColumn) continue;
|
||||||
|
if (!isSafeIdentifier(ag.targetColumn)) continue;
|
||||||
|
if (columns.includes(`"${ag.targetColumn}"`)) continue;
|
||||||
|
try {
|
||||||
|
const generatedCode = await numberingRuleService.allocateCode(
|
||||||
|
ag.numberingRuleId,
|
||||||
|
companyCode,
|
||||||
|
{ ...fieldValues, ...item },
|
||||||
|
);
|
||||||
|
columns.push(`"${ag.targetColumn}"`);
|
||||||
|
values.push(generatedCode);
|
||||||
|
generatedCodes.push({ targetColumn: ag.targetColumn, code: generatedCode, showResultModal: ag.showResultModal ?? false });
|
||||||
|
logger.info("[pop/execute-action] 채번 완료", {
|
||||||
|
ruleId: ag.numberingRuleId,
|
||||||
|
targetColumn: ag.targetColumn,
|
||||||
|
generatedCode,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error("[pop/execute-action] 채번 실패", {
|
||||||
|
ruleId: ag.numberingRuleId,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (columns.length > 1) {
|
if (columns.length > 1) {
|
||||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
const sql = `INSERT INTO "${cardMapping.targetTable}" (${columns.join(", ")}) VALUES (${placeholders})`;
|
||||||
|
|
@ -254,16 +635,17 @@ router.post("/execute-action", authenticateToken, async (req: Request, res: Resp
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
|
|
||||||
logger.info("[pop/execute-action] 완료", {
|
logger.info("[pop/execute-action] 완료", {
|
||||||
action,
|
action: action ?? "task-list",
|
||||||
companyCode,
|
companyCode,
|
||||||
processedCount,
|
processedCount,
|
||||||
insertedCount,
|
insertedCount,
|
||||||
|
deletedCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${processedCount}건 처리 완료${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}`,
|
message: `${processedCount}건 처리${insertedCount > 0 ? `, ${insertedCount}건 생성` : ""}${deletedCount > 0 ? `, ${deletedCount}건 삭제` : ""}`,
|
||||||
data: { processedCount, insertedCount },
|
data: { processedCount, insertedCount, deletedCount, generatedCodes },
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
await client.query("ROLLBACK");
|
await client.query("ROLLBACK");
|
||||||
|
|
|
||||||
|
|
@ -471,7 +471,15 @@ export function PopCategoryTree({
|
||||||
// 상태 관리
|
// 상태 관리
|
||||||
const [groups, setGroups] = useState<PopScreenGroup[]>([]);
|
const [groups, setGroups] = useState<PopScreenGroup[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() => {
|
||||||
|
if (typeof window === "undefined") return new Set();
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem("pop-tree-expanded-groups");
|
||||||
|
return saved ? new Set(JSON.parse(saved) as number[]) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
|
||||||
|
|
||||||
// 그룹 모달 상태
|
// 그룹 모달 상태
|
||||||
|
|
@ -500,7 +508,15 @@ export function PopCategoryTree({
|
||||||
const [moveSearchTerm, setMoveSearchTerm] = useState("");
|
const [moveSearchTerm, setMoveSearchTerm] = useState("");
|
||||||
|
|
||||||
// 미분류 회사코드별 접기/펼치기
|
// 미분류 회사코드별 접기/펼치기
|
||||||
const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(new Set());
|
const [expandedCompanyCodes, setExpandedCompanyCodes] = useState<Set<string>>(() => {
|
||||||
|
if (typeof window === "undefined") return new Set();
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem("pop-tree-expanded-companies");
|
||||||
|
return saved ? new Set(JSON.parse(saved) as string[]) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 화면 맵 생성 (screen_id로 빠르게 조회)
|
// 화면 맵 생성 (screen_id로 빠르게 조회)
|
||||||
const screensMap = useMemo(() => {
|
const screensMap = useMemo(() => {
|
||||||
|
|
@ -544,6 +560,9 @@ export function PopCategoryTree({
|
||||||
} else {
|
} else {
|
||||||
next.add(groupId);
|
next.add(groupId);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("pop-tree-expanded-groups", JSON.stringify([...next]));
|
||||||
|
} catch { /* noop */ }
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -1013,6 +1032,9 @@ export function PopCategoryTree({
|
||||||
} else {
|
} else {
|
||||||
next.add(code);
|
next.add(code);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem("pop-tree-expanded-companies", JSON.stringify([...next]));
|
||||||
|
} catch { /* noop */ }
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ function SelectTrigger({
|
||||||
size?: "xs" | "sm" | "default";
|
size?: "xs" | "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
|
// className에 h-full/h-[ 또는 style.height가 있으면 data-size 높이를 무시
|
||||||
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || !!style?.height;
|
const hasCustomHeight = className?.includes("h-full") || className?.includes("h-[") || /\bh-\d/.test(className ?? "") || !!style?.height;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
* - 향후 pop-table 행 액션 등
|
* - 향후 pop-table 행 액션 등
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button";
|
import type { ButtonMainAction, ButtonTask } from "@/lib/registry/pop-components/pop-button";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
|
||||||
|
|
@ -197,3 +197,156 @@ export async function executePopAction(
|
||||||
return { success: false, error: message };
|
return { success: false, error: message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// v2: 작업 목록 실행
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/** 수집된 데이터 구조 */
|
||||||
|
export interface CollectedPayload {
|
||||||
|
items?: Record<string, unknown>[];
|
||||||
|
fieldValues?: Record<string, unknown>;
|
||||||
|
mappings?: {
|
||||||
|
cardList?: Record<string, unknown> | null;
|
||||||
|
field?: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
cartChanges?: {
|
||||||
|
toCreate?: Record<string, unknown>[];
|
||||||
|
toUpdate?: Record<string, unknown>[];
|
||||||
|
toDelete?: (string | number)[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 작업 목록 실행 옵션 */
|
||||||
|
interface ExecuteTaskListOptions {
|
||||||
|
publish: PublishFn;
|
||||||
|
componentId: string;
|
||||||
|
collectedData?: CollectedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 작업 목록을 순차 실행한다.
|
||||||
|
* 데이터 관련 작업(data-save, data-update, data-delete, cart-save)은
|
||||||
|
* 하나의 API 호출로 묶어 백엔드에서 트랜잭션 처리한다.
|
||||||
|
* 나머지 작업(modal-open, navigate 등)은 프론트엔드에서 직접 처리한다.
|
||||||
|
*/
|
||||||
|
export async function executeTaskList(
|
||||||
|
tasks: ButtonTask[],
|
||||||
|
options: ExecuteTaskListOptions,
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
const { publish, componentId, collectedData } = options;
|
||||||
|
|
||||||
|
// 데이터 작업과 프론트 전용 작업 분리
|
||||||
|
const DATA_TASK_TYPES = new Set(["data-save", "data-update", "data-delete", "cart-save"]);
|
||||||
|
const dataTasks = tasks.filter((t) => DATA_TASK_TYPES.has(t.type));
|
||||||
|
const frontTasks = tasks.filter((t) => !DATA_TASK_TYPES.has(t.type));
|
||||||
|
|
||||||
|
let backendData: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 데이터 작업이 있으면 백엔드에 일괄 전송
|
||||||
|
if (dataTasks.length > 0) {
|
||||||
|
const result = await apiClient.post("/pop/execute-action", {
|
||||||
|
tasks: dataTasks,
|
||||||
|
data: {
|
||||||
|
items: collectedData?.items ?? [],
|
||||||
|
fieldValues: collectedData?.fieldValues ?? {},
|
||||||
|
},
|
||||||
|
mappings: collectedData?.mappings ?? {},
|
||||||
|
cartChanges: collectedData?.cartChanges,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.data?.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: result.data?.message || "데이터 작업 실행에 실패했습니다.",
|
||||||
|
data: result.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
backendData = result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerData = (backendData as Record<string, unknown>)?.data as Record<string, unknown> | undefined;
|
||||||
|
const generatedCodes = innerData?.generatedCodes as
|
||||||
|
Array<{ targetColumn: string; code: string; showResultModal?: boolean }> | undefined;
|
||||||
|
const hasResultModal = generatedCodes?.some((g) => g.showResultModal);
|
||||||
|
|
||||||
|
// 2. 프론트엔드 전용 작업 순차 실행 (채번 모달이 있으면 navigate 보류)
|
||||||
|
const deferredNavigateTasks: ButtonTask[] = [];
|
||||||
|
for (const task of frontTasks) {
|
||||||
|
switch (task.type) {
|
||||||
|
case "modal-open":
|
||||||
|
publish("__pop_modal_open__", {
|
||||||
|
modalId: task.modalScreenId,
|
||||||
|
title: task.modalTitle,
|
||||||
|
mode: task.modalMode,
|
||||||
|
items: task.modalItems,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "navigate":
|
||||||
|
if (hasResultModal) {
|
||||||
|
deferredNavigateTasks.push(task);
|
||||||
|
} else if (task.targetScreenId) {
|
||||||
|
publish("__pop_navigate__", { screenId: task.targetScreenId, params: task.params });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "close-modal":
|
||||||
|
publish("__pop_close_modal__");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "refresh":
|
||||||
|
if (!hasResultModal) {
|
||||||
|
publish("__pop_refresh__");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "api-call": {
|
||||||
|
if (!task.apiEndpoint) break;
|
||||||
|
const method = (task.apiMethod || "POST").toUpperCase();
|
||||||
|
switch (method) {
|
||||||
|
case "GET":
|
||||||
|
await apiClient.get(task.apiEndpoint);
|
||||||
|
break;
|
||||||
|
case "PUT":
|
||||||
|
await apiClient.put(task.apiEndpoint);
|
||||||
|
break;
|
||||||
|
case "DELETE":
|
||||||
|
await apiClient.delete(task.apiEndpoint);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await apiClient.post(task.apiEndpoint);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "custom-event":
|
||||||
|
if (task.eventName) {
|
||||||
|
publish(task.eventName, task.eventPayload ?? {});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 완료 이벤트
|
||||||
|
if (!hasResultModal) {
|
||||||
|
publish(`__comp_output__${componentId}__action_completed`, {
|
||||||
|
action: "task-list",
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
generatedCodes,
|
||||||
|
deferredTasks: deferredNavigateTasks,
|
||||||
|
...(backendData ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : "작업 실행 중 오류가 발생했습니다.";
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,5 +26,8 @@ export { useConnectionResolver } from "./useConnectionResolver";
|
||||||
export { useCartSync } from "./useCartSync";
|
export { useCartSync } from "./useCartSync";
|
||||||
export type { UseCartSyncReturn } from "./useCartSync";
|
export type { UseCartSyncReturn } from "./useCartSync";
|
||||||
|
|
||||||
|
// 설정 패널 접기/펼치기 상태 관리
|
||||||
|
export { useCollapsibleSections } from "./useCollapsibleSections";
|
||||||
|
|
||||||
// SQL 빌더 유틸 (고급 사용 시)
|
// SQL 빌더 유틸 (고급 사용 시)
|
||||||
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ import type {
|
||||||
|
|
||||||
// ===== 반환 타입 =====
|
// ===== 반환 타입 =====
|
||||||
|
|
||||||
|
export interface CartChanges {
|
||||||
|
toCreate: Record<string, unknown>[];
|
||||||
|
toUpdate: Record<string, unknown>[];
|
||||||
|
toDelete: (string | number)[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseCartSyncReturn {
|
export interface UseCartSyncReturn {
|
||||||
cartItems: CartItemWithId[];
|
cartItems: CartItemWithId[];
|
||||||
savedItems: CartItemWithId[];
|
savedItems: CartItemWithId[];
|
||||||
|
|
@ -48,6 +54,7 @@ export interface UseCartSyncReturn {
|
||||||
isItemInCart: (rowKey: string) => boolean;
|
isItemInCart: (rowKey: string) => boolean;
|
||||||
getCartItem: (rowKey: string) => CartItemWithId | undefined;
|
getCartItem: (rowKey: string) => CartItemWithId | undefined;
|
||||||
|
|
||||||
|
getChanges: (selectedColumns?: string[]) => CartChanges;
|
||||||
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
|
saveToDb: (selectedColumns?: string[]) => Promise<boolean>;
|
||||||
loadFromDb: () => Promise<void>;
|
loadFromDb: () => Promise<void>;
|
||||||
resetToSaved: () => void;
|
resetToSaved: () => void;
|
||||||
|
|
@ -252,6 +259,29 @@ export function useCartSync(
|
||||||
[cartItems],
|
[cartItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ----- diff 계산 (백엔드 전송용) -----
|
||||||
|
const getChanges = useCallback((selectedColumns?: string[]): CartChanges => {
|
||||||
|
const currentScreenId = screenIdRef.current;
|
||||||
|
|
||||||
|
const cartRowKeys = new Set(cartItems.map((i) => i.rowKey));
|
||||||
|
const toDeleteItems = savedItems.filter((s) => s.cartId && !cartRowKeys.has(s.rowKey));
|
||||||
|
const toCreateItems = cartItems.filter((c) => !c.cartId);
|
||||||
|
|
||||||
|
const savedMap = new Map(savedItems.map((s) => [s.rowKey, s]));
|
||||||
|
const toUpdateItems = cartItems.filter((c) => {
|
||||||
|
if (!c.cartId) return false;
|
||||||
|
const saved = savedMap.get(c.rowKey);
|
||||||
|
if (!saved) return false;
|
||||||
|
return c.quantity !== saved.quantity || c.packageUnit !== saved.packageUnit || c.status !== saved.status;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
toCreate: toCreateItems.map((item) => cartItemToDbRecord(item, currentScreenId, selectedColumns)),
|
||||||
|
toUpdate: toUpdateItems.map((item) => ({ id: item.cartId, ...cartItemToDbRecord(item, currentScreenId, selectedColumns) })),
|
||||||
|
toDelete: toDeleteItems.map((item) => item.cartId!),
|
||||||
|
};
|
||||||
|
}, [cartItems, savedItems]);
|
||||||
|
|
||||||
// ----- DB 저장 (일괄) -----
|
// ----- DB 저장 (일괄) -----
|
||||||
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
|
const saveToDb = useCallback(async (selectedColumns?: string[]): Promise<boolean> => {
|
||||||
setSyncStatus("saving");
|
setSyncStatus("saving");
|
||||||
|
|
@ -324,6 +354,7 @@ export function useCartSync(
|
||||||
updateItemQuantity,
|
updateItemQuantity,
|
||||||
isItemInCart,
|
isItemInCart,
|
||||||
getCartItem,
|
getCartItem,
|
||||||
|
getChanges,
|
||||||
saveToDb,
|
saveToDb,
|
||||||
loadFromDb,
|
loadFromDb,
|
||||||
resetToSaved,
|
resetToSaved,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 패널 접기/펼치기 상태를 sessionStorage로 기억하는 훅
|
||||||
|
*
|
||||||
|
* - 초기 상태: 모든 섹션 접힘
|
||||||
|
* - 사용자가 펼친 섹션은 같은 탭 세션 내에서 기억
|
||||||
|
* - 탭 닫으면 초기화
|
||||||
|
*
|
||||||
|
* @param storageKey sessionStorage 키 (예: "pop-card-list")
|
||||||
|
*/
|
||||||
|
export function useCollapsibleSections(storageKey: string) {
|
||||||
|
const fullKey = `pop-config-sections-${storageKey}`;
|
||||||
|
|
||||||
|
const [openSections, setOpenSections] = useState<Set<string>>(() => {
|
||||||
|
if (typeof window === "undefined") return new Set<string>();
|
||||||
|
try {
|
||||||
|
const saved = sessionStorage.getItem(fullKey);
|
||||||
|
if (saved) return new Set<string>(JSON.parse(saved));
|
||||||
|
} catch {}
|
||||||
|
return new Set<string>();
|
||||||
|
});
|
||||||
|
|
||||||
|
const openSectionsRef = useRef(openSections);
|
||||||
|
openSectionsRef.current = openSections;
|
||||||
|
|
||||||
|
const persist = useCallback(
|
||||||
|
(next: Set<string>) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(fullKey, JSON.stringify([...next]));
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
[fullKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOpen = useCallback(
|
||||||
|
(key: string) => openSectionsRef.current.has(key),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
(key: string) => {
|
||||||
|
setOpenSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) {
|
||||||
|
next.delete(key);
|
||||||
|
} else {
|
||||||
|
next.add(key);
|
||||||
|
}
|
||||||
|
persist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[persist],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isOpen, toggle };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -702,11 +702,13 @@ export function PopCardListComponent({
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const cartChanges = cart.isDirty ? cart.getChanges() : undefined;
|
||||||
|
|
||||||
const response: CollectedDataResponse = {
|
const response: CollectedDataResponse = {
|
||||||
requestId: request?.requestId ?? "",
|
requestId: request?.requestId ?? "",
|
||||||
componentId: componentId,
|
componentId: componentId,
|
||||||
componentType: "pop-card-list",
|
componentType: "pop-card-list",
|
||||||
data: { items: selectedItems },
|
data: { items: selectedItems, cartChanges },
|
||||||
mapping,
|
mapping,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -714,7 +716,7 @@ export function PopCardListComponent({
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys]);
|
}, [componentId, subscribe, publish, isCartListMode, filteredRows, rows, selectedKeys, cart]);
|
||||||
|
|
||||||
// 장바구니 목록 모드: 선택 항목 이벤트 발행
|
// 장바구니 목록 모드: 선택 항목 이벤트 발행
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -728,14 +730,13 @@ export function PopCardListComponent({
|
||||||
gap: `${scaled.gap}px`,
|
gap: `${scaled.gap}px`,
|
||||||
...(isHorizontalMode
|
...(isHorizontalMode
|
||||||
? {
|
? {
|
||||||
gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`,
|
gridTemplateRows: `repeat(${gridRows}, minmax(${scaled.cardHeight}px, auto))`,
|
||||||
gridAutoFlow: "column",
|
gridAutoFlow: "column",
|
||||||
gridAutoColumns: `${scaled.cardWidth}px`,
|
gridAutoColumns: `${scaled.cardWidth}px`,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
// 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지
|
|
||||||
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
|
||||||
gridAutoRows: `${scaled.cardHeight}px`,
|
gridAutoRows: `minmax(${scaled.cardHeight}px, auto)`,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -998,16 +999,18 @@ function Card({
|
||||||
return 999999;
|
return 999999;
|
||||||
}, [limitCol, row]);
|
}, [limitCol, row]);
|
||||||
|
|
||||||
// 제한 컬럼이 있으면 최대값으로 자동 초기화
|
// 제한 컬럼이 있으면 최대값으로 자동 초기화 (장바구니 목록 모드에서는 cart 수량 유지)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isCartListMode) return;
|
||||||
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
|
if (inputField?.enabled && limitCol && effectiveMax > 0 && effectiveMax < 999999) {
|
||||||
setInputValue(effectiveMax);
|
setInputValue(effectiveMax);
|
||||||
}
|
}
|
||||||
}, [effectiveMax, inputField?.enabled, limitCol]);
|
}, [effectiveMax, inputField?.enabled, limitCol, isCartListMode]);
|
||||||
|
|
||||||
|
const hasPackageEntries = packageEntries.length > 0;
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
height: `${scaled.cardHeight}px`,
|
minHeight: `${scaled.cardHeight}px`,
|
||||||
overflow: "hidden",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const headerStyle: React.CSSProperties = {
|
const headerStyle: React.CSSProperties = {
|
||||||
|
|
@ -1113,7 +1116,7 @@ function Card({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`relative cursor-pointer rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
className={`relative flex cursor-pointer flex-col rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
||||||
style={cardStyle}
|
style={cardStyle}
|
||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
role="button"
|
role="button"
|
||||||
|
|
@ -1154,7 +1157,7 @@ function Card({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 본문 영역 */}
|
{/* 본문 영역 */}
|
||||||
<div className="flex" style={bodyStyle}>
|
<div className="flex flex-1 overflow-hidden" style={bodyStyle}>
|
||||||
{/* 이미지 (왼쪽) */}
|
{/* 이미지 (왼쪽) */}
|
||||||
{image?.enabled && (
|
{image?.enabled && (
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
|
|
@ -1196,7 +1199,7 @@ function Card({
|
||||||
{/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */}
|
{/* 오른쪽: 수량 버튼 + 담기/취소/삭제 버튼 */}
|
||||||
{(inputField?.enabled || cartAction || isCartListMode) && (
|
{(inputField?.enabled || cartAction || isCartListMode) && (
|
||||||
<div
|
<div
|
||||||
className="ml-2 flex shrink-0 flex-col items-stretch justify-center gap-2"
|
className="ml-2 flex shrink-0 flex-col items-stretch justify-start gap-2"
|
||||||
style={{ minWidth: "100px" }}
|
style={{ minWidth: "100px" }}
|
||||||
>
|
>
|
||||||
{/* 수량 버튼 (입력 필드 ON일 때만) */}
|
{/* 수량 버튼 (입력 필드 ON일 때만) */}
|
||||||
|
|
@ -1265,6 +1268,37 @@ function Card({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 포장 요약 바: 본문 아래에 표시 */}
|
||||||
|
{hasPackageEntries && (
|
||||||
|
<div className="border-t bg-emerald-50">
|
||||||
|
{packageEntries.map((entry, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="flex items-center justify-between px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||||
|
포장완료
|
||||||
|
</span>
|
||||||
|
<Package className="h-4 w-4 text-emerald-600" />
|
||||||
|
<span
|
||||||
|
className="font-medium text-emerald-700"
|
||||||
|
style={{ fontSize: `${scaled.bodyTextSize}px` }}
|
||||||
|
>
|
||||||
|
{entry.packageCount}{entry.unitLabel} x {entry.quantityPerUnit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="font-bold text-emerald-700"
|
||||||
|
style={{ fontSize: `${scaled.bodyTextSize}px` }}
|
||||||
|
>
|
||||||
|
= {entry.totalQuantity.toLocaleString()}{inputField?.unit || "EA"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{inputField?.enabled && (
|
{inputField?.enabled && (
|
||||||
<NumberInputModal
|
<NumberInputModal
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
|
|
@ -1304,7 +1338,7 @@ function FieldRow({
|
||||||
|
|
||||||
// 구조화된 수식 우선
|
// 구조화된 수식 우선
|
||||||
if (field.formulaLeft && field.formulaOperator) {
|
if (field.formulaLeft && field.formulaOperator) {
|
||||||
const rightVal = field.formulaRightType === "input"
|
const rightVal = (field.formulaRightType || "input") === "input"
|
||||||
? (inputValue ?? 0)
|
? (inputValue ?? 0)
|
||||||
: Number(row[field.formulaRight || ""] ?? 0);
|
: Number(row[field.formulaRight || ""] ?? 0);
|
||||||
const leftVal = Number(row[field.formulaLeft] ?? 0);
|
const leftVal = Number(row[field.formulaLeft] ?? 0);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
|
import { ChevronDown, ChevronRight, Plus, Trash2, Database, Check } from "lucide-react";
|
||||||
|
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
|
||||||
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";
|
||||||
|
|
@ -135,6 +136,7 @@ const COLOR_OPTIONS = [
|
||||||
|
|
||||||
export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) {
|
export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) {
|
||||||
const [activeTab, setActiveTab] = useState<"basic" | "template">("basic");
|
const [activeTab, setActiveTab] = useState<"basic" | "template">("basic");
|
||||||
|
const sections = useCollapsibleSections("pop-card-list");
|
||||||
|
|
||||||
const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
|
const cfg: PopCardListConfig = config || DEFAULT_CONFIG;
|
||||||
|
|
||||||
|
|
@ -184,6 +186,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
|
||||||
onUpdate={updateConfig}
|
onUpdate={updateConfig}
|
||||||
currentMode={currentMode}
|
currentMode={currentMode}
|
||||||
currentColSpan={currentColSpan}
|
currentColSpan={currentColSpan}
|
||||||
|
sections={sections}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "template" && (
|
{activeTab === "template" && (
|
||||||
|
|
@ -195,7 +198,7 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<CardTemplateTab config={cfg} onUpdate={updateConfig} />
|
<CardTemplateTab config={cfg} onUpdate={updateConfig} sections={sections} />
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,16 +208,20 @@ export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentC
|
||||||
|
|
||||||
// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
|
// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) =====
|
||||||
|
|
||||||
|
type SectionsApi = { isOpen: (key: string) => boolean; toggle: (key: string) => void };
|
||||||
|
|
||||||
function BasicSettingsTab({
|
function BasicSettingsTab({
|
||||||
config,
|
config,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
currentMode,
|
currentMode,
|
||||||
currentColSpan,
|
currentColSpan,
|
||||||
|
sections,
|
||||||
}: {
|
}: {
|
||||||
config: PopCardListConfig;
|
config: PopCardListConfig;
|
||||||
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
||||||
currentMode?: GridMode;
|
currentMode?: GridMode;
|
||||||
currentColSpan?: number;
|
currentColSpan?: number;
|
||||||
|
sections: SectionsApi;
|
||||||
}) {
|
}) {
|
||||||
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
||||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
|
@ -321,7 +328,7 @@ function BasicSettingsTab({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 장바구니 목록 모드 */}
|
{/* 장바구니 목록 모드 */}
|
||||||
<CollapsibleSection title="장바구니 목록 모드" defaultOpen={isCartListMode}>
|
<CollapsibleSection sectionKey="basic-cart-mode" title="장바구니 목록 모드" sections={sections}>
|
||||||
<CartListModeSection
|
<CartListModeSection
|
||||||
cartListMode={config.cartListMode}
|
cartListMode={config.cartListMode}
|
||||||
onUpdate={(cartListMode) => onUpdate({ cartListMode })}
|
onUpdate={(cartListMode) => onUpdate({ cartListMode })}
|
||||||
|
|
@ -330,7 +337,7 @@ function BasicSettingsTab({
|
||||||
|
|
||||||
{/* 테이블 선택 (장바구니 모드 시 숨김) */}
|
{/* 테이블 선택 (장바구니 모드 시 숨김) */}
|
||||||
{!isCartListMode && (
|
{!isCartListMode && (
|
||||||
<CollapsibleSection title="테이블 선택" defaultOpen>
|
<CollapsibleSection sectionKey="basic-table" title="테이블 선택" sections={sections}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-[10px] text-muted-foreground">데이터 테이블</Label>
|
<Label className="text-[10px] text-muted-foreground">데이터 테이블</Label>
|
||||||
|
|
@ -365,7 +372,9 @@ function BasicSettingsTab({
|
||||||
{/* 조인 설정 (장바구니 모드 시 숨김) */}
|
{/* 조인 설정 (장바구니 모드 시 숨김) */}
|
||||||
{!isCartListMode && dataSource.tableName && (
|
{!isCartListMode && dataSource.tableName && (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
|
sectionKey="basic-join"
|
||||||
title="조인 설정"
|
title="조인 설정"
|
||||||
|
sections={sections}
|
||||||
badge={
|
badge={
|
||||||
dataSource.joins && dataSource.joins.length > 0
|
dataSource.joins && dataSource.joins.length > 0
|
||||||
? `${dataSource.joins.length}개`
|
? `${dataSource.joins.length}개`
|
||||||
|
|
@ -383,7 +392,9 @@ function BasicSettingsTab({
|
||||||
{/* 정렬 기준 (장바구니 모드 시 숨김) */}
|
{/* 정렬 기준 (장바구니 모드 시 숨김) */}
|
||||||
{!isCartListMode && dataSource.tableName && (
|
{!isCartListMode && dataSource.tableName && (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
|
sectionKey="basic-sort"
|
||||||
title="정렬 기준"
|
title="정렬 기준"
|
||||||
|
sections={sections}
|
||||||
badge={
|
badge={
|
||||||
dataSource.sort
|
dataSource.sort
|
||||||
? Array.isArray(dataSource.sort)
|
? Array.isArray(dataSource.sort)
|
||||||
|
|
@ -403,7 +414,9 @@ function BasicSettingsTab({
|
||||||
{/* 필터 기준 (장바구니 모드 시 숨김) */}
|
{/* 필터 기준 (장바구니 모드 시 숨김) */}
|
||||||
{!isCartListMode && dataSource.tableName && (
|
{!isCartListMode && dataSource.tableName && (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
|
sectionKey="basic-filter"
|
||||||
title="필터 기준"
|
title="필터 기준"
|
||||||
|
sections={sections}
|
||||||
badge={
|
badge={
|
||||||
dataSource.filters && dataSource.filters.length > 0
|
dataSource.filters && dataSource.filters.length > 0
|
||||||
? `${dataSource.filters.length}개`
|
? `${dataSource.filters.length}개`
|
||||||
|
|
@ -421,7 +434,9 @@ function BasicSettingsTab({
|
||||||
{/* 저장 매핑 (장바구니 모드일 때만) */}
|
{/* 저장 매핑 (장바구니 모드일 때만) */}
|
||||||
{isCartListMode && (
|
{isCartListMode && (
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
|
sectionKey="basic-save-mapping"
|
||||||
title="저장 매핑"
|
title="저장 매핑"
|
||||||
|
sections={sections}
|
||||||
badge={
|
badge={
|
||||||
config.saveMapping?.mappings && config.saveMapping.mappings.length > 0
|
config.saveMapping?.mappings && config.saveMapping.mappings.length > 0
|
||||||
? `${config.saveMapping.mappings.length}개`
|
? `${config.saveMapping.mappings.length}개`
|
||||||
|
|
@ -437,7 +452,7 @@ function BasicSettingsTab({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 레이아웃 설정 */}
|
{/* 레이아웃 설정 */}
|
||||||
<CollapsibleSection title="레이아웃 설정" defaultOpen>
|
<CollapsibleSection sectionKey="basic-layout" title="레이아웃 설정" sections={sections}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{modeLabel && (
|
{modeLabel && (
|
||||||
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5">
|
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2.5 py-1.5">
|
||||||
|
|
@ -526,9 +541,11 @@ function BasicSettingsTab({
|
||||||
function CardTemplateTab({
|
function CardTemplateTab({
|
||||||
config,
|
config,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
sections,
|
||||||
}: {
|
}: {
|
||||||
config: PopCardListConfig;
|
config: PopCardListConfig;
|
||||||
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
onUpdate: (partial: Partial<PopCardListConfig>) => void;
|
||||||
|
sections: SectionsApi;
|
||||||
}) {
|
}) {
|
||||||
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
const dataSource = config.dataSource || DEFAULT_DATA_SOURCE;
|
||||||
const template = config.cardTemplate || DEFAULT_TEMPLATE;
|
const template = config.cardTemplate || DEFAULT_TEMPLATE;
|
||||||
|
|
@ -634,7 +651,7 @@ function CardTemplateTab({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 헤더 설정 */}
|
{/* 헤더 설정 */}
|
||||||
<CollapsibleSection title="헤더 설정" defaultOpen>
|
<CollapsibleSection sectionKey="tpl-header" title="헤더 설정" sections={sections}>
|
||||||
<HeaderSettingsSection
|
<HeaderSettingsSection
|
||||||
header={template.header || DEFAULT_HEADER}
|
header={template.header || DEFAULT_HEADER}
|
||||||
columnGroups={columnGroups}
|
columnGroups={columnGroups}
|
||||||
|
|
@ -643,7 +660,7 @@ function CardTemplateTab({
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* 이미지 설정 */}
|
{/* 이미지 설정 */}
|
||||||
<CollapsibleSection title="이미지 설정" defaultOpen>
|
<CollapsibleSection sectionKey="tpl-image" title="이미지 설정" sections={sections}>
|
||||||
<ImageSettingsSection
|
<ImageSettingsSection
|
||||||
image={template.image || DEFAULT_IMAGE}
|
image={template.image || DEFAULT_IMAGE}
|
||||||
columnGroups={columnGroups}
|
columnGroups={columnGroups}
|
||||||
|
|
@ -653,9 +670,10 @@ function CardTemplateTab({
|
||||||
|
|
||||||
{/* 본문 필드 */}
|
{/* 본문 필드 */}
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
|
sectionKey="tpl-body"
|
||||||
title="본문 필드"
|
title="본문 필드"
|
||||||
|
sections={sections}
|
||||||
badge={`${template.body?.fields?.length || 0}개`}
|
badge={`${template.body?.fields?.length || 0}개`}
|
||||||
defaultOpen
|
|
||||||
>
|
>
|
||||||
<BodyFieldsSection
|
<BodyFieldsSection
|
||||||
body={template.body || DEFAULT_BODY}
|
body={template.body || DEFAULT_BODY}
|
||||||
|
|
@ -665,7 +683,7 @@ function CardTemplateTab({
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* 입력 필드 설정 */}
|
{/* 입력 필드 설정 */}
|
||||||
<CollapsibleSection title="입력 필드" defaultOpen={false}>
|
<CollapsibleSection sectionKey="tpl-input" title="입력 필드" sections={sections}>
|
||||||
<InputFieldSettingsSection
|
<InputFieldSettingsSection
|
||||||
inputField={config.inputField}
|
inputField={config.inputField}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
|
|
@ -675,7 +693,7 @@ function CardTemplateTab({
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* 포장등록 설정 */}
|
{/* 포장등록 설정 */}
|
||||||
<CollapsibleSection title="포장등록 (계산기)" defaultOpen={false}>
|
<CollapsibleSection sectionKey="tpl-package" title="포장등록 (계산기)" sections={sections}>
|
||||||
<PackageSettingsSection
|
<PackageSettingsSection
|
||||||
packageConfig={config.packageConfig}
|
packageConfig={config.packageConfig}
|
||||||
onUpdate={(packageConfig) => onUpdate({ packageConfig })}
|
onUpdate={(packageConfig) => onUpdate({ packageConfig })}
|
||||||
|
|
@ -683,7 +701,7 @@ function CardTemplateTab({
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* 담기 버튼 설정 */}
|
{/* 담기 버튼 설정 */}
|
||||||
<CollapsibleSection title="담기 버튼" defaultOpen={false}>
|
<CollapsibleSection sectionKey="tpl-cart" title="담기 버튼" sections={sections}>
|
||||||
<CartActionSettingsSection
|
<CartActionSettingsSection
|
||||||
cartAction={config.cartAction}
|
cartAction={config.cartAction}
|
||||||
onUpdate={(cartAction) => onUpdate({ cartAction })}
|
onUpdate={(cartAction) => onUpdate({ cartAction })}
|
||||||
|
|
@ -693,7 +711,7 @@ function CardTemplateTab({
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
{/* 반응형 표시 설정 */}
|
{/* 반응형 표시 설정 */}
|
||||||
<CollapsibleSection title="반응형 표시" defaultOpen={false}>
|
<CollapsibleSection sectionKey="tpl-responsive" title="반응형 표시" sections={sections}>
|
||||||
<ResponsiveDisplaySection
|
<ResponsiveDisplaySection
|
||||||
config={config}
|
config={config}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
|
|
@ -769,24 +787,26 @@ function GroupedColumnSelect({
|
||||||
// ===== 접기/펴기 섹션 컴포넌트 =====
|
// ===== 접기/펴기 섹션 컴포넌트 =====
|
||||||
|
|
||||||
function CollapsibleSection({
|
function CollapsibleSection({
|
||||||
|
sectionKey,
|
||||||
title,
|
title,
|
||||||
badge,
|
badge,
|
||||||
defaultOpen = false,
|
sections,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
|
sectionKey: string;
|
||||||
title: string;
|
title: string;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
defaultOpen?: boolean;
|
sections: SectionsApi;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const open = sections.isOpen(sectionKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
|
className="flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-muted/50"
|
||||||
onClick={() => setOpen(!open)}
|
onClick={() => sections.toggle(sectionKey)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{open ? (
|
{open ? (
|
||||||
|
|
@ -2784,6 +2804,13 @@ function SaveMappingSection({
|
||||||
label: f.label || f.columnName,
|
label: f.label || f.columnName,
|
||||||
badge: "본문",
|
badge: "본문",
|
||||||
});
|
});
|
||||||
|
} else if (f.valueType === "formula" && f.label) {
|
||||||
|
const formulaKey = `__formula_${f.id || f.label}`;
|
||||||
|
displayed.push({
|
||||||
|
sourceField: formulaKey,
|
||||||
|
label: f.label,
|
||||||
|
badge: "수식",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (inputFieldConfig?.enabled) {
|
if (inputFieldConfig?.enabled) {
|
||||||
|
|
@ -2855,6 +2882,21 @@ function SaveMappingSection({
|
||||||
[mapping.mappings]
|
[mapping.mappings]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 카드에 표시된 필드가 로드되면 매핑에 누락된 필드를 자동 추가 (매핑 안함으로)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapping.targetTable || cardDisplayedFields.length === 0) return;
|
||||||
|
const existing = new Set(mapping.mappings.map((m) => m.sourceField));
|
||||||
|
const missing = cardDisplayedFields.filter((f) => !existing.has(f.sourceField));
|
||||||
|
if (missing.length === 0) return;
|
||||||
|
onUpdate({
|
||||||
|
...mapping,
|
||||||
|
mappings: [
|
||||||
|
...mapping.mappings,
|
||||||
|
...missing.map((f) => ({ sourceField: f.sourceField, targetColumn: "" })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}, [cardDisplayedFields]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 카드에 표시된 필드 중 아직 매핑되지 않은 것
|
// 카드에 표시된 필드 중 아직 매핑되지 않은 것
|
||||||
const unmappedCardFields = useMemo(
|
const unmappedCardFields = useMemo(
|
||||||
() => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)),
|
() => cardDisplayedFields.filter((f) => !mappedSourceFields.has(f.sourceField)),
|
||||||
|
|
@ -2937,7 +2979,7 @@ function SaveMappingSection({
|
||||||
</div>
|
</div>
|
||||||
{isCartMeta(entry.sourceField) ? (
|
{isCartMeta(entry.sourceField) ? (
|
||||||
!badge && <span className="text-[9px] text-muted-foreground">장바구니</span>
|
!badge && <span className="text-[9px] text-muted-foreground">장바구니</span>
|
||||||
) : (
|
) : entry.sourceField.startsWith("__formula_") ? null : (
|
||||||
<span className="truncate text-[9px] text-muted-foreground">
|
<span className="truncate text-[9px] text-muted-foreground">
|
||||||
{entry.sourceField}
|
{entry.sourceField}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -1937,7 +1937,7 @@ function PageEditor({
|
||||||
isPreviewing?: boolean;
|
isPreviewing?: boolean;
|
||||||
onUpdateItem?: (updatedItem: DashboardItem) => void;
|
onUpdateItem?: (updatedItem: DashboardItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(true);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border p-2">
|
<div className="rounded-md border p-2">
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import type {
|
||||||
FieldSectionStyle,
|
FieldSectionStyle,
|
||||||
PopFieldReadSource,
|
PopFieldReadSource,
|
||||||
PopFieldAutoGenMapping,
|
PopFieldAutoGenMapping,
|
||||||
|
SelectLinkedFilter,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import type { CollectDataRequest, CollectedDataResponse } 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";
|
||||||
|
|
@ -60,6 +61,16 @@ export function PopFieldComponent({
|
||||||
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
||||||
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
|
const visibleAutoGens = autoGenMappings.filter((m) => m.showInForm);
|
||||||
|
|
||||||
|
const fieldIdToName = useMemo(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
for (const section of cfg.sections) {
|
||||||
|
for (const f of section.fields ?? []) {
|
||||||
|
map[f.id] = f.fieldName || f.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [cfg.sections]);
|
||||||
|
|
||||||
// ResizeObserver로 컨테이너 너비 감시
|
// ResizeObserver로 컨테이너 너비 감시
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined" || !containerRef.current) return;
|
if (typeof window === "undefined" || !containerRef.current) return;
|
||||||
|
|
@ -211,6 +222,23 @@ export function PopFieldComponent({
|
||||||
columnMapping: Object.fromEntries(
|
columnMapping: Object.fromEntries(
|
||||||
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
|
(cfg.saveConfig.fieldMappings || []).map((m) => [m.fieldId, m.targetColumn])
|
||||||
),
|
),
|
||||||
|
autoGenMappings: (cfg.saveConfig.autoGenMappings || [])
|
||||||
|
.filter((m) => m.numberingRuleId)
|
||||||
|
.map((m) => ({
|
||||||
|
numberingRuleId: m.numberingRuleId!,
|
||||||
|
targetColumn: m.targetColumn,
|
||||||
|
showResultModal: m.showResultModal,
|
||||||
|
})),
|
||||||
|
hiddenMappings: (cfg.saveConfig.hiddenMappings || [])
|
||||||
|
.filter((m) => m.targetColumn)
|
||||||
|
.map((m) => ({
|
||||||
|
valueSource: m.valueSource,
|
||||||
|
targetColumn: m.targetColumn,
|
||||||
|
staticValue: m.staticValue,
|
||||||
|
sourceJsonColumn: m.sourceJsonColumn,
|
||||||
|
sourceJsonKey: m.sourceJsonKey,
|
||||||
|
sourceDbColumn: m.sourceDbColumn,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
|
|
@ -360,6 +388,8 @@ export function PopFieldComponent({
|
||||||
error={errors[fKey]}
|
error={errors[fKey]}
|
||||||
onChange={handleFieldChange}
|
onChange={handleFieldChange}
|
||||||
sectionStyle={section.style}
|
sectionStyle={section.style}
|
||||||
|
allValues={allValues}
|
||||||
|
fieldIdToName={fieldIdToName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -394,6 +424,8 @@ interface FieldRendererProps {
|
||||||
error?: string;
|
error?: string;
|
||||||
onChange: (fieldName: string, value: unknown) => void;
|
onChange: (fieldName: string, value: unknown) => void;
|
||||||
sectionStyle: FieldSectionStyle;
|
sectionStyle: FieldSectionStyle;
|
||||||
|
allValues?: Record<string, unknown>;
|
||||||
|
fieldIdToName?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldRenderer({
|
function FieldRenderer({
|
||||||
|
|
@ -403,6 +435,8 @@ function FieldRenderer({
|
||||||
error,
|
error,
|
||||||
onChange,
|
onChange,
|
||||||
sectionStyle,
|
sectionStyle,
|
||||||
|
allValues,
|
||||||
|
fieldIdToName,
|
||||||
}: FieldRendererProps) {
|
}: FieldRendererProps) {
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(v: unknown) => onChange(field.fieldName, v),
|
(v: unknown) => onChange(field.fieldName, v),
|
||||||
|
|
@ -429,7 +463,7 @@ function FieldRenderer({
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{renderByType(field, value, handleChange, inputClassName)}
|
{renderByType(field, value, handleChange, inputClassName, allValues, fieldIdToName)}
|
||||||
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
{error && <p className="text-[10px] text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -443,7 +477,9 @@ function renderByType(
|
||||||
field: PopFieldItem,
|
field: PopFieldItem,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
onChange: (v: unknown) => void,
|
onChange: (v: unknown) => void,
|
||||||
className: string
|
className: string,
|
||||||
|
allValues?: Record<string, unknown>,
|
||||||
|
fieldIdToName?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
switch (field.inputType) {
|
switch (field.inputType) {
|
||||||
case "text":
|
case "text":
|
||||||
|
|
@ -482,6 +518,8 @@ function renderByType(
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className={className}
|
className={className}
|
||||||
|
allValues={allValues}
|
||||||
|
fieldIdToName={fieldIdToName}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "auto":
|
case "auto":
|
||||||
|
|
@ -554,11 +592,15 @@ function SelectFieldInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
|
allValues,
|
||||||
|
fieldIdToName,
|
||||||
}: {
|
}: {
|
||||||
field: PopFieldItem;
|
field: PopFieldItem;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onChange: (v: unknown) => void;
|
onChange: (v: unknown) => void;
|
||||||
className: string;
|
className: string;
|
||||||
|
allValues?: Record<string, unknown>;
|
||||||
|
fieldIdToName?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
const [options, setOptions] = useState<{ value: string; label: string }[]>(
|
||||||
[]
|
[]
|
||||||
|
|
@ -566,6 +608,30 @@ function SelectFieldInput({
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const source = field.selectSource;
|
const source = field.selectSource;
|
||||||
|
const linkedFilters = source?.linkedFilters;
|
||||||
|
const hasLinkedFilters = !!linkedFilters?.length;
|
||||||
|
|
||||||
|
// 연동 필터에서 참조하는 필드의 현재 값들을 안정적인 문자열로 직렬화
|
||||||
|
const linkedFilterKey = useMemo(() => {
|
||||||
|
if (!hasLinkedFilters || !allValues || !fieldIdToName) return "";
|
||||||
|
return linkedFilters!
|
||||||
|
.map((lf) => {
|
||||||
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||||
|
const val = allValues[fieldName] ?? "";
|
||||||
|
return `${lf.filterColumn}=${String(val)}`;
|
||||||
|
})
|
||||||
|
.join("&");
|
||||||
|
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
|
||||||
|
|
||||||
|
// 연동 필터의 소스 값이 모두 채워졌는지 확인
|
||||||
|
const linkedFiltersFilled = useMemo(() => {
|
||||||
|
if (!hasLinkedFilters || !allValues || !fieldIdToName) return true;
|
||||||
|
return linkedFilters!.every((lf) => {
|
||||||
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||||
|
const val = allValues[fieldName];
|
||||||
|
return val != null && val !== "";
|
||||||
|
});
|
||||||
|
}, [hasLinkedFilters, linkedFilters, allValues, fieldIdToName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
|
|
@ -581,22 +647,44 @@ function SelectFieldInput({
|
||||||
source.valueColumn &&
|
source.valueColumn &&
|
||||||
source.labelColumn
|
source.labelColumn
|
||||||
) {
|
) {
|
||||||
|
// 연동 필터가 있는데 소스 값이 비어있으면 빈 옵션 표시
|
||||||
|
if (hasLinkedFilters && !linkedFiltersFilled) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 동적 필터 구성
|
||||||
|
const dynamicFilters: Record<string, string> = {};
|
||||||
|
if (hasLinkedFilters && allValues && fieldIdToName) {
|
||||||
|
for (const lf of linkedFilters!) {
|
||||||
|
const fieldName = fieldIdToName[lf.sourceFieldId] ?? lf.sourceFieldId;
|
||||||
|
const val = allValues[fieldName];
|
||||||
|
if (val != null && val !== "" && lf.filterColumn) {
|
||||||
|
dynamicFilters[lf.filterColumn] = String(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
dataApi
|
dataApi
|
||||||
.getTableData(source.tableName, {
|
.getTableData(source.tableName, {
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 500,
|
size: 500,
|
||||||
sortColumn: source.labelColumn,
|
sortBy: source.labelColumn,
|
||||||
sortDirection: "asc",
|
sortOrder: "asc",
|
||||||
|
...(Object.keys(dynamicFilters).length > 0 ? { filters: dynamicFilters } : {}),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.data?.success && Array.isArray(res.data.data?.data)) {
|
if (Array.isArray(res.data)) {
|
||||||
setOptions(
|
const seen = new Set<string>();
|
||||||
res.data.data.data.map((row: Record<string, unknown>) => ({
|
const deduped: { value: string; label: string }[] = [];
|
||||||
value: String(row[source.valueColumn!] ?? ""),
|
for (const row of res.data) {
|
||||||
label: String(row[source.labelColumn!] ?? ""),
|
const v = String(row[source.valueColumn!] ?? "");
|
||||||
}))
|
if (!v || seen.has(v)) continue;
|
||||||
);
|
seen.add(v);
|
||||||
|
deduped.push({ value: v, label: String(row[source.labelColumn!] ?? "") });
|
||||||
|
}
|
||||||
|
setOptions(deduped);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|
@ -604,7 +692,16 @@ function SelectFieldInput({
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions]);
|
}, [source?.type, source?.tableName, source?.valueColumn, source?.labelColumn, source?.staticOptions, linkedFilterKey, linkedFiltersFilled]);
|
||||||
|
|
||||||
|
// W3: 옵션이 바뀌었을 때 현재 선택값이 유효하지 않으면 자동 초기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasLinkedFilters || !value || loading) return;
|
||||||
|
const currentStr = String(value);
|
||||||
|
if (options.length > 0 && !options.some((o) => o.value === currentStr)) {
|
||||||
|
onChange("");
|
||||||
|
}
|
||||||
|
}, [options, hasLinkedFilters]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -631,6 +728,11 @@ function SelectFieldInput({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// W2: 연동 필터의 소스 값이 비어있으면 안내 메시지
|
||||||
|
const emptyMessage = hasLinkedFilters && !linkedFiltersFilled
|
||||||
|
? "상위 필드를 먼저 선택하세요"
|
||||||
|
: "옵션이 없습니다";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
value={String(value ?? "")}
|
value={String(value ?? "")}
|
||||||
|
|
@ -642,7 +744,7 @@ function SelectFieldInput({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{options.length === 0 ? (
|
{options.length === 0 ? (
|
||||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
옵션이 없습니다
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
options.map((opt) => (
|
options.map((opt) => (
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { useCollapsibleSections } from "@/hooks/pop/useCollapsibleSections";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -52,6 +53,7 @@ import type {
|
||||||
FieldSectionStyle,
|
FieldSectionStyle,
|
||||||
FieldSectionAppearance,
|
FieldSectionAppearance,
|
||||||
FieldSelectSource,
|
FieldSelectSource,
|
||||||
|
SelectLinkedFilter,
|
||||||
AutoNumberConfig,
|
AutoNumberConfig,
|
||||||
FieldValueSource,
|
FieldValueSource,
|
||||||
PopFieldSaveMapping,
|
PopFieldSaveMapping,
|
||||||
|
|
@ -74,7 +76,7 @@ import {
|
||||||
type ColumnInfo,
|
type ColumnInfo,
|
||||||
} from "../pop-dashboard/utils/dataFetcher";
|
} from "../pop-dashboard/utils/dataFetcher";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import { getAvailableNumberingRulesForScreen } from "@/lib/api/numberingRule";
|
import { getAvailableNumberingRulesForScreen, getNumberingRules } from "@/lib/api/numberingRule";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
|
|
@ -213,6 +215,7 @@ export function PopFieldConfigPanel({
|
||||||
onUpdate={(partial) => updateSection(section.id, partial)}
|
onUpdate={(partial) => updateSection(section.id, partial)}
|
||||||
onRemove={() => removeSection(section.id)}
|
onRemove={() => removeSection(section.id)}
|
||||||
onMoveUp={() => moveSectionUp(idx)}
|
onMoveUp={() => moveSectionUp(idx)}
|
||||||
|
allSections={cfg.sections}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -462,6 +465,8 @@ function SaveTabContent({
|
||||||
// --- 자동생성 필드 로직 ---
|
// --- 자동생성 필드 로직 ---
|
||||||
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
const autoGenMappings = cfg.saveConfig?.autoGenMappings ?? [];
|
||||||
const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]);
|
const [numberingRules, setNumberingRules] = useState<{ ruleId: string; ruleName: string }[]>([]);
|
||||||
|
const [allNumberingRules, setAllNumberingRules] = useState<{ ruleId: string; ruleName: string; tableName: string }[]>([]);
|
||||||
|
const [showAllRules, setShowAllRules] = useState(false);
|
||||||
|
|
||||||
// 레이아웃 auto 필드 → autoGenMappings 자동 동기화
|
// 레이아웃 auto 필드 → autoGenMappings 자동 동기화
|
||||||
const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(",");
|
const autoFieldIdsKey = autoInputFields.map(({ field }) => field.id).join(",");
|
||||||
|
|
@ -478,7 +483,7 @@ function SaveTabContent({
|
||||||
label: field.labelText || "",
|
label: field.labelText || "",
|
||||||
targetColumn: "",
|
targetColumn: "",
|
||||||
numberingRuleId: field.autoNumber?.numberingRuleId ?? "",
|
numberingRuleId: field.autoNumber?.numberingRuleId ?? "",
|
||||||
showInForm: true,
|
showInForm: false,
|
||||||
showResultModal: true,
|
showResultModal: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -513,6 +518,24 @@ function SaveTabContent({
|
||||||
}
|
}
|
||||||
}, [saveTableName]);
|
}, [saveTableName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showAllRules) return;
|
||||||
|
if (allNumberingRules.length > 0) return;
|
||||||
|
getNumberingRules()
|
||||||
|
.then((res) => {
|
||||||
|
if (res.success && Array.isArray(res.data)) {
|
||||||
|
setAllNumberingRules(
|
||||||
|
res.data.map((r: any) => ({
|
||||||
|
ruleId: String(r.ruleId ?? r.rule_id ?? ""),
|
||||||
|
ruleName: String(r.ruleName ?? r.rule_name ?? ""),
|
||||||
|
tableName: String(r.tableName ?? r.table_name ?? ""),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setAllNumberingRules([]));
|
||||||
|
}, [showAllRules, allNumberingRules.length]);
|
||||||
|
|
||||||
const addAutoGenMapping = useCallback(() => {
|
const addAutoGenMapping = useCallback(() => {
|
||||||
const newMapping: PopFieldAutoGenMapping = {
|
const newMapping: PopFieldAutoGenMapping = {
|
||||||
id: `autogen_${Date.now()}`,
|
id: `autogen_${Date.now()}`,
|
||||||
|
|
@ -626,10 +649,7 @@ function SaveTabContent({
|
||||||
|
|
||||||
const noFields = allFields.length === 0;
|
const noFields = allFields.length === 0;
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
const sections = useCollapsibleSections("pop-field");
|
||||||
const toggleSection = useCallback((key: string) => {
|
|
||||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -644,16 +664,16 @@ function SaveTabContent({
|
||||||
<div className="rounded-md border bg-card">
|
<div className="rounded-md border bg-card">
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||||
onClick={() => toggleSection("table")}
|
onClick={() => sections.toggle("table")}
|
||||||
>
|
>
|
||||||
{collapsed["table"] ? (
|
{sections.isOpen("table") ? (
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium">테이블 설정</span>
|
<span className="text-xs font-medium">테이블 설정</span>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed["table"] && <div className="space-y-3 border-t p-3">
|
{sections.isOpen("table") && <div className="space-y-3 border-t p-3">
|
||||||
{/* 읽기 테이블 (display 섹션이 있을 때만) */}
|
{/* 읽기 테이블 (display 섹션이 있을 때만) */}
|
||||||
{hasDisplayFields && (
|
{hasDisplayFields && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -817,19 +837,19 @@ function SaveTabContent({
|
||||||
<div className="rounded-md border bg-card">
|
<div className="rounded-md border bg-card">
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||||
onClick={() => toggleSection("read")}
|
onClick={() => sections.toggle("read")}
|
||||||
>
|
>
|
||||||
{collapsed["read"] ? (
|
{sections.isOpen("read") ? (
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium">읽기 필드</span>
|
<span className="text-xs font-medium">읽기 필드</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
(읽기 폼)
|
(읽기 폼)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed["read"] && <div className="space-y-2 border-t p-3">
|
{sections.isOpen("read") && <div className="space-y-2 border-t p-3">
|
||||||
{readColumns.length === 0 ? (
|
{readColumns.length === 0 ? (
|
||||||
<p className="py-2 text-xs text-muted-foreground">
|
<p className="py-2 text-xs text-muted-foreground">
|
||||||
읽기 테이블의 컬럼을 불러오는 중...
|
읽기 테이블의 컬럼을 불러오는 중...
|
||||||
|
|
@ -966,19 +986,19 @@ function SaveTabContent({
|
||||||
<div className="rounded-md border bg-card">
|
<div className="rounded-md border bg-card">
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||||
onClick={() => toggleSection("input")}
|
onClick={() => sections.toggle("input")}
|
||||||
>
|
>
|
||||||
{collapsed["input"] ? (
|
{sections.isOpen("input") ? (
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium">입력 필드</span>
|
<span className="text-xs font-medium">입력 필드</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
(입력 폼 → 저장)
|
(입력 폼 → 저장)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed["input"] && <div className="space-y-2 border-t p-3">
|
{sections.isOpen("input") && <div className="space-y-2 border-t p-3">
|
||||||
{saveColumns.length === 0 ? (
|
{saveColumns.length === 0 ? (
|
||||||
<p className="py-2 text-xs text-muted-foreground">
|
<p className="py-2 text-xs text-muted-foreground">
|
||||||
저장 테이블의 컬럼을 불러오는 중...
|
저장 테이블의 컬럼을 불러오는 중...
|
||||||
|
|
@ -1028,21 +1048,23 @@ function SaveTabContent({
|
||||||
<div className="rounded-md border bg-card">
|
<div className="rounded-md border bg-card">
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||||
onClick={() => toggleSection("hidden")}
|
onClick={() => sections.toggle("hidden")}
|
||||||
>
|
>
|
||||||
{collapsed["hidden"] ? (
|
{sections.isOpen("hidden") ? (
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium">숨은 필드</span>
|
<span className="text-xs font-medium">숨은 필드</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
(UI 미표시, 전달 데이터에서 추출하여 저장)
|
(UI 미표시, 전달 데이터에서 추출하여 저장)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed["hidden"] && <div className="space-y-3 border-t p-3">
|
{sections.isOpen("hidden") && <div className="space-y-3 border-t p-3">
|
||||||
{hiddenMappings.map((m) => {
|
{hiddenMappings.map((m) => {
|
||||||
const isJson = m.valueSource === "json_extract";
|
const isJson = m.valueSource === "json_extract";
|
||||||
|
const isStatic = m.valueSource === "static";
|
||||||
|
const isDbColumn = m.valueSource === "db_column";
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
|
<div key={m.id} className="space-y-1.5 rounded border bg-background p-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1070,6 +1092,7 @@ function SaveTabContent({
|
||||||
sourceDbColumn: undefined,
|
sourceDbColumn: undefined,
|
||||||
sourceJsonColumn: undefined,
|
sourceJsonColumn: undefined,
|
||||||
sourceJsonKey: undefined,
|
sourceJsonKey: undefined,
|
||||||
|
staticValue: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -1079,10 +1102,10 @@ function SaveTabContent({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="db_column" className="text-xs">DB 컬럼</SelectItem>
|
<SelectItem value="db_column" className="text-xs">DB 컬럼</SelectItem>
|
||||||
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
|
<SelectItem value="json_extract" className="text-xs">JSON</SelectItem>
|
||||||
|
<SelectItem value="static" className="text-xs">고정값</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{!isJson && (
|
{isDbColumn && (
|
||||||
<>
|
|
||||||
<Select
|
<Select
|
||||||
value={m.sourceDbColumn || "__none__"}
|
value={m.sourceDbColumn || "__none__"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
|
|
@ -1101,7 +1124,14 @@ function SaveTabContent({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</>
|
)}
|
||||||
|
{isStatic && (
|
||||||
|
<Input
|
||||||
|
value={m.staticValue || ""}
|
||||||
|
onChange={(e) => updateHiddenMapping(m.id, { staticValue: e.target.value })}
|
||||||
|
placeholder="고정값 입력"
|
||||||
|
className="h-7 flex-1 text-xs"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isJson && (
|
{isJson && (
|
||||||
|
|
@ -1183,19 +1213,19 @@ function SaveTabContent({
|
||||||
<div className="rounded-md border bg-card">
|
<div className="rounded-md border bg-card">
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
className="flex cursor-pointer items-center gap-1.5 px-3 py-2"
|
||||||
onClick={() => toggleSection("autogen")}
|
onClick={() => sections.toggle("autogen")}
|
||||||
>
|
>
|
||||||
{collapsed["autogen"] ? (
|
{sections.isOpen("autogen") ? (
|
||||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="text-xs font-medium">자동생성 필드</span>
|
<span className="text-xs font-medium">자동생성 필드</span>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
(저장 시 서버에서 채번)
|
(저장 시 서버에서 채번)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed["autogen"] && <div className="space-y-3 border-t p-3">
|
{sections.isOpen("autogen") && <div className="space-y-3 border-t p-3">
|
||||||
{autoGenMappings.map((m) => {
|
{autoGenMappings.map((m) => {
|
||||||
const isLinked = !!m.linkedFieldId;
|
const isLinked = !!m.linkedFieldId;
|
||||||
return (
|
return (
|
||||||
|
|
@ -1248,7 +1278,19 @@ function SaveTabContent({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-[10px]">채번 규칙</Label>
|
<Label className="text-[10px]">채번 규칙</Label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Switch
|
||||||
|
checked={showAllRules}
|
||||||
|
onCheckedChange={setShowAllRules}
|
||||||
|
className="h-3.5 w-7 data-[state=checked]:bg-primary [&>span]:h-2.5 [&>span]:w-2.5"
|
||||||
|
/>
|
||||||
|
<Label className="cursor-pointer text-[10px] text-muted-foreground" onClick={() => setShowAllRules(!showAllRules)}>
|
||||||
|
전체 보기
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={m.numberingRuleId || "__none__"}
|
value={m.numberingRuleId || "__none__"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) =>
|
||||||
|
|
@ -1262,11 +1304,19 @@ function SaveTabContent({
|
||||||
<SelectItem value="__none__" className="text-xs">
|
<SelectItem value="__none__" className="text-xs">
|
||||||
선택
|
선택
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{numberingRules.map((r) => (
|
{showAllRules
|
||||||
|
? allNumberingRules.map((r) => (
|
||||||
|
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
|
||||||
|
{r.ruleName || r.ruleId}
|
||||||
|
<span className="ml-1 text-muted-foreground">({r.tableName || "-"})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
: numberingRules.map((r) => (
|
||||||
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
|
<SelectItem key={r.ruleId} value={r.ruleId} className="text-xs">
|
||||||
{r.ruleName || r.ruleId}
|
{r.ruleName || r.ruleId}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1325,6 +1375,7 @@ interface SectionEditorProps {
|
||||||
onUpdate: (partial: Partial<PopFieldSection>) => void;
|
onUpdate: (partial: Partial<PopFieldSection>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onMoveUp: () => void;
|
onMoveUp: () => void;
|
||||||
|
allSections: PopFieldSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function migrateStyle(style: string): FieldSectionStyle {
|
function migrateStyle(style: string): FieldSectionStyle {
|
||||||
|
|
@ -1341,8 +1392,9 @@ function SectionEditor({
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
|
allSections,
|
||||||
}: SectionEditorProps) {
|
}: SectionEditorProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const resolvedStyle = migrateStyle(section.style);
|
const resolvedStyle = migrateStyle(section.style);
|
||||||
|
|
||||||
const sectionFields = section.fields || [];
|
const sectionFields = section.fields || [];
|
||||||
|
|
@ -1522,6 +1574,7 @@ function SectionEditor({
|
||||||
sectionStyle={resolvedStyle}
|
sectionStyle={resolvedStyle}
|
||||||
onUpdate={(partial) => updateField(field.id, partial)}
|
onUpdate={(partial) => updateField(field.id, partial)}
|
||||||
onRemove={() => removeField(field.id)}
|
onRemove={() => removeField(field.id)}
|
||||||
|
allSections={allSections}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -1549,6 +1602,7 @@ interface FieldItemEditorProps {
|
||||||
sectionStyle?: FieldSectionStyle;
|
sectionStyle?: FieldSectionStyle;
|
||||||
onUpdate: (partial: Partial<PopFieldItem>) => void;
|
onUpdate: (partial: Partial<PopFieldItem>) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
allSections?: PopFieldSection[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function FieldItemEditor({
|
function FieldItemEditor({
|
||||||
|
|
@ -1556,6 +1610,7 @@ function FieldItemEditor({
|
||||||
sectionStyle,
|
sectionStyle,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
allSections,
|
||||||
}: FieldItemEditorProps) {
|
}: FieldItemEditorProps) {
|
||||||
const isDisplay = sectionStyle === "display";
|
const isDisplay = sectionStyle === "display";
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
@ -1645,9 +1700,9 @@ function FieldItemEditor({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 읽기전용 + 필수 (입력 폼에서만 표시) */}
|
{/* 읽기전용 + 필수 + 데이터 연동 (입력 폼에서만 표시) */}
|
||||||
{!isDisplay && (
|
{!isDisplay && (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Switch
|
<Switch
|
||||||
checked={field.readOnly || false}
|
checked={field.readOnly || false}
|
||||||
|
|
@ -1666,6 +1721,29 @@ function FieldItemEditor({
|
||||||
/>
|
/>
|
||||||
<Label className="text-[10px]">필수</Label>
|
<Label className="text-[10px]">필수</Label>
|
||||||
</div>
|
</div>
|
||||||
|
{field.inputType === "select" && field.selectSource?.type === "table" && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Switch
|
||||||
|
checked={!!field.selectSource?.linkedFilters?.length}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const src = field.selectSource ?? { type: "table" as const };
|
||||||
|
if (v) {
|
||||||
|
onUpdate({
|
||||||
|
selectSource: {
|
||||||
|
...src,
|
||||||
|
linkedFilters: [{ sourceFieldId: "", filterColumn: "" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
onUpdate({
|
||||||
|
selectSource: { ...src, linkedFilters: undefined },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label className="text-[10px]">데이터 연동</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1690,13 +1768,32 @@ function FieldItemEditor({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* auto 전용: 채번 설정 */}
|
{/* select + table + 연동 필터 활성화 시 */}
|
||||||
{field.inputType === "auto" && (
|
{field.inputType === "select" &&
|
||||||
<AutoNumberEditor
|
field.selectSource?.type === "table" &&
|
||||||
config={field.autoNumber}
|
field.selectSource?.linkedFilters &&
|
||||||
onUpdate={(autoNumber) => onUpdate({ autoNumber })}
|
field.selectSource.linkedFilters.length > 0 && (
|
||||||
|
<LinkedFiltersEditor
|
||||||
|
linkedFilters={field.selectSource.linkedFilters}
|
||||||
|
tableName={field.selectSource.tableName || ""}
|
||||||
|
currentFieldId={field.id}
|
||||||
|
allSections={allSections || []}
|
||||||
|
onUpdate={(filters) =>
|
||||||
|
onUpdate({
|
||||||
|
selectSource: { ...field.selectSource!, linkedFilters: filters },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* auto 전용: 저장 탭에서 채번 규칙을 연결하라는 안내 */}
|
||||||
|
{field.inputType === "auto" && (
|
||||||
|
<div className="rounded border bg-muted/30 p-2">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
채번 규칙은 [저장] 탭 > 자동생성 필드에서 설정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1948,108 +2045,7 @@ function TableSourceEditor({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// AutoNumberEditor 삭제됨: 채번 규칙은 저장 탭 > 자동생성 필드에서 관리
|
||||||
// AutoNumberEditor: 자동 채번 설정
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function AutoNumberEditor({
|
|
||||||
config,
|
|
||||||
onUpdate,
|
|
||||||
}: {
|
|
||||||
config?: AutoNumberConfig;
|
|
||||||
onUpdate: (config: AutoNumberConfig) => void;
|
|
||||||
}) {
|
|
||||||
const current: AutoNumberConfig = config || {
|
|
||||||
prefix: "",
|
|
||||||
dateFormat: "YYYYMMDD",
|
|
||||||
separator: "-",
|
|
||||||
sequenceDigits: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 rounded border bg-muted/30 p-2">
|
|
||||||
<Label className="text-[10px] text-muted-foreground">자동 채번 설정</Label>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<Label className="text-[10px]">접두사</Label>
|
|
||||||
<Input
|
|
||||||
value={current.prefix || ""}
|
|
||||||
onChange={(e) => onUpdate({ ...current, prefix: e.target.value })}
|
|
||||||
placeholder="IN-"
|
|
||||||
className="mt-0.5 h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-[10px]">날짜 형식</Label>
|
|
||||||
<Select
|
|
||||||
value={current.dateFormat || "YYYYMMDD"}
|
|
||||||
onValueChange={(v) => onUpdate({ ...current, dateFormat: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="mt-0.5 h-7 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="YYYYMMDD" className="text-xs">
|
|
||||||
YYYYMMDD
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="YYMMDD" className="text-xs">
|
|
||||||
YYMMDD
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="YYMM" className="text-xs">
|
|
||||||
YYMM
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<Label className="text-[10px]">구분자</Label>
|
|
||||||
<Input
|
|
||||||
value={current.separator || ""}
|
|
||||||
onChange={(e) => onUpdate({ ...current, separator: e.target.value })}
|
|
||||||
placeholder="-"
|
|
||||||
className="mt-0.5 h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-[10px]">시퀀스 자릿수</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={current.sequenceDigits || 3}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate({
|
|
||||||
...current,
|
|
||||||
sequenceDigits: Number(e.target.value) || 3,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
min={1}
|
|
||||||
max={10}
|
|
||||||
className="mt-0.5 h-7 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 미리보기 */}
|
|
||||||
<div className="text-[10px] text-muted-foreground">
|
|
||||||
미리보기:{" "}
|
|
||||||
<span className="font-mono">
|
|
||||||
{current.prefix || ""}
|
|
||||||
{current.separator || ""}
|
|
||||||
{current.dateFormat === "YYMM"
|
|
||||||
? "2602"
|
|
||||||
: current.dateFormat === "YYMMDD"
|
|
||||||
? "260226"
|
|
||||||
: "20260226"}
|
|
||||||
{current.separator || ""}
|
|
||||||
{"0".repeat(current.sequenceDigits || 3).slice(0, -1)}1
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// JsonKeySelect: JSON 키 드롭다운 (자동 추출)
|
// JsonKeySelect: JSON 키 드롭다운 (자동 추출)
|
||||||
|
|
@ -2132,6 +2128,118 @@ function JsonKeySelect({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LinkedFiltersEditor: 데이터 연동 필터 설정
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function LinkedFiltersEditor({
|
||||||
|
linkedFilters,
|
||||||
|
tableName,
|
||||||
|
currentFieldId,
|
||||||
|
allSections,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
linkedFilters: SelectLinkedFilter[];
|
||||||
|
tableName: string;
|
||||||
|
currentFieldId: string;
|
||||||
|
allSections: PopFieldSection[];
|
||||||
|
onUpdate: (filters: SelectLinkedFilter[]) => void;
|
||||||
|
}) {
|
||||||
|
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tableName) {
|
||||||
|
fetchTableColumns(tableName).then(setColumns);
|
||||||
|
} else {
|
||||||
|
setColumns([]);
|
||||||
|
}
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
const candidateFields = useMemo(() => {
|
||||||
|
return allSections.flatMap((sec) =>
|
||||||
|
(sec.fields ?? [])
|
||||||
|
.filter((f) => f.id !== currentFieldId)
|
||||||
|
.map((f) => ({ id: f.id, label: f.labelText || f.fieldName || f.id, sectionLabel: sec.label }))
|
||||||
|
);
|
||||||
|
}, [allSections, currentFieldId]);
|
||||||
|
|
||||||
|
const updateFilter = (idx: number, partial: Partial<SelectLinkedFilter>) => {
|
||||||
|
const next = linkedFilters.map((f, i) => (i === idx ? { ...f, ...partial } : f));
|
||||||
|
onUpdate(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFilter = (idx: number) => {
|
||||||
|
const next = linkedFilters.filter((_, i) => i !== idx);
|
||||||
|
onUpdate(next.length > 0 ? next : [{ sourceFieldId: "", filterColumn: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFilter = () => {
|
||||||
|
onUpdate([...linkedFilters, { sourceFieldId: "", filterColumn: "" }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 rounded border bg-muted/30 p-2">
|
||||||
|
<Label className="text-[10px] text-muted-foreground">데이터 연동</Label>
|
||||||
|
{linkedFilters.map((lf, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-1">
|
||||||
|
<Select
|
||||||
|
value={lf.sourceFieldId || "__none__"}
|
||||||
|
onValueChange={(v) => updateFilter(idx, { sourceFieldId: v === "__none__" ? "" : v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="연동 필드" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-xs">필드 선택</SelectItem>
|
||||||
|
{candidateFields.map((cf) => (
|
||||||
|
<SelectItem key={cf.id} value={cf.id} className="text-xs">
|
||||||
|
{cf.sectionLabel ? `[${cf.sectionLabel}] ` : ""}{cf.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-[10px] text-muted-foreground">=</span>
|
||||||
|
<Select
|
||||||
|
value={lf.filterColumn || "__none__"}
|
||||||
|
onValueChange={(v) => updateFilter(idx, { filterColumn: v === "__none__" ? "" : v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||||
|
<SelectValue placeholder="필터 컬럼" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-xs">컬럼 선택</SelectItem>
|
||||||
|
{columns.map((c) => (
|
||||||
|
<SelectItem key={c.name} value={c.name} className="text-xs">
|
||||||
|
{c.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{linkedFilters.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 shrink-0 text-destructive"
|
||||||
|
onClick={() => removeFilter(idx)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-full text-[10px]"
|
||||||
|
onClick={addFilter}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
필터 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// AppearanceEditor: 섹션 외관 설정
|
// AppearanceEditor: 섹션 외관 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ export const DEFAULT_SECTION_APPEARANCES: Record<FieldSectionStyle, FieldSection
|
||||||
|
|
||||||
export type FieldSelectSourceType = "static" | "table";
|
export type FieldSelectSourceType = "static" | "table";
|
||||||
|
|
||||||
|
export interface SelectLinkedFilter {
|
||||||
|
sourceFieldId: string;
|
||||||
|
filterColumn: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FieldSelectSource {
|
export interface FieldSelectSource {
|
||||||
type: FieldSelectSourceType;
|
type: FieldSelectSourceType;
|
||||||
staticOptions?: { value: string; label: string }[];
|
staticOptions?: { value: string; label: string }[];
|
||||||
|
|
@ -51,6 +56,7 @@ export interface FieldSelectSource {
|
||||||
valueColumn?: string;
|
valueColumn?: string;
|
||||||
labelColumn?: string;
|
labelColumn?: string;
|
||||||
filters?: DataSourceFilter[];
|
filters?: DataSourceFilter[];
|
||||||
|
linkedFilters?: SelectLinkedFilter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 자동 채번 설정 =====
|
// ===== 자동 채번 설정 =====
|
||||||
|
|
@ -124,7 +130,7 @@ export interface PopFieldSaveMapping {
|
||||||
|
|
||||||
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
|
// ===== 숨은 필드 매핑 (UI에 미표시, 전달 데이터에서 추출하여 저장) =====
|
||||||
|
|
||||||
export type HiddenValueSource = "json_extract" | "db_column";
|
export type HiddenValueSource = "json_extract" | "db_column" | "static";
|
||||||
|
|
||||||
export interface PopFieldHiddenMapping {
|
export interface PopFieldHiddenMapping {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -133,6 +139,7 @@ export interface PopFieldHiddenMapping {
|
||||||
sourceJsonColumn?: string;
|
sourceJsonColumn?: string;
|
||||||
sourceJsonKey?: string;
|
sourceJsonKey?: string;
|
||||||
sourceDbColumn?: string;
|
sourceDbColumn?: string;
|
||||||
|
staticValue?: string;
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -638,6 +638,19 @@ export interface CollectedDataResponse {
|
||||||
export interface SaveMapping {
|
export interface SaveMapping {
|
||||||
targetTable: string;
|
targetTable: string;
|
||||||
columnMapping: Record<string, string>;
|
columnMapping: Record<string, string>;
|
||||||
|
autoGenMappings?: Array<{
|
||||||
|
numberingRuleId: string;
|
||||||
|
targetColumn: string;
|
||||||
|
showResultModal?: boolean;
|
||||||
|
}>;
|
||||||
|
hiddenMappings?: Array<{
|
||||||
|
valueSource: "json_extract" | "db_column" | "static";
|
||||||
|
targetColumn: string;
|
||||||
|
staticValue?: string;
|
||||||
|
sourceJsonColumn?: string;
|
||||||
|
sourceJsonKey?: string;
|
||||||
|
sourceDbColumn?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatusChangeRule {
|
export interface StatusChangeRule {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue