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:
commit
95f668d40d
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue