Compare commits

...

8 Commits

Author SHA1 Message Date
SeongHyun Kim a81cb7ca19 Merge branch 'ksh-v2-work' 2026-03-05 19:04:54 +09:00
SeongHyun Kim 12a8290873 feat(pop): 설정 패널 아코디언 접기/펼치기 일관성 + sessionStorage 상태 기억
설정 패널을 열 때 섹션이 일부는 펼쳐져 있고 일부는 접혀 있어
일관성이 없던 UX를 개선하고, 사용자가 펼친 섹션을 탭 세션 내에서 기억한다.
- useCollapsibleSections 커스텀 훅 생성 (sessionStorage 기반, 초기 모두 접힘)
- PopCardListConfig: CollapsibleSection에 sectionKey/sections prop 패턴 적용
- PopFieldConfig: SaveTabContent 5개 고정 섹션 훅 적용,
  SectionEditor 초기값 접힘으로 변경
- PopDashboardConfig: PageEditor 초기값 접힘으로 변경
2026-03-05 18:54:29 +09:00
SeongHyun Kim 7a9a705f19 feat(pop-card-list): 포장 요약 바 UI + 카드 레이아웃 flex column 개선
포장 입력 완료 시 카드 하단에 포장 내역 요약을 표시하여
디자이너가 포장 계산 결과를 즉시 확인할 수 있도록 한다.
- 카드 하단에 포장 요약 바 추가 (emerald 테마, 포장완료 뱃지)
- height(고정) -> minHeight(유동)으로 카드 자연 성장 허용
- gridAutoRows를 minmax(높이, auto)로 변경 (그리드 셀도 성장)
- 카드 flex flex-col + 본문 flex-1 overflow-hidden 구조
- 오른쪽 버튼 영역 justify-center -> justify-start (위쪽 정렬)
2026-03-05 18:34:45 +09:00
SeongHyun Kim 85bf4882a8 fix(pop-card-list): 미입고 formula 필드 입력값 연동 복원
설정 UI에서 formulaRightType 기본값을 "input"으로 표시하지만
DB에 명시적으로 저장하지 않아, 렌더링 시 undefined === "input"이
false가 되어 입력필드 연동이 작동하지 않던 버그를 수정한다.
- FieldRow: (field.formulaRightType || "input") === "input"으로
  기본값 fallback 추가
2026-03-05 18:00:17 +09:00
SeongHyun Kim b2b0b575df feat(pop): 버튼 v2 통합 아키텍처 + data-update 연산 확장 (BLOCK M + N)
버튼 컴포넌트의 실행 경로를 프리셋별 파편화에서 단일 작업 목록(task-list)
패턴으로 통합하고, 부분입고 시나리오 지원을 위해 data-update 연산을 확장한다.
[BLOCK M: 버튼 v2 통합 아키텍처]
- ButtonTask 타입 체계 정의 (10종 작업 타입 + UpdateOperation)
- PopButtonConfigV2 + migrateButtonConfig 자동 마이그레이션
- 설정 UI: 빠른 시작 + 외형 + 작업 목록 에디터
- executeTaskList 범용 실행 함수 (데이터 작업 일괄 백엔드 전달)
- collect_data 프로토콜에 cartChanges 포함
- 백엔드 tasks 배열 기반 처리 (data-save/update/delete/cart-save)
- useCartSync.getChanges() 추출 + 카드리스트 응답 포함
[BLOCK N: data-update 연산 확장]
- UpdateOperationType에 multiply, divide, db-conditional 추가
- ButtonTask에 db-conditional 전용 필드 5개 추가
  (compareColumn, compareOperator, compareWith, dbThenValue, dbElseValue)
- 설정 UI: 드롭다운 3개 옵션 + DB 컬럼 비교 설정 폼
- 백엔드 SQL: multiply, divide(0-division 방어),
  db-conditional(CASE WHEN 배치 UPDATE)
