Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into gbpark-node

; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
DDD1542 2026-02-12 16:33:00 +09:00
commit 95f668d40d
13 changed files with 1668 additions and 223 deletions

View File

@ -1045,7 +1045,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@ -2373,7 +2372,6 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0", "generic-pool": "3.9.0",
@ -3477,7 +3475,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -3714,7 +3711,6 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@ -3932,7 +3928,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -4459,7 +4454,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@ -5670,7 +5664,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@ -5949,7 +5942,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@ -7443,7 +7435,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@ -8413,6 +8404,7 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@ -9301,7 +9293,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.1", "pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1", "pg-pool": "^3.10.1",
@ -10152,6 +10143,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@ -10960,7 +10952,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@ -11066,7 +11057,6 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

View File

@ -2447,3 +2447,260 @@ export async function getReferencedByTables(
res.status(500).json(response); res.status(500).json(response);
} }
} }
// ========================================
// PK / 인덱스 관리 API
// ========================================
/**
* PK/
* GET /api/table-management/tables/:tableName/constraints
*/
export async function getTableConstraints(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
if (!tableName) {
res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
return;
}
// PK 조회
const pkResult = await query<any>(
`SELECT tc.conname AS constraint_name,
array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_constraint tc
JOIN pg_class c ON tc.conrelid = c.oid
JOIN pg_namespace ns ON c.relnamespace = ns.oid
CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'
GROUP BY tc.conname`,
[tableName]
);
// array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환
const parseColumns = (cols: any): string[] => {
if (Array.isArray(cols)) return cols;
if (typeof cols === "string") {
// PostgreSQL 배열 형식: {col1,col2}
return cols.replace(/[{}]/g, "").split(",").filter(Boolean);
}
return [];
};
const primaryKey = pkResult.length > 0
? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) }
: { name: "", columns: [] };
// 인덱스 조회 (PK 인덱스 제외)
const indexResult = await query<any>(
`SELECT i.relname AS index_name,
ix.indisunique AS is_unique,
array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_index ix
JOIN pg_class t ON ix.indrelid = t.oid
JOIN pg_class i ON ix.indexrelid = i.oid
JOIN pg_namespace ns ON t.relnamespace = ns.oid
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE ns.nspname = 'public' AND t.relname = $1
AND ix.indisprimary = false
GROUP BY i.relname, ix.indisunique
ORDER BY i.relname`,
[tableName]
);
const indexes = indexResult.map((row: any) => ({
name: row.index_name,
columns: parseColumns(row.columns),
isUnique: row.is_unique,
}));
logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}`);
res.status(200).json({
success: true,
data: { primaryKey, indexes },
});
} catch (error) {
logger.error("제약조건 조회 오류:", error);
res.status(500).json({
success: false,
message: "제약조건 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* PK
* PUT /api/table-management/tables/:tableName/primary-key
*/
export async function setTablePrimaryKey(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { columns } = req.body;
if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) {
res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." });
return;
}
logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`);
// 기존 PK 제약조건 이름 조회
const existingPk = await query<any>(
`SELECT conname FROM pg_constraint tc
JOIN pg_class c ON tc.conrelid = c.oid
JOIN pg_namespace ns ON c.relnamespace = ns.oid
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`,
[tableName]
);
// 기존 PK 삭제
if (existingPk.length > 0) {
const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`;
logger.info(`기존 PK 삭제: ${dropSql}`);
await query(dropSql);
}
// 새 PK 추가
const colList = columns.map((c: string) => `"${c}"`).join(", ");
const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`;
logger.info(`새 PK 추가: ${addSql}`);
await query(addSql);
res.status(200).json({
success: true,
message: `PK가 설정되었습니다: ${columns.join(", ")}`,
});
} catch (error) {
logger.error("PK 설정 오류:", error);
res.status(500).json({
success: false,
message: "PK 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* (/)
* POST /api/table-management/tables/:tableName/indexes
*/
export async function toggleTableIndex(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { columnName, indexType, action } = req.body;
if (!tableName || !columnName || !indexType || !action) {
res.status(400).json({
success: false,
message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.",
});
return;
}
const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`;
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
if (action === "create") {
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
logger.info(`인덱스 생성: ${sql}`);
await query(sql);
} else if (action === "drop") {
const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`;
logger.info(`인덱스 삭제: ${sql}`);
await query(sql);
} else {
res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." });
return;
}
res.status(200).json({
success: true,
message: action === "create"
? `인덱스가 생성되었습니다: ${indexName}`
: `인덱스가 삭제되었습니다: ${indexName}`,
});
} catch (error: any) {
logger.error("인덱스 토글 오류:", error);
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
const errorMsg = error.message?.includes("duplicate key")
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
: "인덱스 설정 중 오류가 발생했습니다.";
res.status(500).json({
success: false,
message: errorMsg,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* NOT NULL
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*/
export async function toggleColumnNullable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { nullable } = req.body;
if (!tableName || !columnName || typeof nullable !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, nullable(boolean)이 필요합니다.",
});
return;
}
if (nullable) {
// NOT NULL 해제
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
logger.info(`NOT NULL 해제: ${sql}`);
await query(sql);
} else {
// NOT NULL 설정
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`;
logger.info(`NOT NULL 설정: ${sql}`);
await query(sql);
}
res.status(200).json({
success: true,
message: nullable
? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.`
: `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`,
});
} catch (error: any) {
logger.error("NOT NULL 토글 오류:", error);
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
const errorMsg = error.message?.includes("contains null values")
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
: "NOT NULL 설정 중 오류가 발생했습니다.";
res.status(500).json({
success: false,
message: errorMsg,
error: error instanceof Error ? error.message : "Unknown error",
});
}
}

View File

