fix: Refine ExcelUploadModal and TableListComponent for improved data handling

- Updated ExcelUploadModal to automatically generate numbering codes when Excel values are empty, enhancing user experience during data uploads.
- Modified TableListComponent to display only the first image in case of multiple images, ensuring clarity in image representation.
- Improved data handling logic in TableListComponent to prevent unnecessary processing of string values.
This commit is contained in:
kjs
2026-02-25 14:42:42 +09:00
parent 38dda2f807
commit 262221e300
7 changed files with 342 additions and 35 deletions

View File

@@ -939,6 +939,24 @@ export async function addTableData(
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
data,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 추가
await tableManagementService.addTableData(tableName, data);
@@ -1041,6 +1059,26 @@ export async function editTableData(
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
const excludeId = originalData?.id ? String(originalData.id) : undefined;
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
updatedData,
companyCode,
excludeId
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 수정
await tableManagementService.editTableData(
tableName,
@@ -2653,8 +2691,22 @@ export async function toggleTableIndex(
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
if (action === "create") {
let indexColumns = `"${columnName}"`;
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
if (indexType === "unique") {
const hasCompanyCode = await query(
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
indexColumns = `"company_code", "${columnName}"`;
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
}
}
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
logger.info(`인덱스 생성: ${sql}`);
await query(sql);
} else if (action === "drop") {
@@ -2675,15 +2727,45 @@ export async function toggleTableIndex(
} catch (error: any) {
logger.error("인덱스 토글 오류:", error);
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
const errorMsg = error.message?.includes("duplicate key")
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
: "인덱스 설정 중 오류가 발생했습니다.";
const errMsg = error.message || "";
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
let duplicates: any[] = [];
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
if (
errMsg.includes("could not create unique index") ||
errMsg.includes("duplicate key")
) {
const { columnName, tableName } = { ...req.params, ...req.body };
try {
duplicates = await query(
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch {
try {
duplicates = await query(
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch { /* 중복 조회 실패 시 무시 */ }
}
const dupDetails = duplicates.length > 0
? duplicates.map((d: any) => {
const company = d.company_code ? `[${d.company_code}] ` : "";
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
}).join(", ")
: "";
userMessage = dupDetails
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
}
res.status(500).json({
success: false,
message: errorMsg,
error: error instanceof Error ? error.message : "Unknown error",
message: userMessage,
error: errMsg,
duplicates,
});
}
}
@@ -2776,3 +2858,89 @@ export async function toggleColumnNullable(
});
}
}
/**
* UNIQUE 토글 (회사별 소프트 제약조건)
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*
* DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다.
* 저장 시 앱 레벨에서 중복 검증을 수행한다.
*/
export async function toggleColumnUnique(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { unique } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof unique !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, unique(boolean)이 필요합니다.",
});
return;
}
const isUniqueValue = unique ? "Y" : "N";
if (unique) {
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const dupQuery = companyCode === "*"
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
const dupParams = companyCode === "*" ? [] : [companyCode];
const dupResult = await query<any>(dupQuery, dupParams);
if (dupResult.length > 0) {
const dupDetails = dupResult
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
.join(", ");
res.status(400).json({
success: false,
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
});
return;
}
}
}
// table_type_columns에 회사별 is_unique 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
[tableName, columnName, isUniqueValue, companyCode]
);
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: unique
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
});
} catch (error: any) {
logger.error("UNIQUE 토글 오류:", error);
res.status(500).json({
success: false,
message: "UNIQUE 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}

View File

@@ -32,6 +32,7 @@ import {
setTablePrimaryKey, // 🆕 PK 설정
toggleTableIndex, // 🆕 인덱스 토글
toggleColumnNullable, // 🆕 NOT NULL 토글
toggleColumnUnique, // 🆕 UNIQUE 토글
} from "../controllers/tableManagementController";
const router = express.Router();
@@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex);
*/
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
/**
* UNIQUE 토글
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*/
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
/**
* 테이블 존재 여부 확인
* GET /api/table-management/tables/:tableName/exists

View File

@@ -204,6 +204,10 @@ export class TableManagementService {
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
@@ -250,6 +254,10 @@ export class TableManagementService {
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
ELSE c.is_nullable
END as "isNullable",
CASE
WHEN cl.is_unique = 'Y' THEN 'YES'
ELSE 'NO'
END as "isUnique",
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
c.column_default as "defaultValue",
c.character_maximum_length as "maxLength",
@@ -2534,6 +2542,93 @@ export class TableManagementService {
}
}
/**
* 회사별 UNIQUE 소프트 제약조건 검증
* table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다.
* @param excludeId 수정 시 자기 자신은 제외
*/
async validateUniqueConstraints(
tableName: string,
data: Record<string, any>,
companyCode: string,
excludeId?: string
): Promise<string[]> {
try {
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = $2`,
[tableName, companyCode]
);
// 회사별 설정이 없으면 공통 설정 확인
if (uniqueColumns.length === 0 && companyCode !== "*") {
const globalUnique = await query<{ column_name: string; column_label: string }>(
`SELECT
ttc.column_name,
COALESCE(ttc.column_label, ttc.column_name) as column_label
FROM table_type_columns ttc
WHERE ttc.table_name = $1
AND ttc.is_unique = 'Y'
AND ttc.company_code = '*'
AND NOT EXISTS (
SELECT 1 FROM table_type_columns ttc2
WHERE ttc2.table_name = ttc.table_name
AND ttc2.column_name = ttc.column_name
AND ttc2.company_code = $2
)`,
[tableName, companyCode]
);
uniqueColumns = globalUnique;
}
if (uniqueColumns.length === 0) return [];
const violations: string[] = [];
for (const col of uniqueColumns) {
const value = data[col.column_name];
if (value === null || value === undefined || value === "") continue;
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
const hasCompanyCode = await query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
let dupQuery: string;
let dupParams: any[];
if (hasCompanyCode.length > 0 && companyCode !== "*") {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
} else {
dupQuery = excludeId
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
dupParams = excludeId ? [value, excludeId] : [value];
}
const dupResult = await query(dupQuery, dupParams);
if (dupResult.length > 0) {
violations.push(`${col.column_label} (${value})`);
}
}
return violations;
} catch (error) {
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
return [];
}
}
/**
* 테이블에 데이터 추가
* @returns 무시된 컬럼 정보 (디버깅용)