- 기존 add/subtract에 ::numeric 캐스팅 일관 적용
2026-03-05 17:22:30 +09:00
SeongHyun Kim 91c9dda6ae feat(pop-field): 숨은 필드 고정값 + Select 데이터 연동(linkedFilters) 구현
입고 확정 시 status/inbound_status가 빈 값으로 저장되는 문제(FIX-3)와
창고내 위치 셀렉트가 전체 위치를 보여주는 문제를 해결한다.
[FIX-3: 숨은 필드 고정값]
- types.ts: HiddenValueSource에 "static" 추가, staticValue 필드
- PopFieldConfig: 숨은 필드 설정 UI에 "고정값" 모드 추가
- PopFieldComponent: collected_data에 hiddenMappings 포함
- popActionRoutes: INSERT 시 hiddenMappings 값 주입
[Select 데이터 연동 - BLOCK L]
- types.ts: SelectLinkedFilter 인터페이스 + FieldSelectSource.linkedFilters
- PopFieldConfig: "데이터 연동" 토글 + LinkedFiltersEditor 컴포넌트
  (섹션 내 필드 선택 → 필터 컬럼 매핑)
- PopFieldComponent: fieldIdToName 맵으로 id-name 변환,
  SelectFieldInput에서 연동 필드 값 변경 시 동적 필터 재조회,
  상위 미선택 시 안내 메시지, 상위 변경 시 하위 자동 초기화
2026-03-05 12:13:07 +09:00
SeongHyun Kim a6c0ab5664 feat(pop): 입고 확정 시 자동 채번 실행 + 결과 모달 UX + 셀렉트 높이 통일
입고 확정(inbound-confirm) 실행 시 채번 규칙이 설정되어 있어도
inbound_number가 null로 저장되던 문제를 해결한다.
[채번 실행 (FIX-1)]
- types.ts: SaveMapping에 autoGenMappings 필드 추가 (numberingRuleId,
  targetColumn, showResultModal)
- PopFieldComponent: collect_data 응답에 autoGenMappings 포함하여
  백엔드에 채번 규칙 정보 전달
- popActionRoutes: INSERT 전 numberingRuleService.allocateCode() 호출,
  생성된 코드를 generatedCodes 배열로 응답에 포함
[결과 모달 UX]
- pop-button: showResultModal 토글에 따라 채번 결과 모달 표시 분기
- 모달이 열려 있는 동안 followUpActions(refresh/navigate) 지연하여
  사용자가 확인 버튼을 눌러야 후속 액션 실행
[셀렉트 높이 일관성]
- SelectTrigger hasCustomHeight에 /\bh-\d/ 패턴 추가하여
  className의 h-9 등이 기본 data-size="xs"(h-6)와 충돌하지 않도록 수정
[기타 수정]
- SelectFieldInput: Set 기반 dedup으로 React key 중복 방지
- PopFieldConfig: AutoNumberEditor 제거, 채번 규칙을 저장 탭에서 관리
- PopFieldConfig: 전체 채번 규칙 보기 토글 추가
- PopCardListComponent: 장바구니 목록 모드에서 수량 자동 초기화 방지
- PopCardListConfig: 수식 필드 매핑 노출 + 누락 필드 자동 추가
2026-03-04 19:12:22 +09:00
SeongHyun Kim e5abd93600 fix(pop): 카테고리 트리 접기/펼치기 상태를 sessionStorage로 유지
설계 화면에 진입했다 돌아올 때 카테고리 트리와 미분류 회사코드
접기/펼치기 상태가 초기화되는 문제를 수정한다.
expandedGroups, expandedCompanyCodes를 sessionStorage에 저장하여
같은 탭 세션 내에서 상태가 유지되도록 변경.
2026-03-04 14:40:48 +09:00
15 changed files with 2281 additions and 684 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">
[] &gt; .
</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: 섹션 외관 설정
// ======================================== // ========================================

View File

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

View File

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