@ -166,14 +166,20 @@ router.post(
masterInserted: result.masterInserted, masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated, masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted, detailInserted: result.detailInserted,
detailUpdated: result.detailUpdated,
errors: result.errors.length, errors: result.errors.length,
}); });
const detailTotal = result.detailInserted + (result.detailUpdated || 0);
const detailMsg = result.detailUpdated
? `디테일 신규 ${result.detailInserted}건, 수정 ${result.detailUpdated}`
: `디테일 ${result.detailInserted}`;
return res.json({ return res.json({
success: result.success, success: result.success,
data: result, data: result,
message: result.success message: result.success
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.` ? `마스터 ${result.masterInserted + result.masterUpdated}건, ${detailMsg} 처리되었습니다.`
: "업로드 중 오류가 발생했습니다.", : "업로드 중 오류가 발생했습니다.",
}); });
} catch (error: any) { } catch (error: any) {

View File

@ -28,6 +28,10 @@ import {
multiTableSave, // 🆕 범용 다중 테이블 저장 multiTableSave, // 🆕 범용 다중 테이블 저장
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회 getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회 getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
getTableConstraints, // 🆕 PK/인덱스 상태 조회
setTablePrimaryKey, // 🆕 PK 설정
toggleTableIndex, // 🆕 인덱스 토글
toggleColumnNullable, // 🆕 NOT NULL 토글
} from "../controllers/tableManagementController"; } from "../controllers/tableManagementController";
const router = express.Router(); const router = express.Router();
@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings);
*/ */
router.get("/tables/:tableName/schema", getTableSchema); router.get("/tables/:tableName/schema", getTableSchema);
/**
* PK/
* GET /api/table-management/tables/:tableName/constraints
*/
router.get("/tables/:tableName/constraints", getTableConstraints);
/**
* PK ( PK DROP )
* PUT /api/table-management/tables/:tableName/primary-key
*/
router.put("/tables/:tableName/primary-key", setTablePrimaryKey);
/**
* (/)
* POST /api/table-management/tables/:tableName/indexes
*/
router.post("/tables/:tableName/indexes", toggleTableIndex);
/**
* NOT NULL
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*/
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
/** /**
* *
* GET /api/table-management/tables/:tableName/exists * GET /api/table-management/tables/:tableName/exists

View File

@ -78,6 +78,7 @@ export interface ExcelUploadResult {
masterInserted: number; masterInserted: number;
masterUpdated: number; masterUpdated: number;
detailInserted: number; detailInserted: number;
detailUpdated: number;
detailDeleted: number; detailDeleted: number;
errors: string[]; errors: string[];
} }
@ -517,11 +518,6 @@ class MasterDetailExcelService {
params params
); );
logger.info(`채번 컬럼 조회 결과: ${tableName}.${columnName}`, {
rowCount: result.length,
rows: result.map((r: any) => ({ input_type: r.input_type, company_code: r.company_code })),
});
// 채번 타입인 행 찾기 (회사별 우선) // 채번 타입인 행 찾기 (회사별 우선)
for (const row of result) { for (const row of result) {
if (row.input_type === "numbering") { if (row.input_type === "numbering") {
@ -530,13 +526,11 @@ class MasterDetailExcelService {
: row.detail_settings; : row.detail_settings;
if (settings?.numberingRuleId) { if (settings?.numberingRuleId) {
logger.info(`채번 컬럼 감지: ${tableName}.${columnName} → 규칙 ID: ${settings.numberingRuleId} (company: ${row.company_code})`);
return { numberingRuleId: settings.numberingRuleId }; return { numberingRuleId: settings.numberingRuleId };
} }
} }
} }
logger.info(`채번 컬럼 아님: ${tableName}.${columnName}`);
return null; return null;
} catch (error) { } catch (error) {
logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error); logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error);
@ -544,6 +538,118 @@ class MasterDetailExcelService {
} }
} }
/**
*
* , (*) fallback
* @returns Map<columnName, numberingRuleId>
*/
private async detectAllNumberingColumns(
tableName: string,
companyCode?: string
): Promise<Map<string, string>> {
const numberingCols = new Map<string, string>();
try {
const companyCondition = companyCode && companyCode !== "*"
? `AND company_code IN ($2, '*')`
: `AND company_code = '*'`;
const params = companyCode && companyCode !== "*"
? [tableName, companyCode]
: [tableName];
const result = await query<any>(
`SELECT column_name, detail_settings, company_code
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
params
);
// 컬럼별로 회사 설정 우선 적용
for (const row of result) {
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
const settings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings || "{}")
: row.detail_settings;
if (settings?.numberingRuleId) {
numberingCols.set(row.column_name, settings.numberingRuleId);
}
}
if (numberingCols.size > 0) {
logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols));
}
} catch (error) {
logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error);
}
return numberingCols;
}
/**
* (UPSERT )
* PK가 , auto-increment 'id'
* @returns ( INSERT만 )
*/
private async detectUniqueKeyColumns(
client: any,
tableName: string
): Promise<string[]> {
try {
// 1. PK 컬럼 조회
const pkResult = await client.query(
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`,
[tableName]
);
if (pkResult.rows.length > 0 && pkResult.rows[0].columns) {
const pkCols: string[] = typeof pkResult.rows[0].columns === "string"
? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
: pkResult.rows[0].columns;
// PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가
if (!(pkCols.length === 1 && pkCols[0] === "id")) {
logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`);
return pkCols;
}
}
// 2. PK가 'id'뿐이면 유니크 인덱스 탐색
const uqResult = await client.query(
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_index ix
JOIN pg_class t ON t.oid = ix.indrelid
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE n.nspname = 'public' AND t.relname = $1
AND ix.indisunique = true AND ix.indisprimary = false
GROUP BY i.relname
LIMIT 1`,
[tableName]
);
if (uqResult.rows.length > 0 && uqResult.rows[0].columns) {
const uqCols: string[] = typeof uqResult.rows[0].columns === "string"
? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
: uqResult.rows[0].columns;
logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`);
return uqCols;
}
logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`);
return [];
} catch (error) {
logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error);
return [];
}
}
/** /**
* - ( ) * - ( )
* *
@ -551,7 +657,7 @@ class MasterDetailExcelService {
* 1. * 1.
* 2-A. 경우: 다른 INSERT * 2-A. 경우: 다른 INSERT
* 2-B. 경우: 마스터 UPSERT * 2-B. 경우: 마스터 UPSERT
* 3. INSERT * 3. UPSERT ( )
*/ */
async uploadJoinedData( async uploadJoinedData(
relation: MasterDetailRelation, relation: MasterDetailRelation,
@ -564,6 +670,7 @@ class MasterDetailExcelService {
masterInserted: 0, masterInserted: 0,
masterUpdated: 0, masterUpdated: 0,
detailInserted: 0, detailInserted: 0,
detailUpdated: 0,
detailDeleted: 0, detailDeleted: 0,
errors: [], errors: [],
}; };
@ -633,30 +740,78 @@ class MasterDetailExcelService {
logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`); logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
} }
// 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회)
const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode);
// 마스터 테이블의 비-키 채번 컬럼도 감지
const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode);
// 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
// PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색
const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable);
// 각 그룹 처리 // 각 그룹 처리
for (const [groupKey, rows] of groupedData.entries()) { for (const [groupKey, rows] of groupedData.entries()) {
try { try {
// 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키) // 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키)
let masterKey: string; let masterKey: string;
let existingMasterKey: string | null = null;
// 마스터 데이터 추출 (첫 번째 행에서, 키 제외)
const masterDataWithoutKey: Record<string, any> = {};
for (const col of masterColumns) {
if (col.name === masterKeyColumn) continue;
if (rows[0][col.name] !== undefined) {
masterDataWithoutKey[col.name] = rows[0][col.name];
}
}
if (isAutoNumbering) { if (isAutoNumbering) {
// 채번 규칙으로 마스터 키 자동 생성 // 채번 모드: 동일한 마스터가 이미 DB에 있는지 먼저 확인
masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode); // 마스터 키 제외한 다른 컬럼들로 매칭 (예: dept_name이 같은 부서가 있는지)
logger.info(`채번 생성: ${masterKey}`); const matchCols = Object.keys(masterDataWithoutKey)
.filter(k => k !== "company_code" && k !== "writer" && k !== "created_date" && k !== "updated_date" && k !== "id"
&& masterDataWithoutKey[k] !== undefined && masterDataWithoutKey[k] !== null && masterDataWithoutKey[k] !== "");
if (matchCols.length > 0) {
const whereClause = matchCols.map((col, i) => `"${col}" = $${i + 1}`).join(" AND ");
const companyIdx = matchCols.length + 1;
const matchResult = await client.query(
`SELECT "${masterKeyColumn}" FROM "${masterTable}" WHERE ${whereClause} AND company_code = $${companyIdx} LIMIT 1`,
[...matchCols.map(k => masterDataWithoutKey[k]), companyCode]
);
if (matchResult.rows.length > 0) {
existingMasterKey = matchResult.rows[0][masterKeyColumn];
logger.info(`채번 모드: 기존 마스터 발견 → ${masterKeyColumn}=${existingMasterKey} (매칭: ${matchCols.map(c => `${c}=${masterDataWithoutKey[c]}`).join(", ")})`);
}
}
if (existingMasterKey) {
// 기존 마스터 사용 (UPDATE)
masterKey = existingMasterKey;
const updateKeys = matchCols.filter(k => k !== masterKeyColumn);
if (updateKeys.length > 0) {
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
const setValues = updateKeys.map(k => masterDataWithoutKey[k]);
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
await client.query(
`UPDATE "${masterTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE "${masterKeyColumn}" = $${setValues.length + 1} AND company_code = $${setValues.length + 2}`,
[...setValues, masterKey, companyCode]
);
}
result.masterUpdated++;
} else {
// 새 마스터 생성 (채번)
masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode);
logger.info(`채번 생성: ${masterKey}`);
}
} else { } else {
masterKey = groupKey; masterKey = groupKey;
} }
// 마스터 데이터 추출 (첫 번째 행에서) // 마스터 데이터 조립
const masterData: Record<string, any> = {}; const masterData: Record<string, any> = {};
// 마스터 키 컬럼은 항상 설정 (분할패널 컬럼 목록에 없어도)
masterData[masterKeyColumn] = masterKey; masterData[masterKeyColumn] = masterKey;
for (const col of masterColumns) { Object.assign(masterData, masterDataWithoutKey);
if (col.name === masterKeyColumn) continue; // 이미 위에서 설정
if (rows[0][col.name] !== undefined) {
masterData[col.name] = rows[0][col.name];
}
}
// 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만) // 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만)
if (masterExistingCols.has("company_code")) { if (masterExistingCols.has("company_code")) {
@ -666,6 +821,16 @@ class MasterDetailExcelService {
masterData.writer = userId; masterData.writer = userId;
} }
// 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우)
for (const [colName, ruleId] of masterNumberingCols) {
if (colName === masterKeyColumn) continue;
if (!masterData[colName] || masterData[colName] === "") {
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
masterData[colName] = generatedValue;
logger.info(`마스터 채번 생성: ${masterTable}.${colName} = ${generatedValue}`);
}
}
// INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가) // INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가)
const buildInsertSQL = (table: string, data: Record<string, any>, existingCols: Set<string>) => { const buildInsertSQL = (table: string, data: Record<string, any>, existingCols: Set<string>) => {
const cols = Object.keys(data); const cols = Object.keys(data);
@ -680,12 +845,12 @@ class MasterDetailExcelService {
}; };
}; };
if (isAutoNumbering) { if (isAutoNumbering && !existingMasterKey) {
// 채번 모드: 항상 INSERT (새 마스터 생성) // 채번 모드 + 새 마스터: INSERT
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols); const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
await client.query(sql, values); await client.query(sql, values);
result.masterInserted++; result.masterInserted++;
} else { } else if (!isAutoNumbering) {
// 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT) // 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT)
const existingMaster = await client.query( const existingMaster = await client.query(
`SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`, `SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
@ -716,15 +881,9 @@ class MasterDetailExcelService {
result.masterInserted++; result.masterInserted++;
} }
// 일반 모드에서만 기존 디테일 삭제 (채번 모드는 새 마스터이므로 삭제할 디테일 없음)
const deleteResult = await client.query(
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
[masterKey, companyCode]
);
result.detailDeleted += deleteResult.rowCount || 0;
} }
// 디테일 INSERT // 디테일 개별 행 UPSERT 처리
for (const row of rows) { for (const row of rows) {
const detailData: Record<string, any> = {}; const detailData: Record<string, any> = {};
@ -737,16 +896,105 @@ class MasterDetailExcelService {
detailData.writer = userId; detailData.writer = userId;
} }
// 디테일 컬럼 데이터 추출 // 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준)
for (const col of detailColumns) { for (const col of detailColumns) {
if (row[col.name] !== undefined) { if (row[col.name] !== undefined) {
detailData[col.name] = row[col.name]; detailData[col.name] = row[col.name];
} }
} }
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols); // 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함
await client.query(sql, values); // (user_id 등 화면에 표시되지 않지만 NOT NULL인 컬럼 처리)
result.detailInserted++; const detailColNames = new Set(detailColumns.map(c => c.name));
const skipCols = new Set([
detailFkColumn, masterKeyColumn,
"company_code", "writer", "created_date", "updated_date", "id",
]);
for (const key of Object.keys(row)) {
if (!detailColNames.has(key) && !skipCols.has(key) && detailExistingCols.has(key) && row[key] !== undefined && row[key] !== null && row[key] !== "") {
const isMasterCol = masterColumns.some(mc => mc.name === key);
if (!isMasterCol) {
detailData[key] = row[key];
}
}
}
// 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입)
for (const [colName, ruleId] of detailNumberingCols) {
if (!detailData[colName] || detailData[colName] === "") {
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
detailData[colName] = generatedValue;
logger.info(`디테일 채번 생성: ${detailTable}.${colName} = ${generatedValue}`);
}
}
// 고유 키 기반 UPSERT: 존재하면 UPDATE, 없으면 INSERT
const hasUniqueKey = detailUniqueKeyCols.length > 0;
const uniqueKeyValues = hasUniqueKey
? detailUniqueKeyCols.map(col => detailData[col])
: [];
// 고유 키 값이 모두 있어야 매칭 가능 (채번으로 생성된 값도 포함)
const canMatch = hasUniqueKey && uniqueKeyValues.every(v => v !== undefined && v !== null && v !== "");
if (canMatch) {
// 기존 행 존재 여부 확인
const whereClause = detailUniqueKeyCols
.map((col, i) => `"${col}" = $${i + 1}`)
.join(" AND ");
const companyParam = detailExistingCols.has("company_code")
? ` AND company_code = $${detailUniqueKeyCols.length + 1}`
: "";
const checkParams = detailExistingCols.has("company_code")
? [...uniqueKeyValues, companyCode]
: uniqueKeyValues;
const existingRow = await client.query(
`SELECT 1 FROM "${detailTable}" WHERE ${whereClause}${companyParam} LIMIT 1`,
checkParams
);
if (existingRow.rows.length > 0) {
// UPDATE: 고유 키와 시스템 컬럼 제외한 나머지 업데이트
const updateExclude = new Set([
...detailUniqueKeyCols, "id", "company_code", "created_date",
]);
const updateKeys = Object.keys(detailData).filter(k => !updateExclude.has(k));
if (updateKeys.length > 0) {
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
const setValues = updateKeys.map(k => detailData[k]);
const updatedDateClause = detailExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
const whereParams = detailUniqueKeyCols.map((col, i) => `"${col}" = $${setValues.length + i + 1}`);
const companyWhere = detailExistingCols.has("company_code")
? ` AND company_code = $${setValues.length + detailUniqueKeyCols.length + 1}`
: "";
const allValues = [
...setValues,
...uniqueKeyValues,
...(detailExistingCols.has("company_code") ? [companyCode] : []),
];
await client.query(
`UPDATE "${detailTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE ${whereParams.join(" AND ")}${companyWhere}`,
allValues
);
result.detailUpdated = (result.detailUpdated || 0) + 1;
logger.info(`디테일 UPDATE: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
}
} else {
// INSERT: 새로운 행
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
await client.query(sql, values);
result.detailInserted++;
logger.info(`디테일 INSERT: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
}
} else {
// 고유 키가 없거나 값이 없으면 INSERT 전용
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
await client.query(sql, values);
result.detailInserted++;
}
} }
} catch (error: any) { } catch (error: any) {
result.errors.push(`그룹 처리 실패: ${error.message}`); result.errors.push(`그룹 처리 실패: ${error.message}`);
@ -761,7 +1009,7 @@ class MasterDetailExcelService {
masterInserted: result.masterInserted, masterInserted: result.masterInserted,
masterUpdated: result.masterUpdated, masterUpdated: result.masterUpdated,
detailInserted: result.detailInserted, detailInserted: result.detailInserted,
detailDeleted: result.detailDeleted, detailUpdated: result.detailUpdated,
errors: result.errors.length, errors: result.errors.length,
}); });

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useMemo, useCallback } from "react"; import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -19,6 +19,7 @@ import {
Copy, Copy,
Check, Check,
ChevronsUpDown, ChevronsUpDown,
Loader2,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
@ -140,11 +141,22 @@ export default function TableManagementPage() {
const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerOpen, setLogViewerOpen] = useState(false);
const [logViewerTableName, setLogViewerTableName] = useState<string>(""); const [logViewerTableName, setLogViewerTableName] = useState<string>("");
// 저장 중 상태 (중복 실행 방지)
const [isSaving, setIsSaving] = useState(false);
// 테이블 삭제 확인 다이얼로그 상태 // 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>(""); const [tableToDelete, setTableToDelete] = useState<string>("");
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// PK/인덱스 관리 상태
const [constraints, setConstraints] = useState<{
primaryKey: { name: string; columns: string[] };
indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>;
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
const [pkDialogOpen, setPkDialogOpen] = useState(false);
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
// 선택된 테이블 목록 (체크박스) // 선택된 테이블 목록 (체크박스)
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set()); const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
@ -397,6 +409,19 @@ export default function TableManagementPage() {
} }
}, []); }, []);
// PK/인덱스 제약조건 로드
const loadConstraints = useCallback(async (tableName: string) => {
try {
const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`);
if (response.data.success) {
setConstraints(response.data.data);
}
} catch (error) {
console.error("제약조건 로드 실패:", error);
setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] });
}
}, []);
// 테이블 선택 // 테이블 선택
const handleTableSelect = useCallback( const handleTableSelect = useCallback(
(tableName: string) => { (tableName: string) => {
@ -410,8 +435,9 @@ export default function TableManagementPage() {
setTableDescription(tableInfo?.description || ""); setTableDescription(tableInfo?.description || "");
loadColumnTypes(tableName, 1, pageSize); loadColumnTypes(tableName, 1, pageSize);
loadConstraints(tableName);
}, },
[loadColumnTypes, pageSize, tables], [loadColumnTypes, loadConstraints, pageSize, tables],
); );
// 입력 타입 변경 // 입력 타입 변경
@ -757,7 +783,9 @@ export default function TableManagementPage() {
// 전체 저장 (테이블 라벨 + 모든 컬럼 설정) // 전체 저장 (테이블 라벨 + 모든 컬럼 설정)
const saveAllSettings = async () => { const saveAllSettings = async () => {
if (!selectedTable) return; if (!selectedTable) return;
if (isSaving) return; // 저장 중 중복 실행 방지
setIsSaving(true);
try { try {
// 1. 테이블 라벨 저장 (변경된 경우에만) // 1. 테이블 라벨 저장 (변경된 경우에만)
if (tableLabel !== selectedTable || tableDescription) { if (tableLabel !== selectedTable || tableDescription) {
@ -952,9 +980,30 @@ export default function TableManagementPage() {
} catch (error) { } catch (error) {
// console.error("설정 저장 실패:", error); // console.error("설정 저장 실패:", error);
toast.error("설정 저장 중 오류가 발생했습니다."); toast.error("설정 저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
} }
}; };
// Ctrl+S 단축키: 테이블 설정 전체 저장
// saveAllSettings를 ref로 참조하여 useEffect 의존성 문제 방지
const saveAllSettingsRef = useRef(saveAllSettings);
saveAllSettingsRef.current = saveAllSettings;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault(); // 브라우저 기본 저장 동작 방지
if (selectedTable && columns.length > 0) {
saveAllSettingsRef.current();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedTable, columns.length]);
// 필터링된 테이블 목록 (메모이제이션) // 필터링된 테이블 목록 (메모이제이션)
const filteredTables = useMemo( const filteredTables = useMemo(
() => () =>
@ -1000,6 +1049,123 @@ export default function TableManagementPage() {
} }
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
// PK 체크박스 변경 핸들러
const handlePkToggle = useCallback(
(columnName: string, checked: boolean) => {
const currentPkCols = [...constraints.primaryKey.columns];
let newPkCols: string[];
if (checked) {
newPkCols = [...currentPkCols, columnName];
} else {
newPkCols = currentPkCols.filter((c) => c !== columnName);
}
// PK 변경은 확인 다이얼로그 표시
setPendingPkColumns(newPkCols);
setPkDialogOpen(true);
},
[constraints.primaryKey.columns],
);
// PK 변경 확인
const handlePkConfirm = async () => {
if (!selectedTable) return;
try {
if (pendingPkColumns.length === 0) {
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
setPkDialogOpen(false);
return;
}
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
columns: pendingPkColumns,
});
if (response.data.success) {
toast.success(response.data.message);
await loadConstraints(selectedTable);
} else {
toast.error(response.data.message || "PK 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다.");
} finally {
setPkDialogOpen(false);
}
};
// 인덱스 토글 핸들러
const handleIndexToggle = useCallback(
async (columnName: string, indexType: "index" | "unique", checked: boolean) => {
if (!selectedTable) return;
const action = checked ? "create" : "drop";
try {
const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, {
columnName,
indexType,
action,
});
if (response.data.success) {
toast.success(response.data.message);
await loadConstraints(selectedTable);
} else {
toast.error(response.data.message || "인덱스 설정 실패");
}
} catch (error: any) {
toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다.");
}
},
[selectedTable, loadConstraints],
);
// 컬럼별 인덱스 상태 헬퍼
const getColumnIndexState = useCallback(
(columnName: string) => {
const isPk = constraints.primaryKey.columns.includes(columnName);
const hasIndex = constraints.indexes.some(
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
const hasUnique = constraints.indexes.some(
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
return { isPk, hasIndex, hasUnique };
},
[constraints],
);
// NOT NULL 토글 핸들러
const handleNullableToggle = useCallback(
async (columnName: string, currentIsNullable: string) => {
if (!selectedTable) return;
// isNullable이 "YES"면 nullable, "NO"면 NOT NULL
// 체크박스 체크 = NOT NULL 설정 (nullable: false)
// 체크박스 해제 = NOT NULL 해제 (nullable: true)
const isCurrentlyNotNull = currentIsNullable === "NO";
const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정
try {
const response = await apiClient.put(
`/table-management/tables/${selectedTable}/columns/${columnName}/nullable`,
{ nullable: newNullable },
);
if (response.data.success) {
toast.success(response.data.message);
// 컬럼 상태 로컬 업데이트
setColumns((prev) =>
prev.map((col) =>
col.columnName === columnName
? { ...col, isNullable: newNullable ? "YES" : "NO" }
: col,
),
);
} else {
toast.error(response.data.message || "NOT NULL 설정 실패");
}
} catch (error: any) {
toast.error(
error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.",
);
}
},
[selectedTable],
);
// 테이블 삭제 확인 // 테이블 삭제 확인
const handleDeleteTableClick = (tableName: string) => { const handleDeleteTableClick = (tableName: string) => {
setTableToDelete(tableName); setTableToDelete(tableName);
@ -1367,11 +1533,15 @@ export default function TableManagementPage() {
{/* 저장 버튼 (항상 보이도록 상단에 배치) */} {/* 저장 버튼 (항상 보이도록 상단에 배치) */}
<Button <Button
onClick={saveAllSettings} onClick={saveAllSettings}
disabled={!selectedTable || columns.length === 0} disabled={!selectedTable || columns.length === 0 || isSaving}
className="h-10 gap-2 text-sm font-medium" className="h-10 gap-2 text-sm font-medium"
> >
<Settings className="h-4 w-4" /> {isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Settings className="h-4 w-4" />
)}
{isSaving ? "저장 중..." : "전체 설정 저장"}
</Button> </Button>
</div> </div>
@ -1391,12 +1561,16 @@ export default function TableManagementPage() {
{/* 컬럼 헤더 (고정) */} {/* 컬럼 헤더 (고정) */}
<div <div
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold" className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }} style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
> >
<div className="pr-4"></div> <div className="pr-4"></div>
<div className="px-4"></div> <div className="px-4"></div>
<div className="pr-6"> </div> <div className="pr-6"> </div>
<div className="pl-4"></div> <div className="pl-4"></div>
<div className="text-center text-xs">Primary</div>
<div className="text-center text-xs">NotNull</div>
<div className="text-center text-xs">Index</div>
<div className="text-center text-xs">Unique</div>
</div> </div>
{/* 컬럼 리스트 (스크롤 영역) */} {/* 컬럼 리스트 (스크롤 영역) */}
@ -1410,16 +1584,15 @@ export default function TableManagementPage() {
} }
}} }}
> >
{columns.map((column, index) => ( {columns.map((column, index) => {
const idxState = getColumnIndexState(column.columnName);
return (
<div <div
key={column.columnName} key={column.columnName}
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors" className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }} style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
> >
<div className="pt-1 pr-4"> <div className="pr-4">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="px-4">
<Input <Input
value={column.displayName || ""} value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)} onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
@ -1427,6 +1600,9 @@ export default function TableManagementPage() {
className="h-8 text-xs" className="h-8 text-xs"
/> />
</div> </div>
<div className="px-4 pt-1">
<div className="font-mono text-sm">{column.columnName}</div>
</div>
<div className="pr-6"> <div className="pr-6">
<div className="space-y-3"> <div className="space-y-3">
{/* 입력 타입 선택 */} {/* 입력 타입 선택 */}
@ -1689,141 +1865,11 @@ export default function TableManagementPage() {
</div> </div>
)} )}
{/* 표시 컬럼 - 검색 가능한 Combobox */}
{column.referenceTable &&
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" && (
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> </label>
<Popover
open={entityComboboxOpen[column.columnName]?.displayColumn || false}
onOpenChange={(open) =>
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: open,
},
}))
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={
entityComboboxOpen[column.columnName]?.displayColumn || false
}
className="bg-background h-8 w-full justify-between text-xs"
disabled={
!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0
}
>
{!referenceTableColumns[column.referenceTable] ||
referenceTableColumns[column.referenceTable].length === 0 ? (
<span className="flex items-center gap-2">
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
...
</span>
) : column.displayColumn && column.displayColumn !== "none" ? (
column.displayColumn
) : (
"컬럼 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList className="max-h-[200px]">
<CommandEmpty className="py-2 text-center text-xs">
.
</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
handleDetailSettingsChange(
column.columnName,
"entity_display_column",
"none",
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.displayColumn === "none" || !column.displayColumn
? "opacity-100"
: "opacity-0",
)}
/>
-- --
</CommandItem>
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
<CommandItem
key={refCol.columnName}
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
onSelect={() => {
handleDetailSettingsChange(
column.columnName,
"entity_display_column",
refCol.columnName,
);
setEntityComboboxOpen((prev) => ({
...prev,
[column.columnName]: {
...prev[column.columnName],
displayColumn: false,
},
}));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
column.displayColumn === refCol.columnName
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{refCol.columnName}</span>
{refCol.columnLabel && (
<span className="text-muted-foreground text-[10px]">
{refCol.columnLabel}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 설정 완료 표시 */} {/* 설정 완료 표시 */}
{column.referenceTable && {column.referenceTable &&
column.referenceTable !== "none" && column.referenceTable !== "none" &&
column.referenceColumn && column.referenceColumn &&
column.referenceColumn !== "none" && column.referenceColumn !== "none" && (
column.displayColumn &&
column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs"> <div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
<span className="truncate"> </span> <span className="truncate"> </span>
@ -1953,8 +1999,49 @@ export default function TableManagementPage() {
className="h-8 w-full text-xs" className="h-8 w-full text-xs"
/> />
</div> </div>
{/* PK 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.isPk}
onCheckedChange={(checked) =>
handlePkToggle(column.columnName, checked as boolean)
}
aria-label={`${column.columnName} PK 설정`}
/>
</div>
{/* NN (NOT NULL) 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={column.isNullable === "NO"}
onCheckedChange={() =>
handleNullableToggle(column.columnName, column.isNullable)
}
aria-label={`${column.columnName} NOT NULL 설정`}
/>
</div>
{/* IDX 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.hasIndex}
onCheckedChange={(checked) =>
handleIndexToggle(column.columnName, "index", checked as boolean)
}
aria-label={`${column.columnName} 인덱스 설정`}
/>
</div>
{/* UQ 체크박스 */}
<div className="flex items-center justify-center pt-1">
<Checkbox
checked={idxState.hasUnique}
onCheckedChange={(checked) =>
handleIndexToggle(column.columnName, "unique", checked as boolean)
}
aria-label={`${column.columnName} 유니크 설정`}
/>
</div>
</div> </div>
))} );
})}
{/* 로딩 표시 */} {/* 로딩 표시 */}
{columnsLoading && ( {columnsLoading && (
@ -2120,6 +2207,52 @@ export default function TableManagementPage() {
</> </>
)} )}
{/* PK 변경 확인 다이얼로그 */}
<Dialog open={pkDialogOpen} onOpenChange={setPkDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">PK </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
PK를 .
<br /> .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div className="rounded-lg border p-4">
<p className="text-sm font-medium"> PK :</p>
{pendingPkColumns.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{pendingPkColumns.map((col) => (
<Badge key={col} variant="secondary" className="font-mono text-xs">
{col}
</Badge>
))}
</div>
) : (
<p className="text-destructive mt-2 text-sm">PK가 </p>
)}
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setPkDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handlePkConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Scroll to Top 버튼 */} {/* Scroll to Top 버튼 */}
<ScrollToTop /> <ScrollToTop />
</div> </div>

View File

@ -453,6 +453,48 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
} }
// 채번 정보 병합: table_type_columns에서 inputType 가져오기
try {
const { getTableColumns } = await import("@/lib/api/tableManagement");
const targetTables = isMasterDetail && masterDetailRelation
? [masterDetailRelation.masterTable, masterDetailRelation.detailTable]
: [tableName];
// 테이블별 채번 컬럼 수집
const numberingColSet = new Set<string>();
for (const tbl of targetTables) {
const typeResponse = await getTableColumns(tbl);
if (typeResponse.success && typeResponse.data?.columns) {
for (const tc of typeResponse.data.columns) {
if (tc.inputType === "numbering") {
try {
const settings = typeof tc.detailSettings === "string"
? JSON.parse(tc.detailSettings) : tc.detailSettings;
if (settings?.numberingRuleId) {
numberingColSet.add(tc.columnName);
}
} catch { /* 파싱 실패 무시 */ }
}
}
}
}
// systemColumns에 isNumbering 플래그 추가
if (numberingColSet.size > 0) {
allColumns = allColumns.map((col) => {
const rawName = (col as any).originalName || col.name;
const colName = rawName.includes(".") ? rawName.split(".")[1] : rawName;
if (numberingColSet.has(colName)) {
return { ...col, isNumbering: true } as any;
}
return col;
});
console.log("✅ 채번 컬럼 감지:", Array.from(numberingColSet));
}
} catch (error) {
console.warn("채번 정보 로드 실패 (무시):", error);
}
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns); console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
setSystemColumns(allColumns); setSystemColumns(allColumns);
@ -613,6 +655,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
} }
} }
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증
if (currentStep === 2) {
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
const mappedSystemCols = new Set<string>();
columnMappings.filter((m) => m.systemColumn).forEach((m) => {
const colName = m.systemColumn!;
mappedSystemCols.add(colName); // 원본 (예: user_info.user_id)
if (colName.includes(".")) {
mappedSystemCols.add(colName.split(".")[1]); // dot 뒤 (예: user_id)
}
});
const unmappedRequired = systemColumns.filter((col) => {
const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false;
if (col.nullable) return false;
if (mappedSystemCols.has(col.name) || mappedSystemCols.has(rawName)) return false;
if ((col as any).isNumbering) return false;
return true;
});
if (unmappedRequired.length > 0) {
const colNames = unmappedRequired.map((c) => c.label || c.name).join(", ");
toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`);
return;
}
}
setCurrentStep((prev) => Math.min(prev + 1, 3)); setCurrentStep((prev) => Math.min(prev + 1, 3));
}; };
@ -1397,15 +1467,19 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
<SelectItem value="none" className="text-xs sm:text-sm"> <SelectItem value="none" className="text-xs sm:text-sm">
</SelectItem> </SelectItem>
{systemColumns.map((col) => ( {systemColumns.map((col) => {
const isRequired = !col.nullable && !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && !(col as any).isNumbering;
return (
<SelectItem <SelectItem
key={col.name} key={col.name}
value={col.name} value={col.name}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
> >
{isRequired && <span className="text-destructive mr-1">*</span>}
{col.label || col.name} ({col.type}) {col.label || col.name} ({col.type})
</SelectItem> </SelectItem>
))} );
})}
</SelectContent> </SelectContent>
</Select> </Select>
{/* 중복 체크 체크박스 */} {/* 중복 체크 체크박스 */}
@ -1427,6 +1501,38 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div> </div>
</div> </div>
{/* 미매핑 필수(NOT NULL) 컬럼 경고 */}
{(() => {
const mappedCols = new Set<string>();
columnMappings.filter((m) => m.systemColumn).forEach((m) => {
const n = m.systemColumn!;
mappedCols.add(n);
if (n.includes(".")) mappedCols.add(n.split(".")[1]);
});
const missing = systemColumns.filter((col) => {
const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false;
if (col.nullable) return false;
if (mappedCols.has(col.name) || mappedCols.has(rawName)) return false;
if ((col as any).isNumbering) return false;
return true;
});
if (missing.length === 0) return null;
return (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
<div className="text-[10px] text-destructive sm:text-xs">
<p className="font-medium">(NOT NULL) :</p>
<p className="mt-1">
{missing.map((c) => c.label || c.name).join(", ")}
</p>
</div>
</div>
</div>
);
})()}
{/* 중복 체크 안내 */} {/* 중복 체크 안내 */}
{duplicateCheckCount > 0 ? ( {duplicateCheckCount > 0 ? (
<div className="rounded-md border border-blue-200 bg-blue-50 p-3"> <div className="rounded-md border border-blue-200 bg-blue-50 p-3">

View File

@ -4,7 +4,7 @@
* *
*/ */
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react"; import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -42,6 +42,27 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
const [showSaveDialog, setShowSaveDialog] = useState(false); const [showSaveDialog, setShowSaveDialog] = useState(false);
// Ctrl+S 단축키: 플로우 저장
const handleSaveRef = useRef<() => void>();
useEffect(() => {
handleSaveRef.current = handleSave;
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (!isSaving) {
handleSaveRef.current?.();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isSaving]);
const handleSave = async () => { const handleSave = async () => {
// 검증 수행 // 검증 수행
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges); const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useCallback } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { X, Loader2 } from "lucide-react"; import { X, Loader2 } from "lucide-react";
@ -26,6 +26,17 @@ interface TabsWidgetProps {
isDesignMode?: boolean; isDesignMode?: boolean;
onComponentSelect?: (tabId: string, componentId: string) => void; onComponentSelect?: (tabId: string, componentId: string) => void;
selectedComponentId?: string; selectedComponentId?: string;
// 테이블 선택된 행 데이터 (버튼 활성화 및 수정/삭제 동작에 필요)
selectedRowsData?: any[];
onSelectedRowsChange?: (
selectedRows: any[],
selectedRowsData: any[],
sortBy?: string,
sortOrder?: "asc" | "desc",
columnOrder?: string[],
) => void;
// 추가 props (부모에서 전달받은 나머지 props)
[key: string]: any;
} }
export function TabsWidget({ export function TabsWidget({
@ -38,6 +49,9 @@ export function TabsWidget({
isDesignMode = false, isDesignMode = false,
onComponentSelect, onComponentSelect,
selectedComponentId, selectedComponentId,
selectedRowsData: _externalSelectedRowsData,
onSelectedRowsChange: externalOnSelectedRowsChange,
...restProps
}: TabsWidgetProps) { }: TabsWidgetProps) {
const { setActiveTab, removeTabsComponent } = useActiveTab(); const { setActiveTab, removeTabsComponent } = useActiveTab();
const { const {
@ -51,6 +65,30 @@ export function TabsWidget({
const storageKey = `tabs-${component.id}-selected`; const storageKey = `tabs-${component.id}-selected`;
// 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용)
// 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
// 선택 변경 핸들러: 로컬 상태 업데이트 + 부모 콜백 호출
const handleSelectedRowsChange = useCallback(
(
selectedRows: any[],
selectedRowsDataNew: any[],
sortBy?: string,
sortOrder?: "asc" | "desc",
columnOrder?: string[],
) => {
// 로컬 상태 업데이트 (탭 내부 버튼이 즉시 인식)
setLocalSelectedRowsData(selectedRowsDataNew);
// 부모 콜백 호출 (부모 상태도 업데이트)
if (externalOnSelectedRowsChange) {
externalOnSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder);
}
},
[externalOnSelectedRowsChange],
);
// 초기 선택 탭 결정 // 초기 선택 탭 결정
const getInitialTab = () => { const getInitialTab = () => {
if (persistSelection && typeof window !== "undefined") { if (persistSelection && typeof window !== "undefined") {
@ -331,6 +369,7 @@ export function TabsWidget({
}} }}
> >
<DynamicComponentRenderer <DynamicComponentRenderer
{...restProps}
component={{ component={{
id: comp.id, id: comp.id,
componentType: comp.componentType, componentType: comp.componentType,
@ -345,6 +384,8 @@ export function TabsWidget({
menuObjid={menuObjid} menuObjid={menuObjid}
isDesignMode={isDesignMode} isDesignMode={isDesignMode}
isInteractive={!isDesignMode} isInteractive={!isDesignMode}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleSelectedRowsChange}
/> />
</div> </div>
); );

View File

@ -780,9 +780,15 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
); );
case "number": case "number":
// DB에서 문자열("325")로 반환되는 경우도 숫자로 변환하여 표시
const numValue = typeof displayValue === "number"
? displayValue
: (displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)))
? Number(displayValue)
: undefined;
return ( return (
<NumberInput <NumberInput
value={typeof displayValue === "number" ? displayValue : undefined} value={numValue}
onChange={(v) => { onChange={(v) => {
setAutoGeneratedValue(null); setAutoGeneratedValue(null);
onChange?.(v ?? 0); onChange?.(v ?? 0);
@ -813,9 +819,15 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
); );
case "slider": case "slider":
// DB에서 문자열로 반환되는 경우도 숫자로 변환
const sliderValue = typeof displayValue === "number"
? displayValue
: (displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)))
? Number(displayValue)
: (config.min ?? 0);
return ( return (
<SliderInput <SliderInput
value={typeof displayValue === "number" ? displayValue : (config.min ?? 0)} value={sliderValue}
onChange={(v) => { onChange={(v) => {
setAutoGeneratedValue(null); setAutoGeneratedValue(null);
onChange?.(v); onChange?.(v);

View File

@ -194,6 +194,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null); const [selectedLeftItem, setSelectedLeftItem] = useState<any>(null);
const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템 const [expandedRightItems, setExpandedRightItems] = useState<Set<string | number>>(new Set()); // 확장된 우측 아이템
const [customLeftSelectedData, setCustomLeftSelectedData] = useState<Record<string, any>>({}); // 커스텀 모드: 좌측 선택 데이터 const [customLeftSelectedData, setCustomLeftSelectedData] = useState<Record<string, any>>({}); // 커스텀 모드: 좌측 선택 데이터
// 커스텀 모드: 탭/버튼 간 공유할 selectedRowsData 자체 관리 (항상 로컬 상태 사용)
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
const handleLocalSelectedRowsChange = useCallback(
(selectedRows: any[], selectedRowsDataNew: any[], sortBy?: string, sortOrder?: "asc" | "desc", columnOrder?: string[]) => {
setLocalSelectedRowsData(selectedRowsDataNew);
if ((props as any).onSelectedRowsChange) {
(props as any).onSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder);
}
},
[(props as any).onSelectedRowsChange],
);
const [leftSearchQuery, setLeftSearchQuery] = useState(""); const [leftSearchQuery, setLeftSearchQuery] = useState("");
const [rightSearchQuery, setRightSearchQuery] = useState(""); const [rightSearchQuery, setRightSearchQuery] = useState("");
const [isLoadingLeft, setIsLoadingLeft] = useState(false); const [isLoadingLeft, setIsLoadingLeft] = useState(false);
@ -1553,6 +1564,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (isSameItem) { if (isSameItem) {
// 선택 해제 → 전체 데이터 로드 // 선택 해제 → 전체 데이터 로드
setSelectedLeftItem(null); setSelectedLeftItem(null);
setCustomLeftSelectedData({}); // 커스텀 모드 우측 폼 데이터 초기화
setExpandedRightItems(new Set()); setExpandedRightItems(new Set());
setTabsData({}); setTabsData({});
if (activeTabIndex === 0) { if (activeTabIndex === 0) {
@ -1573,6 +1585,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
setSelectedLeftItem(item); setSelectedLeftItem(item);
setCustomLeftSelectedData(item); // 커스텀 모드 우측 폼에 선택된 데이터 전달
setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화 setExpandedRightItems(new Set()); // 좌측 항목 변경 시 우측 확장 초기화
setTabsData({}); // 모든 탭 데이터 초기화 setTabsData({}); // 모든 탭 데이터 초기화
@ -1980,6 +1993,88 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 추가 버튼 핸들러 // 추가 버튼 핸들러
const handleAddClick = useCallback( const handleAddClick = useCallback(
(panel: "left" | "right") => { (panel: "left" | "right") => {
// 좌측 패널 추가 시, addButton 모달 모드 확인
if (panel === "left") {
const addButtonConfig = componentConfig.leftPanel?.addButton;
if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) {
const leftTableName = componentConfig.leftPanel?.tableName || "";
// ScreenModal 열기 이벤트 발생
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: addButtonConfig.modalScreenId,
urlParams: {
mode: "add",
tableName: leftTableName,
},
},
}),
);
console.log("✅ [SplitPanel] 좌측 추가 모달 화면 열기:", {
screenId: addButtonConfig.modalScreenId,
tableName: leftTableName,
});
return;
}
}
// 우측 패널 추가 시, addButton 모달 모드 확인
if (panel === "right") {
const addButtonConfig =
activeTabIndex === 0
? componentConfig.rightPanel?.addButton
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addButton;
if (addButtonConfig?.mode === "modal" && addButtonConfig?.modalScreenId) {
// 커스텀 모달 화면 열기
const currentTableName =
activeTabIndex === 0
? componentConfig.rightPanel?.tableName || ""
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName || "";
// 좌측 선택 데이터를 modalDataStore에 저장 (추가 화면에서 참조 가능)
if (selectedLeftItem && componentConfig.leftPanel?.tableName) {
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(componentConfig.leftPanel!.tableName!, [selectedLeftItem]);
});
}
// ScreenModal 열기 이벤트 발생
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: addButtonConfig.modalScreenId,
urlParams: {
mode: "add",
tableName: currentTableName,
// 좌측 선택 항목의 연결 키 값 전달
...(selectedLeftItem && (() => {
const relation = activeTabIndex === 0
? componentConfig.rightPanel?.relation
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
const leftColumn = relation?.keys?.[0]?.leftColumn || relation?.leftColumn;
const rightColumn = relation?.keys?.[0]?.rightColumn || relation?.foreignKey;
if (leftColumn && rightColumn && selectedLeftItem[leftColumn] !== undefined) {
return { [rightColumn]: selectedLeftItem[leftColumn] };
}
return {};
})()),
},
},
}),
);
console.log("✅ [SplitPanel] 추가 모달 화면 열기:", {
screenId: addButtonConfig.modalScreenId,
tableName: currentTableName,
});
return;
}
}
// 기존 내장 추가 모달 로직
setAddModalPanel(panel); setAddModalPanel(panel);
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움 // 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
@ -1999,12 +2094,59 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setShowAddModal(true); setShowAddModal(true);
}, },
[selectedLeftItem, componentConfig], [selectedLeftItem, componentConfig, activeTabIndex],
); );
// 수정 버튼 핸들러 // 수정 버튼 핸들러
const handleEditClick = useCallback( const handleEditClick = useCallback(
(panel: "left" | "right", item: any) => { (panel: "left" | "right", item: any) => {
// 좌측 패널 수정 버튼 설정 확인 (모달 모드)
if (panel === "left") {
const editButtonConfig = componentConfig.leftPanel?.editButton;
if (editButtonConfig?.mode === "modal" && editButtonConfig?.modalScreenId) {
const leftTableName = componentConfig.leftPanel?.tableName || "";
// Primary Key 찾기 - 실제 DB의 id 컬럼 값을 우선 사용
let primaryKeyValue = item.id || item.ID;
if (primaryKeyValue === undefined || primaryKeyValue === null) {
// id가 없으면 sourceColumn 시도, 마지막으로 첫 번째 키
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
primaryKeyValue = item[sourceColumn];
if (primaryKeyValue === undefined || primaryKeyValue === null) {
const firstKey = Object.keys(item)[0];
primaryKeyValue = item[firstKey];
}
}
// modalDataStore에 저장
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
useModalDataStore.getState().setData(leftTableName, [item]);
});
// ScreenModal 열기 이벤트 발생
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: editButtonConfig.modalScreenId,
urlParams: {
mode: "edit",
editId: primaryKeyValue,
tableName: leftTableName,
},
},
}),
);
console.log("✅ [SplitPanel] 좌측 수정 모달 화면 열기:", {
screenId: editButtonConfig.modalScreenId,
tableName: leftTableName,
primaryKeyValue,
});
return;
}
}
// 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원) // 우측 패널 수정 버튼 설정 확인 (탭별 설정 지원)
if (panel === "right") { if (panel === "right") {
const editButtonConfig = const editButtonConfig =
@ -2112,6 +2254,81 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
[componentConfig, activeTabIndex], [componentConfig, activeTabIndex],
); );
// 커스텀 모드 우측 패널 저장 (인라인 편집 데이터)
const handleCustomRightSave = useCallback(async () => {
if (!selectedLeftItem || !customLeftSelectedData || Object.keys(customLeftSelectedData).length === 0) {
toast({
title: "저장 실패",
description: "저장할 데이터가 없습니다. 좌측에서 항목을 선택해주세요.",
variant: "destructive",
});
return;
}
const tableName = componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName;
if (!tableName) {
toast({
title: "저장 실패",
description: "테이블 정보가 없습니다.",
variant: "destructive",
});
return;
}
// Primary Key 찾기 - 실제 DB의 id 컬럼 값을 사용 (sourceColumn은 관계 연결용이므로 PK로 사용하지 않음)
const primaryKey = selectedLeftItem.id || selectedLeftItem.ID;
if (!primaryKey) {
toast({
title: "저장 실패",
description: "Primary Key를 찾을 수 없습니다.",
variant: "destructive",
});
return;
}
try {
// 프론트엔드 전용 필드 제거
const cleanData = { ...customLeftSelectedData };
delete cleanData.children;
delete cleanData.level;
delete cleanData._originalItems;
// company_code 자동 추가
if (companyCode && !cleanData.company_code) {
cleanData.company_code = companyCode;
}
console.log("📝 [SplitPanel] 커스텀 우측 패널 저장:", { tableName, primaryKey, data: cleanData });
const response = await dataApi.updateRecord(tableName, primaryKey, cleanData);
if (response.success) {
toast({
title: "저장 완료",
description: "데이터가 저장되었습니다.",
});
// 좌측 데이터 새로고침 (변경된 항목 반영)
loadLeftData();
// selectedLeftItem도 업데이트
setSelectedLeftItem(customLeftSelectedData);
} else {
toast({
title: "저장 실패",
description: response.error || "데이터 저장에 실패했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("커스텀 우측 패널 저장 오류:", error);
toast({
title: "저장 오류",
description: "데이터 저장 중 오류가 발생했습니다.",
variant: "destructive",
});
}
}, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]);
// 수정 모달 저장 // 수정 모달 저장
const handleEditModalSave = useCallback(async () => { const handleEditModalSave = useCallback(async () => {
const tableName = const tableName =
@ -2304,7 +2521,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (deleteModalPanel === "left") { if (deleteModalPanel === "left") {
loadLeftData(); loadLeftData();
// 삭제된 항목이 선택되어 있었으면 선택 해제 // 삭제된 항목이 선택되어 있었으면 선택 해제
if (selectedLeftItem && selectedLeftItem[sourceColumn] === primaryKey) { const deletedId = deleteModalItem?.id || deleteModalItem?.ID;
if (selectedLeftItem && (selectedLeftItem.id === deletedId || selectedLeftItem.ID === deletedId)) {
setSelectedLeftItem(null); setSelectedLeftItem(null);
setRightData(null); setRightData(null);
} }
@ -2743,6 +2961,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100); const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100);
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환 // 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
// componentConfig의 주요 속성을 최상위로 펼침 (일반 화면의 overrides 플래트닝과 동일)
const componentData = { const componentData = {
id: comp.id, id: comp.id,
type: "component" as const, type: "component" as const,
@ -2752,6 +2971,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
size: { width: displayWidth, height: displayHeight }, size: { width: displayWidth, height: displayHeight },
componentConfig: comp.componentConfig || {}, componentConfig: comp.componentConfig || {},
style: comp.style || {}, style: comp.style || {},
// 파일 업로드/미디어 등이 component.tableName, component.columnName을 직접 참조하므로 펼침
tableName: comp.componentConfig?.tableName,
columnName: comp.componentConfig?.columnName,
webType: comp.componentConfig?.webType,
inputType: comp.inputType || comp.componentConfig?.inputType,
}; };
if (isDesignMode) { if (isDesignMode) {
@ -2920,8 +3144,17 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<DynamicComponentRenderer <DynamicComponentRenderer
component={componentData as any} component={componentData as any}
isDesignMode={false} isDesignMode={false}
isInteractive={true}
formData={{}} formData={{}}
tableName={componentConfig.leftPanel?.tableName} tableName={componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
onFormDataChange={(data: any) => { onFormDataChange={(data: any) => {
// 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처 // 커스텀 모드: 좌측 카드/테이블 선택 시 데이터 캡처
if (data?.selectedRowsData && data.selectedRowsData.length > 0) { if (data?.selectedRowsData && data.selectedRowsData.length > 0) {
@ -3317,29 +3550,33 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{/* 항목별 버튼들 */} {/* 항목별 버튼들 */}
{!isDesignMode && ( {!isDesignMode && (
<div className="flex flex-shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex flex-shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
{/* 수정 버튼 */} {/* 수정 버튼 (showEdit 활성화 시에만 표시) */}
<button {(componentConfig.leftPanel?.showEdit !== false) && (
onClick={(e) => { <button
e.stopPropagation(); onClick={(e) => {
handleEditClick("left", item); e.stopPropagation();
}} handleEditClick("left", item);
className="rounded p-1 transition-colors hover:bg-gray-200" }}
title="수정" className="rounded p-1 transition-colors hover:bg-gray-200"
> title="수정"
<Pencil className="h-4 w-4 text-gray-600" /> >
</button> <Pencil className="h-4 w-4 text-gray-600" />
</button>
)}
{/* 삭제 버튼 */} {/* 삭제 버튼 (showDelete 활성화 시에만 표시) */}
<button {(componentConfig.leftPanel?.showDelete !== false) && (
onClick={(e) => { <button
e.stopPropagation(); onClick={(e) => {
handleDeleteClick("left", item); e.stopPropagation();
}} handleDeleteClick("left", item);
className="rounded p-1 transition-colors hover:bg-red-100" }}
title="삭제" className="rounded p-1 transition-colors hover:bg-red-100"
> title="삭제"
<Trash2 className="h-4 w-4 text-red-600" /> >
</button> <Trash2 className="h-4 w-4 text-red-600" />
</button>
)}
{/* 항목별 추가 버튼 */} {/* 항목별 추가 버튼 */}
{componentConfig.leftPanel?.showItemAddButton && ( {componentConfig.leftPanel?.showItemAddButton && (
@ -3456,6 +3693,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
{!isDesignMode && ( {!isDesignMode && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* 커스텀 모드 기본정보 탭: 저장 버튼 */}
{activeTabIndex === 0 && componentConfig.rightPanel?.displayMode === "custom" && selectedLeftItem && (
<Button size="sm" variant="default" onClick={handleCustomRightSave}>
<Save className="mr-1 h-4 w-4" />
</Button>
)}
{activeTabIndex === 0 {activeTabIndex === 0
? componentConfig.rightPanel?.showAdd && ( ? componentConfig.rightPanel?.showAdd && (
<Button size="sm" variant="outline" onClick={() => handleAddClick("right")}> <Button size="sm" variant="outline" onClick={() => handleAddClick("right")}>
@ -3736,6 +3980,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})() })()
) : componentConfig.rightPanel?.displayMode === "custom" ? ( ) : componentConfig.rightPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
// 실행 모드에서 좌측 미선택 시 안내 메시지 표시
!isDesignMode && !selectedLeftItem ? (
<div className="flex h-full items-center justify-center">
<div className="text-muted-foreground text-center text-sm">
<p className="mb-2"> </p>
<p className="text-xs"> </p>
</div>
</div>
) : (
<div <div
className="relative h-full w-full" className="relative h-full w-full"
data-split-panel-container="true" data-split-panel-container="true"
@ -3757,6 +4010,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100); const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100);
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환 // 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
// componentConfig의 주요 속성을 최상위로 펼침 (일반 화면의 overrides 플래트닝과 동일)
const componentData = { const componentData = {
id: comp.id, id: comp.id,
type: "component" as const, type: "component" as const,
@ -3766,6 +4020,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
size: { width: displayWidth, height: displayHeight }, size: { width: displayWidth, height: displayHeight },
componentConfig: comp.componentConfig || {}, componentConfig: comp.componentConfig || {},
style: comp.style || {}, style: comp.style || {},
// 파일 업로드/미디어 등이 component.tableName, component.columnName을 직접 참조하므로 펼침
tableName: comp.componentConfig?.tableName,
columnName: comp.componentConfig?.columnName,
webType: comp.componentConfig?.webType,
inputType: comp.inputType || comp.componentConfig?.inputType,
}; };
if (isDesignMode) { if (isDesignMode) {
@ -3923,8 +4182,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
<DynamicComponentRenderer <DynamicComponentRenderer
component={componentData as any} component={componentData as any}
isDesignMode={false} isDesignMode={false}
isInteractive={true}
formData={customLeftSelectedData} formData={customLeftSelectedData}
onFormDataChange={(fieldName: string, value: any) => {
setCustomLeftSelectedData((prev: Record<string, any>) => ({ ...prev, [fieldName]: value }));
}}
tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName} tableName={componentConfig.rightPanel?.tableName || componentConfig.leftPanel?.tableName}
menuObjid={(props as any).menuObjid}
screenId={(props as any).screenId}
userId={(props as any).userId}
userName={(props as any).userName}
companyCode={companyCode}
allComponents={(props as any).allComponents}
selectedRowsData={localSelectedRowsData}
onSelectedRowsChange={handleLocalSelectedRowsChange}
/> />
</div> </div>
); );
@ -3944,6 +4215,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
)} )}
</div> </div>
)
) : isLoadingRight ? ( ) : isLoadingRight ? (
// 로딩 중 // 로딩 중
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">

View File

@ -1066,6 +1066,62 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
</div> </div>
)} )}
{/* ===== 10-1. 추가 버튼 설정 ===== */}
{tab.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") => {
updateTab({
addButton: { ...tab.addButton, enabled: true, mode: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{tab.addButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={tab.addButton?.modalScreenId}
onChange={(screenId) => {
updateTab({
addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
});
}}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={tab.addButton?.buttonLabel || ""}
onChange={(e) => {
updateTab({
addButton: { ...tab.addButton, enabled: true, buttonLabel: e.target.value || undefined },
});
}}
placeholder="추가"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{/* ===== 11. 삭제 버튼 설정 ===== */} {/* ===== 11. 삭제 버튼 설정 ===== */}
{tab.showDelete && ( {tab.showDelete && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
@ -2072,6 +2128,169 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/> />
</div> </div>
{/* 좌측 패널 버튼 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
{/* 버튼 표시 체크박스 */}
<div className="grid grid-cols-4 gap-2">
<div className="flex items-center gap-1">
<Checkbox
id="left-showSearch"
checked={config.leftPanel?.showSearch ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showSearch: !!checked })}
/>
<label htmlFor="left-showSearch" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showAdd"
checked={config.leftPanel?.showAdd ?? false}
onCheckedChange={(checked) => updateLeftPanel({ showAdd: !!checked })}
/>
<label htmlFor="left-showAdd" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showEdit"
checked={config.leftPanel?.showEdit ?? true}
onCheckedChange={(checked) => updateLeftPanel({ showEdit: !!checked })}
/>
<label htmlFor="left-showEdit" className="text-xs"></label>
</div>
<div className="flex items-center gap-1">
<Checkbox
id="left-showDelete"
checked={config.leftPanel?.showDelete ?? true}
onCheckedChange={(checked) => updateLeftPanel({ showDelete: !!checked })}
/>
<label htmlFor="left-showDelete" className="text-xs"></label>
</div>
</div>
{/* 추가 버튼 상세 설정 */}
{config.leftPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={config.leftPanel?.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateLeftPanel({
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: value },
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.leftPanel?.addButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={config.leftPanel?.addButton?.modalScreenId}
onChange={(screenId) =>
updateLeftPanel({
addButton: { ...config.leftPanel?.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
})
}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.leftPanel?.addButton?.buttonLabel || ""}
onChange={(e) =>
updateLeftPanel({
addButton: {
...config.leftPanel?.addButton,
enabled: true,
mode: config.leftPanel?.addButton?.mode || "auto",
buttonLabel: e.target.value || undefined,
},
})
}
placeholder="추가"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
{/* 수정 버튼 상세 설정 */}
{(config.leftPanel?.showEdit ?? true) && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-3">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-xs font-semibold"> </h3>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={config.leftPanel?.editButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateLeftPanel({
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: value },
})
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{config.leftPanel?.editButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={config.leftPanel?.editButton?.modalScreenId}
onChange={(screenId) =>
updateLeftPanel({
editButton: { ...config.leftPanel?.editButton, enabled: true, mode: "modal", modalScreenId: screenId },
})
}
/>
</div>
)}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={config.leftPanel?.editButton?.buttonLabel || ""}
onChange={(e) =>
updateLeftPanel({
editButton: {
...config.leftPanel?.editButton,
enabled: true,
mode: config.leftPanel?.editButton?.mode || "auto",
buttonLabel: e.target.value || undefined,
},
})
}
placeholder="수정"
className="h-7 text-xs"
/>
</div>
</div>
</div>
)}
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -2776,6 +2995,85 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
)} )}
</div> </div>
{/* 🆕 우측 패널 추가 버튼 설정 */}
{config.rightPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
</div>
</div>
<div className="space-y-3 border-l-2 pl-4">
<div>
<Label className="text-xs"> </Label>
<Select
value={config.rightPanel?.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton,
mode: value,
enabled: true,
},
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ( )</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
{config.rightPanel?.addButton?.mode === "modal"
? "지정한 화면을 모달로 열어 데이터를 추가합니다"
: "내장 폼으로 데이터를 추가합니다"}
</p>
</div>
{config.rightPanel?.addButton?.mode === "modal" && (
<div>
<Label className="text-xs"> </Label>
<ScreenSelector
value={config.rightPanel?.addButton?.modalScreenId}
onChange={(screenId) =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton!,
modalScreenId: screenId,
},
})
}
/>
</div>
)}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.rightPanel?.addButton?.buttonLabel || "추가"}
onChange={(e) =>
updateRightPanel({
addButton: {
...config.rightPanel?.addButton!,
buttonLabel: e.target.value,
enabled: true,
mode: config.rightPanel?.addButton?.mode || "auto",
},
})
}
placeholder="추가"
className="h-8 text-xs"
/>
</div>
</div>
</div>
)}
{/* 🆕 우측 패널 삭제 버튼 설정 */} {/* 🆕 우측 패널 삭제 버튼 설정 */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -115,6 +115,14 @@ export interface AdditionalTabConfig {
groupByColumns?: string[]; groupByColumns?: string[];
}; };
// 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
};
deleteButton?: { deleteButton?: {
enabled: boolean; enabled: boolean;
buttonLabel?: string; buttonLabel?: string;
@ -141,6 +149,23 @@ export interface SplitPanelLayoutConfig {
showAdd?: boolean; showAdd?: boolean;
showEdit?: boolean; // 수정 버튼 showEdit?: boolean; // 수정 버튼
showDelete?: boolean; // 삭제 버튼 showDelete?: boolean; // 삭제 버튼
// 수정 버튼 설정 (모달 화면 연결 지원)
editButton?: {
enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 편집, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "수정")
};
// 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
};
columns?: Array<{ columns?: Array<{
name: string; name: string;
label: string; label: string;
@ -307,6 +332,14 @@ export interface SplitPanelLayoutConfig {
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"]) groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
}; };
// 🆕 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean; // 추가 버튼 표시 여부 (기본: true)
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
};
// 🆕 삭제 버튼 설정 // 🆕 삭제 버튼 설정
deleteButton?: { deleteButton?: {
enabled: boolean; // 삭제 버튼 표시 여부 (기본: true) enabled: boolean; // 삭제 버튼 표시 여부 (기본: true)