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
c551e82eee
|
|
@ -413,6 +413,16 @@ class MasterDetailExcelService {
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응)
|
||||
const detailIdCheck = await queryOne<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'id'
|
||||
) as exists`,
|
||||
[detailTable]
|
||||
);
|
||||
const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`;
|
||||
|
||||
// JOIN 쿼리 실행
|
||||
const sql = `
|
||||
SELECT ${selectClause}
|
||||
|
|
@ -422,7 +432,7 @@ class MasterDetailExcelService {
|
|||
AND m.company_code = d.company_code
|
||||
${entityJoinClauses}
|
||||
${whereClause}
|
||||
ORDER BY m."${masterKeyColumn}", d.id
|
||||
ORDER BY m."${masterKeyColumn}", ${detailOrderColumn}
|
||||
`;
|
||||
|
||||
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||
|
|
@ -481,14 +491,67 @@ class MasterDetailExcelService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환
|
||||
* 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback
|
||||
*/
|
||||
private async detectNumberingRuleForColumn(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode?: string
|
||||
): Promise<{ numberingRuleId: string } | null> {
|
||||
try {
|
||||
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const params = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT input_type, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
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) {
|
||||
if (row.input_type === "numbering") {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
|
||||
if (settings?.numberingRuleId) {
|
||||
logger.info(`채번 컬럼 감지: ${tableName}.${columnName} → 규칙 ID: ${settings.numberingRuleId} (company: ${row.company_code})`);
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`채번 컬럼 아님: ${tableName}.${columnName}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||
*
|
||||
* 처리 로직:
|
||||
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||
* 4. 새 디테일 데이터 INSERT
|
||||
* 1. 마스터 키 컬럼이 채번 타입인지 확인
|
||||
* 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT
|
||||
* 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT
|
||||
* 3. 디테일 데이터 INSERT
|
||||
*/
|
||||
async uploadJoinedData(
|
||||
relation: MasterDetailRelation,
|
||||
|
|
@ -513,94 +576,164 @@ class MasterDetailExcelService {
|
|||
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||
|
||||
// 1. 데이터를 마스터 키로 그룹화
|
||||
const groupedData = new Map<string, Record<string, any>[]>();
|
||||
|
||||
for (const row of data) {
|
||||
const masterKey = row[masterKeyColumn];
|
||||
if (!masterKey) {
|
||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||
continue;
|
||||
}
|
||||
// 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지)
|
||||
const masterColsResult = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[masterTable]
|
||||
);
|
||||
const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
if (!groupedData.has(masterKey)) {
|
||||
groupedData.set(masterKey, []);
|
||||
const detailColsResult = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[detailTable]
|
||||
);
|
||||
const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선)
|
||||
const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode);
|
||||
const isAutoNumbering = !!numberingInfo;
|
||||
|
||||
logger.info(`마스터 키 채번 감지:`, {
|
||||
masterKeyColumn,
|
||||
isAutoNumbering,
|
||||
numberingRuleId: numberingInfo?.numberingRuleId
|
||||
});
|
||||
|
||||
// 데이터 그룹화
|
||||
const groupedData = new Map<string, Record<string, any>[]>();
|
||||
|
||||
if (isAutoNumbering) {
|
||||
// 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화
|
||||
const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name);
|
||||
|
||||
for (const row of data) {
|
||||
// 다른 마스터 컬럼 값들을 조합해 그룹 키 생성
|
||||
const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||");
|
||||
if (!groupedData.has(groupKey)) {
|
||||
groupedData.set(groupKey, []);
|
||||
}
|
||||
groupedData.get(groupKey)!.push(row);
|
||||
}
|
||||
groupedData.get(masterKey)!.push(row);
|
||||
|
||||
logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`);
|
||||
} else {
|
||||
// 일반 모드: 마스터 키 값으로 그룹화
|
||||
for (const row of data) {
|
||||
const masterKey = row[masterKeyColumn];
|
||||
if (!masterKey) {
|
||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||
continue;
|
||||
}
|
||||
if (!groupedData.has(masterKey)) {
|
||||
groupedData.set(masterKey, []);
|
||||
}
|
||||
groupedData.get(masterKey)!.push(row);
|
||||
}
|
||||
|
||||
logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||
}
|
||||
|
||||
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||
|
||||
// 2. 각 그룹 처리
|
||||
for (const [masterKey, rows] of groupedData.entries()) {
|
||||
// 각 그룹 처리
|
||||
for (const [groupKey, rows] of groupedData.entries()) {
|
||||
try {
|
||||
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||||
// 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키)
|
||||
let masterKey: string;
|
||||
|
||||
if (isAutoNumbering) {
|
||||
// 채번 규칙으로 마스터 키 자동 생성
|
||||
masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode);
|
||||
logger.info(`채번 생성: ${masterKey}`);
|
||||
} else {
|
||||
masterKey = groupKey;
|
||||
}
|
||||
|
||||
// 마스터 데이터 추출 (첫 번째 행에서)
|
||||
const masterData: Record<string, any> = {};
|
||||
// 마스터 키 컬럼은 항상 설정 (분할패널 컬럼 목록에 없어도)
|
||||
masterData[masterKeyColumn] = masterKey;
|
||||
for (const col of masterColumns) {
|
||||
if (col.name === masterKeyColumn) continue; // 이미 위에서 설정
|
||||
if (rows[0][col.name] !== undefined) {
|
||||
masterData[col.name] = rows[0][col.name];
|
||||
}
|
||||
}
|
||||
|
||||
// 회사 코드, 작성자 추가
|
||||
masterData.company_code = companyCode;
|
||||
if (userId) {
|
||||
// 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만)
|
||||
if (masterExistingCols.has("company_code")) {
|
||||
masterData.company_code = companyCode;
|
||||
}
|
||||
if (userId && masterExistingCols.has("writer")) {
|
||||
masterData.writer = userId;
|
||||
}
|
||||
|
||||
// 2b. 마스터 UPSERT
|
||||
const existingMaster = await client.query(
|
||||
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
// INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가)
|
||||
const buildInsertSQL = (table: string, data: Record<string, any>, existingCols: Set<string>) => {
|
||||
const cols = Object.keys(data);
|
||||
const hasCreatedDate = existingCols.has("created_date");
|
||||
const colList = hasCreatedDate ? [...cols, "created_date"] : cols;
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`);
|
||||
const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders;
|
||||
const values = cols.map(k => data[k]);
|
||||
return {
|
||||
sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`,
|
||||
values,
|
||||
};
|
||||
};
|
||||
|
||||
if (existingMaster.rows.length > 0) {
|
||||
// UPDATE
|
||||
const updateCols = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const updateValues = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map(k => masterData[k]);
|
||||
|
||||
if (updateCols.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}"
|
||||
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||
[...updateValues, masterKey, companyCode]
|
||||
);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
// INSERT
|
||||
const insertCols = Object.keys(masterData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => masterData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
if (isAutoNumbering) {
|
||||
// 채번 모드: 항상 INSERT (새 마스터 생성)
|
||||
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.masterInserted++;
|
||||
} else {
|
||||
// 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT)
|
||||
const existingMaster = await client.query(
|
||||
`SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
|
||||
if (existingMaster.rows.length > 0) {
|
||||
const updateCols = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const updateValues = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map(k => masterData[k]);
|
||||
|
||||
if (updateCols.length > 0) {
|
||||
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}"
|
||||
SET ${updateCols.join(", ")}${updatedDateClause}
|
||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||
[...updateValues, masterKey, companyCode]
|
||||
);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.masterInserted++;
|
||||
}
|
||||
|
||||
// 일반 모드에서만 기존 디테일 삭제 (채번 모드는 새 마스터이므로 삭제할 디테일 없음)
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
result.detailDeleted += deleteResult.rowCount || 0;
|
||||
}
|
||||
|
||||
// 2c. 기존 디테일 삭제
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
result.detailDeleted += deleteResult.rowCount || 0;
|
||||
|
||||
// 2d. 새 디테일 INSERT
|
||||
// 디테일 INSERT
|
||||
for (const row of rows) {
|
||||
const detailData: Record<string, any> = {};
|
||||
|
||||
// FK 컬럼 추가
|
||||
// FK 컬럼에 마스터 키 주입
|
||||
detailData[detailFkColumn] = masterKey;
|
||||
detailData.company_code = companyCode;
|
||||
if (userId) {
|
||||
if (detailExistingCols.has("company_code")) {
|
||||
detailData.company_code = companyCode;
|
||||
}
|
||||
if (userId && detailExistingCols.has("writer")) {
|
||||
detailData.writer = userId;
|
||||
}
|
||||
|
||||
|
|
@ -611,20 +744,13 @@ class MasterDetailExcelService {
|
|||
}
|
||||
}
|
||||
|
||||
const insertCols = Object.keys(detailData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => detailData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.detailInserted++;
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||||
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||||
result.errors.push(`그룹 처리 실패: ${error.message}`);
|
||||
logger.error(`그룹 처리 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -84,12 +84,9 @@ export interface ExcelUploadModalProps {
|
|||
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||
};
|
||||
// 🆕 마스터-디테일 엑셀 업로드 설정
|
||||
// 마스터-디테일 엑셀 업로드 설정
|
||||
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
||||
// 🆕 단일 테이블 채번 설정
|
||||
numberingRuleId?: string;
|
||||
numberingTargetColumn?: string;
|
||||
// 🆕 업로드 후 제어 실행 설정
|
||||
// 업로드 후 제어 실행 설정
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
||||
}
|
||||
|
||||
|
|
@ -112,9 +109,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
isMasterDetail = false,
|
||||
masterDetailRelation,
|
||||
masterDetailExcelConfig,
|
||||
// 단일 테이블 채번 설정
|
||||
numberingRuleId,
|
||||
numberingTargetColumn,
|
||||
// 업로드 후 제어 실행 설정
|
||||
afterUploadFlows,
|
||||
}) => {
|
||||
|
|
@ -627,6 +621,44 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// 테이블 타입 관리에서 채번 컬럼 자동 감지
|
||||
const detectNumberingColumn = async (
|
||||
targetTableName: string
|
||||
): Promise<{ columnName: string; numberingRuleId: string } | null> => {
|
||||
try {
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const response = await getTableColumns(targetTableName);
|
||||
|
||||
if (response.success && response.data?.columns) {
|
||||
for (const col of response.data.columns) {
|
||||
if (col.inputType === "numbering") {
|
||||
try {
|
||||
const settings =
|
||||
typeof col.detailSettings === "string"
|
||||
? JSON.parse(col.detailSettings)
|
||||
: col.detailSettings;
|
||||
if (settings?.numberingRuleId) {
|
||||
console.log(
|
||||
`✅ 채번 컬럼 자동 감지: ${col.columnName} → 규칙 ID: ${settings.numberingRuleId}`
|
||||
);
|
||||
return {
|
||||
columnName: col.columnName,
|
||||
numberingRuleId: settings.numberingRuleId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// detailSettings 파싱 실패 시 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("채번 컬럼 감지 실패:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 업로드 핸들러
|
||||
const handleUpload = async () => {
|
||||
if (!file || !tableName) {
|
||||
|
|
@ -667,19 +699,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
||||
);
|
||||
|
||||
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
|
||||
// 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번 자동 감지)
|
||||
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
||||
// 마스터 테이블에서 채번 컬럼 자동 감지
|
||||
const masterNumberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable);
|
||||
const detectedNumberingRuleId = masterNumberingInfo?.numberingRuleId || masterDetailExcelConfig?.numberingRuleId;
|
||||
|
||||
console.log("📊 마스터-디테일 간단 모드 업로드:", {
|
||||
masterDetailRelation,
|
||||
masterFieldValues,
|
||||
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
||||
detectedNumberingRuleId,
|
||||
autoDetected: !!masterNumberingInfo,
|
||||
});
|
||||
|
||||
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
|
||||
screenId,
|
||||
filteredData,
|
||||
masterFieldValues,
|
||||
masterDetailExcelConfig?.numberingRuleId || undefined,
|
||||
detectedNumberingRuleId || undefined,
|
||||
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
|
||||
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
|
||||
);
|
||||
|
|
@ -704,6 +741,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
else if (isMasterDetail && screenId && masterDetailRelation) {
|
||||
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
|
||||
|
||||
// 마스터 키 컬럼 매핑 검증 (채번 타입이면 자동 생성되므로 검증 생략)
|
||||
const masterKeyCol = masterDetailRelation.masterKeyColumn;
|
||||
const hasMasterKey = filteredData.length > 0 && filteredData[0][masterKeyCol] !== undefined && filteredData[0][masterKeyCol] !== null && filteredData[0][masterKeyCol] !== "";
|
||||
if (!hasMasterKey) {
|
||||
// 채번 여부 확인 - 채번이면 백엔드에서 자동 생성하므로 통과
|
||||
const numberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable);
|
||||
const isMasterKeyAutoNumbering = numberingInfo && numberingInfo.columnName === masterKeyCol;
|
||||
|
||||
if (!isMasterKeyAutoNumbering) {
|
||||
toast.error(
|
||||
`마스터 키 컬럼(${masterKeyCol})이 매핑되지 않았습니다. 컬럼 매핑에서 [마스터] 항목을 확인해주세요.`
|
||||
);
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
console.log(`✅ 마스터 키(${masterKeyCol})는 채번 타입 → 백엔드에서 자동 생성`);
|
||||
}
|
||||
|
||||
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
|
||||
screenId,
|
||||
filteredData
|
||||
|
|
@ -731,8 +786,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
let skipCount = 0;
|
||||
let overwriteCount = 0;
|
||||
|
||||
// 단일 테이블 채번 설정 확인
|
||||
const hasNumbering = numberingRuleId && numberingTargetColumn;
|
||||
// 단일 테이블 채번 자동 감지 (테이블 타입 관리에서 input_type = 'numbering' 컬럼)
|
||||
const numberingInfo = await detectNumberingColumn(tableName);
|
||||
const hasNumbering = !!numberingInfo;
|
||||
|
||||
// 중복 체크 설정 확인
|
||||
const duplicateCheckMappings = columnMappings.filter(
|
||||
|
|
@ -816,14 +872,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
continue;
|
||||
}
|
||||
|
||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만)
|
||||
if (hasNumbering && uploadMode === "insert" && !shouldUpdate) {
|
||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용)
|
||||
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
|
||||
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
||||
if (numberingResponse.data?.success && generatedCode) {
|
||||
dataToSave[numberingTargetColumn] = generatedCode;
|
||||
dataToSave[numberingInfo.columnName] = generatedCode;
|
||||
}
|
||||
} catch (numError) {
|
||||
console.error("채번 오류:", numError);
|
||||
|
|
|
|||
|
|
@ -3777,7 +3777,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
/**
|
||||
* 마스터-디테일 엑셀 업로드 설정 컴포넌트
|
||||
* 분할 패널 + column_labels에서 관계를 자동 감지하고, 사용자는 채번 규칙만 선택
|
||||
* 분할 패널 + column_labels에서 관계를 자동 감지 (채번은 테이블 타입 관리에서 자동 감지)
|
||||
*/
|
||||
const MasterDetailExcelUploadConfig: React.FC<{
|
||||
config: any;
|
||||
|
|
@ -4005,7 +4005,7 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
{/* 마스터 키 자동 생성 안내 */}
|
||||
{relationInfo && (
|
||||
<p className="text-muted-foreground border-t pt-2 text-xs">
|
||||
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값은 위에서 설정한 채번 규칙으로 자동
|
||||
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값은 테이블 타입 관리에서 설정된 채번 규칙으로 자동
|
||||
생성됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -4114,165 +4114,15 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
};
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 채번 규칙 설정 (단일 테이블/마스터-디테일 모두 사용 가능)
|
||||
* 엑셀 업로드 채번 규칙 안내 (테이블 타입 관리에서 자동 감지)
|
||||
*/
|
||||
const ExcelNumberingRuleConfig: React.FC<{
|
||||
config: { numberingRuleId?: string; numberingTargetColumn?: string };
|
||||
updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void;
|
||||
tableName?: string; // 단일 테이블인 경우 테이블명
|
||||
hasSplitPanel?: boolean; // 분할 패널 여부 (마스터-디테일)
|
||||
}> = ({ config, updateConfig, tableName, hasSplitPanel }) => {
|
||||
const [numberingRules, setNumberingRules] = useState<any[]>([]);
|
||||
const [ruleSelectOpen, setRuleSelectOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [tableColumns, setTableColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 채번 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
const loadNumberingRules = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get("/numbering-rules");
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setNumberingRules(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNumberingRules();
|
||||
}, []);
|
||||
|
||||
// 단일 테이블인 경우 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (!tableName || hasSplitPanel) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
setColumnsLoading(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data?.success && response.data?.data?.columns) {
|
||||
const cols = response.data.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
}));
|
||||
setTableColumns(cols);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [tableName, hasSplitPanel]);
|
||||
|
||||
const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId));
|
||||
|
||||
const ExcelNumberingRuleInfo: React.FC = () => {
|
||||
return (
|
||||
<div className="border-t pt-3">
|
||||
<Label className="text-xs">채번 규칙</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
업로드 시 자동으로 생성할 코드/번호의 채번 규칙을 선택하세요.
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
테이블 타입 관리에서 "채번" 타입으로 설정된 컬럼의 채번 규칙이 업로드 시 자동으로 적용됩니다.
|
||||
</p>
|
||||
|
||||
<Popover open={ruleSelectOpen} onOpenChange={setRuleSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={ruleSelectOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "로딩 중..." : selectedRule?.rule_name || selectedRule?.ruleName || "채번 없음"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="채번 규칙 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">검색 결과 없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined });
|
||||
setRuleSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", !config.numberingRuleId ? "opacity-100" : "opacity-0")} />
|
||||
채번 없음
|
||||
</CommandItem>
|
||||
{numberingRules.map((rule, idx) => {
|
||||
const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`);
|
||||
const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)";
|
||||
return (
|
||||
<CommandItem
|
||||
key={ruleId}
|
||||
value={ruleName}
|
||||
onSelect={() => {
|
||||
updateConfig({ numberingRuleId: ruleId });
|
||||
setRuleSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
String(config.numberingRuleId) === ruleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{ruleName}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 단일 테이블이고 채번 규칙이 선택된 경우, 적용할 컬럼 선택 */}
|
||||
{config.numberingRuleId && !hasSplitPanel && tableName && (
|
||||
<div className="mt-2">
|
||||
<Label className="text-xs">채번 적용 컬럼</Label>
|
||||
<Select
|
||||
value={config.numberingTargetColumn || ""}
|
||||
onValueChange={(value) => updateConfig({ numberingTargetColumn: value || undefined })}
|
||||
disabled={columnsLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={columnsLoading ? "로딩 중..." : "컬럼 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel} ({col.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">채번 값이 입력될 컬럼을 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분할 패널인 경우 안내 메시지 */}
|
||||
{config.numberingRuleId && hasSplitPanel && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">마스터-디테일 구조에서는 마스터 키 컬럼에 자동 적용됩니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -4440,14 +4290,10 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
allComponents: ComponentData[];
|
||||
currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달)
|
||||
}> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => {
|
||||
// 엑셀 업로드 설정 상태 관리
|
||||
// 엑셀 업로드 설정 상태 관리 (채번은 테이블 타입 관리에서 자동 감지)
|
||||
const [excelUploadConfig, setExcelUploadConfig] = useState<{
|
||||
numberingRuleId?: string;
|
||||
numberingTargetColumn?: string;
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
||||
}>({
|
||||
numberingRuleId: config.action?.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
|
||||
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
|
||||
});
|
||||
|
||||
|
|
@ -4529,17 +4375,11 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
);
|
||||
}, [hasSplitPanel, singleTableName, propTableName]);
|
||||
|
||||
// 설정 업데이트 함수
|
||||
// 설정 업데이트 함수 (채번은 테이블 타입 관리에서 자동 감지되므로 제어 실행만 관리)
|
||||
const updateExcelUploadConfig = (updates: Partial<typeof excelUploadConfig>) => {
|
||||
const newConfig = { ...excelUploadConfig, ...updates };
|
||||
setExcelUploadConfig(newConfig);
|
||||
|
||||
if (updates.numberingRuleId !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelNumberingRuleId", updates.numberingRuleId);
|
||||
}
|
||||
if (updates.numberingTargetColumn !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelNumberingTargetColumn", updates.numberingTargetColumn);
|
||||
}
|
||||
if (updates.afterUploadFlows !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows);
|
||||
}
|
||||
|
|
@ -4548,15 +4388,9 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
// config 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setExcelUploadConfig({
|
||||
numberingRuleId: config.action?.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
|
||||
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
|
||||
});
|
||||
}, [
|
||||
config.action?.excelNumberingRuleId,
|
||||
config.action?.excelNumberingTargetColumn,
|
||||
config.action?.excelAfterUploadFlows,
|
||||
]);
|
||||
}, [config.action?.excelAfterUploadFlows]);
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
|
|
@ -4595,13 +4429,8 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 채번 규칙 설정 (항상 표시) */}
|
||||
<ExcelNumberingRuleConfig
|
||||
config={excelUploadConfig}
|
||||
updateConfig={updateExcelUploadConfig}
|
||||
tableName={singleTableName}
|
||||
hasSplitPanel={hasSplitPanel}
|
||||
/>
|
||||
{/* 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */}
|
||||
<ExcelNumberingRuleInfo />
|
||||
|
||||
{/* 업로드 후 제어 실행 (항상 표시) */}
|
||||
<ExcelAfterUploadControlConfig config={excelUploadConfig} updateConfig={updateExcelUploadConfig} />
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -947,6 +947,21 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
|
||||
// RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만,
|
||||
// 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함
|
||||
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
||||
const hasCustomBackground = !!style?.backgroundColor;
|
||||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
// 텍스트 스타일 오버라이드 (CSS 상속으로 내부 input에 전달)
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
||||
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
|
||||
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
||||
const hasCustomText = Object.keys(customTextStyle).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -975,7 +990,18 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="h-full w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
|
||||
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
|
||||
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
|
||||
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
|
||||
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
|
||||
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
|
||||
)}
|
||||
style={hasCustomText ? customTextStyle : undefined}
|
||||
>
|
||||
{renderInput()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -947,6 +947,19 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
|
||||
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
||||
const hasCustomBackground = !!style?.backgroundColor;
|
||||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
// 텍스트 스타일 오버라이드 (CSS 상속)
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
||||
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
|
||||
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
||||
const hasCustomText = Object.keys(customTextStyle).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -975,7 +988,18 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="h-full w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
// 커스텀 테두리 설정 시, 내부 select trigger의 기본 테두리 제거
|
||||
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
|
||||
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거
|
||||
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
|
||||
// 커스텀 배경 설정 시, 내부 요소를 투명하게
|
||||
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
|
||||
)}
|
||||
style={hasCustomText ? customTextStyle : undefined}
|
||||
>
|
||||
{renderSelect()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1283,13 +1283,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
// 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선)
|
||||
border: style?.border || (style?.borderWidth ? undefined : "none"),
|
||||
borderWidth: style?.borderWidth || undefined,
|
||||
borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined,
|
||||
borderColor: style?.borderColor || undefined,
|
||||
borderRadius: style?.borderRadius || "0.5rem",
|
||||
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
|
||||
color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원
|
||||
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||
fontWeight: "600",
|
||||
color: finalDisabled ? "#9ca3af" : (style?.color || buttonTextColor), // 🔧 StyleEditor 텍스트 색상도 지원
|
||||
// 🔧 크기 설정 적용 (sm/md/lg), StyleEditor fontSize 우선
|
||||
fontSize: style?.fontSize || (componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem"),
|
||||
fontWeight: style?.fontWeight || "600",
|
||||
cursor: finalDisabled ? "not-allowed" : "pointer",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { toast } from "sonner";
|
||||
import { uploadFiles, downloadFile, deleteFile, getComponentFiles, getFileInfoByObjid, getFilePreviewUrl } from "@/lib/api/file";
|
||||
import { GlobalFileManager } from "@/lib/api/globalFile";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { formatFileSize, cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { FileViewerModal } from "./FileViewerModal";
|
||||
import { FileManagerModal } from "./FileManagerModal";
|
||||
|
|
@ -492,11 +492,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
|
||||
const safeComponentConfig = componentConfig || {};
|
||||
const fileConfig = {
|
||||
...safeComponentConfig,
|
||||
accept: safeComponentConfig.accept || "*/*",
|
||||
multiple: safeComponentConfig.multiple || false,
|
||||
maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: safeComponentConfig.maxFiles || 5,
|
||||
...safeComponentConfig,
|
||||
} as FileUploadConfig;
|
||||
|
||||
// 파일 선택 핸들러
|
||||
|
|
@ -513,7 +513,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 파일 업로드 처리
|
||||
// 백엔드 multer 제한에 맞춘 1회 요청당 최대 파일 수
|
||||
const CHUNK_SIZE = 10;
|
||||
|
||||
// 파일 업로드 처리 (10개 초과 시 자동 분할 업로드)
|
||||
const handleFileUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
|
|
@ -548,7 +551,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
|
||||
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
|
||||
setUploadStatus("uploading");
|
||||
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
||||
|
||||
// 분할 업로드 여부 판단
|
||||
const totalFiles = filesToUpload.length;
|
||||
const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE);
|
||||
const isChunked = totalChunks > 1;
|
||||
|
||||
if (isChunked) {
|
||||
toast.loading(`파일 업로드 준비 중... (총 ${totalFiles}개, ${totalChunks}회 분할)`, { id: "file-upload" });
|
||||
} else {
|
||||
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
||||
}
|
||||
|
||||
try {
|
||||
// 🔑 레코드 모드 우선 사용
|
||||
|
|
@ -585,13 +598,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
|
||||
|
||||
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
|
||||
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
|
||||
const finalLinkedTable = effectiveIsRecordMode
|
||||
? effectiveTableName
|
||||
: (formData?.linkedTable || effectiveTableName);
|
||||
|
||||
const uploadData = {
|
||||
// 🎯 formData에서 백엔드 API 설정 가져오기
|
||||
autoLink: formData?.autoLink || true,
|
||||
linkedTable: finalLinkedTable,
|
||||
recordId: effectiveRecordId || `temp_${component.id}`,
|
||||
|
|
@ -599,143 +610,163 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
||||
docType: component.fileConfig?.docType || "DOCUMENT",
|
||||
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
|
||||
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
|
||||
// 호환성을 위한 기존 필드들
|
||||
companyCode: userCompanyCode,
|
||||
tableName: effectiveTableName,
|
||||
fieldName: effectiveColumnName,
|
||||
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
||||
// 🆕 레코드 모드 플래그
|
||||
targetObjid: targetObjid,
|
||||
isRecordMode: effectiveIsRecordMode,
|
||||
};
|
||||
|
||||
const response = await uploadFiles({
|
||||
files: filesToUpload,
|
||||
...uploadData,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// FileUploadResponse 타입에 맞게 files 배열 사용
|
||||
const fileData = response.files || (response as any).data || [];
|
||||
// 🔄 파일을 CHUNK_SIZE(10개)씩 나눠서 순차 업로드
|
||||
const allNewFiles: any[] = [];
|
||||
let failedChunks = 0;
|
||||
|
||||
if (fileData.length === 0) {
|
||||
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, totalFiles);
|
||||
const chunk = filesToUpload.slice(start, end);
|
||||
|
||||
// 분할 업로드 시 진행 상태 토스트 업데이트
|
||||
if (isChunked) {
|
||||
toast.loading(
|
||||
`업로드 중... ${chunkIndex + 1}/${totalChunks} 배치 (${start + 1}~${end}번째 파일)`,
|
||||
{ id: "file-upload" }
|
||||
);
|
||||
}
|
||||
|
||||
const newFiles = fileData.map((file: any) => ({
|
||||
objid: file.objid || file.id,
|
||||
savedFileName: file.saved_file_name || file.savedFileName,
|
||||
realFileName: file.real_file_name || file.realFileName || file.name,
|
||||
fileSize: file.file_size || file.fileSize || file.size,
|
||||
fileExt: file.file_ext || file.fileExt || file.extension,
|
||||
filePath: file.file_path || file.filePath || file.path,
|
||||
docType: file.doc_type || file.docType,
|
||||
docTypeName: file.doc_type_name || file.docTypeName,
|
||||
targetObjid: file.target_objid || file.targetObjid,
|
||||
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
|
||||
companyCode: file.company_code || file.companyCode,
|
||||
writer: file.writer,
|
||||
regdate: file.regdate,
|
||||
status: file.status || "ACTIVE",
|
||||
uploadedAt: new Date().toISOString(),
|
||||
...file,
|
||||
}));
|
||||
|
||||
|
||||
const updatedFiles = [...uploadedFiles, ...newFiles];
|
||||
|
||||
setUploadedFiles(updatedFiles);
|
||||
setUploadStatus("success");
|
||||
|
||||
// localStorage 백업 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = getUniqueKey();
|
||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 실패:", e);
|
||||
const response = await uploadFiles({
|
||||
files: chunk,
|
||||
...uploadData,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
const fileData = response.files || (response as any).data || [];
|
||||
const chunkFiles = fileData.map((file: any) => ({
|
||||
objid: file.objid || file.id,
|
||||
savedFileName: file.saved_file_name || file.savedFileName,
|
||||
realFileName: file.real_file_name || file.realFileName || file.name,
|
||||
fileSize: file.file_size || file.fileSize || file.size,
|
||||
fileExt: file.file_ext || file.fileExt || file.extension,
|
||||
filePath: file.file_path || file.filePath || file.path,
|
||||
docType: file.doc_type || file.docType,
|
||||
docTypeName: file.doc_type_name || file.docTypeName,
|
||||
targetObjid: file.target_objid || file.targetObjid,
|
||||
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
|
||||
companyCode: file.company_code || file.companyCode,
|
||||
writer: file.writer,
|
||||
regdate: file.regdate,
|
||||
status: file.status || "ACTIVE",
|
||||
uploadedAt: new Date().toISOString(),
|
||||
...file,
|
||||
}));
|
||||
allNewFiles.push(...chunkFiles);
|
||||
} else {
|
||||
console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response);
|
||||
failedChunks++;
|
||||
}
|
||||
} catch (chunkError) {
|
||||
console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError);
|
||||
failedChunks++;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||
if (typeof window !== "undefined") {
|
||||
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
||||
const globalFileState = (window as any).globalFileState || {};
|
||||
const uniqueKey = getUniqueKey();
|
||||
globalFileState[uniqueKey] = updatedFiles;
|
||||
(window as any).globalFileState = globalFileState;
|
||||
// 모든 배치 처리 완료 후 결과 처리
|
||||
if (allNewFiles.length === 0) {
|
||||
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
|
||||
}
|
||||
|
||||
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
|
||||
GlobalFileManager.registerFiles(newFiles, {
|
||||
uploadPage: window.location.pathname,
|
||||
const updatedFiles = [...uploadedFiles, ...allNewFiles];
|
||||
|
||||
setUploadedFiles(updatedFiles);
|
||||
setUploadStatus("success");
|
||||
|
||||
// localStorage 백업 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = getUniqueKey();
|
||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 실패:", e);
|
||||
}
|
||||
|
||||
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||
if (typeof window !== "undefined") {
|
||||
const globalFileState = (window as any).globalFileState || {};
|
||||
const uniqueKey = getUniqueKey();
|
||||
globalFileState[uniqueKey] = updatedFiles;
|
||||
(window as any).globalFileState = globalFileState;
|
||||
|
||||
GlobalFileManager.registerFiles(allNewFiles, {
|
||||
uploadPage: window.location.pathname,
|
||||
componentId: component.id,
|
||||
screenId: formData?.screenId,
|
||||
recordId: recordId,
|
||||
});
|
||||
|
||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
screenId: formData?.screenId,
|
||||
recordId: recordId, // 🆕 레코드 ID 추가
|
||||
});
|
||||
eventColumnName: columnName,
|
||||
uniqueKey: uniqueKey,
|
||||
recordId: recordId,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
}
|
||||
|
||||
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||
// 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
eventColumnName: columnName, // 🆕 컬럼명 추가
|
||||
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
||||
recordId: recordId, // 🆕 레코드 ID 추가
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
}
|
||||
|
||||
// 컴포넌트 업데이트
|
||||
if (onUpdate) {
|
||||
const timestamp = Date.now();
|
||||
onUpdate({
|
||||
uploadedFiles: updatedFiles,
|
||||
lastFileUpdate: timestamp,
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
||||
}
|
||||
|
||||
// 🆕 이미지/파일 컬럼에 objid 저장 (formData 업데이트)
|
||||
if (onFormDataChange && effectiveColumnName) {
|
||||
// 🎯 이미지/파일 타입 컬럼: 첫 번째 파일의 objid를 저장 (그리드에서 표시용)
|
||||
// 단일 파일인 경우 단일 값, 복수 파일인 경우 콤마 구분 문자열
|
||||
const fileObjids = updatedFiles.map(file => file.objid);
|
||||
const columnValue = fileConfig.multiple
|
||||
? fileObjids.join(',') // 복수 파일: 콤마 구분
|
||||
: (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID
|
||||
|
||||
// onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환)
|
||||
onFormDataChange(effectiveColumnName, columnValue);
|
||||
}
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
||||
detail: {
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
targetObjid: targetObjid,
|
||||
fileCount: updatedFiles.length,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(refreshEvent);
|
||||
}
|
||||
|
||||
// 컴포넌트 설정 콜백
|
||||
if (safeComponentConfig.onFileUpload) {
|
||||
safeComponentConfig.onFileUpload(newFiles);
|
||||
}
|
||||
|
||||
// 성공 시 토스트 처리
|
||||
setUploadStatus("idle");
|
||||
toast.dismiss("file-upload");
|
||||
toast.success(`${newFiles.length}개 파일 업로드 완료`);
|
||||
// 컴포넌트 업데이트
|
||||
if (onUpdate) {
|
||||
const timestamp = Date.now();
|
||||
onUpdate({
|
||||
uploadedFiles: updatedFiles,
|
||||
lastFileUpdate: timestamp,
|
||||
});
|
||||
} else {
|
||||
console.error("❌ 파일 업로드 실패:", response);
|
||||
throw new Error(response.message || (response as any).error || "파일 업로드에 실패했습니다.");
|
||||
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
||||
}
|
||||
|
||||
// 이미지/파일 컬럼에 objid 저장 (formData 업데이트)
|
||||
if (onFormDataChange && effectiveColumnName) {
|
||||
const fileObjids = updatedFiles.map(file => file.objid);
|
||||
const columnValue = fileConfig.multiple
|
||||
? fileObjids.join(',')
|
||||
: (fileObjids[0] || '');
|
||||
onFormDataChange(effectiveColumnName, columnValue);
|
||||
}
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
||||
detail: {
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
targetObjid: targetObjid,
|
||||
fileCount: updatedFiles.length,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(refreshEvent);
|
||||
}
|
||||
|
||||
// 컴포넌트 설정 콜백
|
||||
if (safeComponentConfig.onFileUpload) {
|
||||
safeComponentConfig.onFileUpload(allNewFiles);
|
||||
}
|
||||
|
||||
// 성공/부분 성공 토스트 처리
|
||||
setUploadStatus("idle");
|
||||
toast.dismiss("file-upload");
|
||||
|
||||
if (failedChunks > 0) {
|
||||
toast.warning(
|
||||
`${allNewFiles.length}개 업로드 완료, 일부 파일 실패`,
|
||||
{ description: "일부 파일이 업로드되지 않았습니다. 다시 시도해주세요." }
|
||||
);
|
||||
} else {
|
||||
toast.success(`${allNewFiles.length}개 파일 업로드 완료`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("파일 업로드 오류:", error);
|
||||
|
|
@ -991,19 +1022,26 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick],
|
||||
);
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값)
|
||||
const customStyle = component.style || {};
|
||||
const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border);
|
||||
const hasCustomBackground = !!customStyle.backgroundColor;
|
||||
const hasCustomRadius = !!customStyle.borderRadius;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...componentStyle,
|
||||
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
|
||||
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
|
||||
border: "none !important",
|
||||
boxShadow: "none !important",
|
||||
outline: "none !important",
|
||||
backgroundColor: "transparent !important",
|
||||
padding: "0px !important",
|
||||
borderRadius: "0px !important",
|
||||
marginBottom: "8px !important",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// 🔧 !important 제거 - 커스텀 스타일이 없을 때만 기본값 적용
|
||||
border: hasCustomBorder ? undefined : "none",
|
||||
boxShadow: "none",
|
||||
outline: "none",
|
||||
backgroundColor: hasCustomBackground ? undefined : "transparent",
|
||||
padding: "0px",
|
||||
borderRadius: hasCustomRadius ? undefined : "0px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
className={`${className} file-upload-container`}
|
||||
>
|
||||
|
|
@ -1014,15 +1052,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
position: "absolute",
|
||||
top: "-20px",
|
||||
left: "0px",
|
||||
fontSize: "12px",
|
||||
color: "rgb(107, 114, 128)",
|
||||
fontWeight: "400",
|
||||
background: "transparent !important",
|
||||
border: "none !important",
|
||||
boxShadow: "none !important",
|
||||
outline: "none !important",
|
||||
padding: "0px !important",
|
||||
margin: "0px !important"
|
||||
fontSize: customStyle.labelFontSize || "12px",
|
||||
color: customStyle.labelColor || "rgb(107, 114, 128)",
|
||||
fontWeight: customStyle.labelFontWeight || "400",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
outline: "none",
|
||||
padding: "0px",
|
||||
margin: "0px"
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
|
|
@ -1033,7 +1071,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
)}
|
||||
|
||||
<div
|
||||
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
|
||||
className={cn(
|
||||
"relative flex h-full w-full flex-col overflow-hidden",
|
||||
// 커스텀 테두리가 없을 때만 기본 테두리 표시
|
||||
!hasCustomBorder && "border-border rounded-lg border",
|
||||
// 커스텀 배경이 없을 때만 기본 배경 표시
|
||||
!hasCustomBackground && "bg-card",
|
||||
)}
|
||||
>
|
||||
{/* 대표 이미지 전체 화면 표시 */}
|
||||
{uploadedFiles.length > 0 ? (() => {
|
||||
|
|
@ -1117,7 +1161,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
onFileDelete={handleFileDelete}
|
||||
onFileView={handleFileView}
|
||||
onSetRepresentative={handleSetRepresentative}
|
||||
config={safeComponentConfig}
|
||||
config={fileConfig}
|
||||
isDesignMode={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,16 +56,19 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
// DOM props 필터링 (React 관련 props 제거)
|
||||
const domProps = filterDOMProps(props);
|
||||
|
||||
// 🔧 StyleEditor(component.style) 값 우선, 없으면 componentConfig 폴백
|
||||
const customStyle = component.style || {};
|
||||
|
||||
// 텍스트 스타일 계산
|
||||
const textStyle: React.CSSProperties = {
|
||||
fontSize: componentConfig.fontSize || "14px",
|
||||
fontWeight: componentConfig.fontWeight || "normal",
|
||||
color: componentConfig.color || "#212121",
|
||||
textAlign: componentConfig.textAlign || "left",
|
||||
backgroundColor: componentConfig.backgroundColor || "transparent",
|
||||
fontSize: customStyle.fontSize || componentConfig.fontSize || "14px",
|
||||
fontWeight: customStyle.fontWeight || componentConfig.fontWeight || "normal",
|
||||
color: customStyle.color || componentConfig.color || "#212121",
|
||||
textAlign: (customStyle.textAlign || componentConfig.textAlign || "left") as React.CSSProperties["textAlign"],
|
||||
backgroundColor: customStyle.backgroundColor || componentConfig.backgroundColor || "transparent",
|
||||
padding: componentConfig.padding || "0",
|
||||
borderRadius: componentConfig.borderRadius || "0",
|
||||
border: componentConfig.border || "none",
|
||||
borderRadius: customStyle.borderRadius || componentConfig.borderRadius || "0",
|
||||
border: customStyle.border || (customStyle.borderWidth ? `${customStyle.borderWidth} ${customStyle.borderStyle || "solid"} ${customStyle.borderColor || "transparent"}` : componentConfig.border || "none"),
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
|
|
|
|||
|
|
@ -4984,7 +4984,7 @@ export class ButtonActionExecutor {
|
|||
// visible이 true인 컬럼만 추출
|
||||
visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName);
|
||||
|
||||
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
|
||||
// column_labels 테이블에서 실제 라벨 가져오기
|
||||
try {
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
|
||||
params: { page: 1, size: 9999 },
|
||||
|
|
@ -5021,19 +5021,77 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 레이아웃 조회 실패:", error);
|
||||
}
|
||||
|
||||
// 🎨 카테고리 값들 조회 (한 번만)
|
||||
// Fallback: 레이아웃에서 컬럼 정보를 못 가져온 경우, table_type_columns에서 직접 조회
|
||||
// 시스템 컬럼 제외 + 라벨 적용으로 raw 컬럼명 노출 방지
|
||||
const SYSTEM_COLUMNS = ["id", "company_code", "created_date", "updated_date", "writer"];
|
||||
if ((!visibleColumns || visibleColumns.length === 0) && context.tableName && dataToExport.length > 0) {
|
||||
console.log("⚠️ 레이아웃에서 컬럼 설정을 찾지 못함 → table_type_columns에서 fallback 조회");
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
|
||||
params: { page: 1, size: 9999 },
|
||||
});
|
||||
|
||||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||
let columnData = columnsResponse.data.data;
|
||||
if (columnData.columns && Array.isArray(columnData.columns)) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData) && columnData.length > 0) {
|
||||
// visible이 false가 아닌 컬럼만 + 시스템 컬럼 제외
|
||||
const filteredCols = columnData.filter((col: any) => {
|
||||
const colName = (col.column_name || col.columnName || "").toLowerCase();
|
||||
if (SYSTEM_COLUMNS.includes(colName)) return false;
|
||||
if (col.isVisible === false || col.is_visible === false) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
visibleColumns = filteredCols.map((col: any) => col.column_name || col.columnName);
|
||||
columnLabels = {};
|
||||
filteredCols.forEach((col: any) => {
|
||||
const colName = col.column_name || col.columnName;
|
||||
const labelValue = col.column_label || col.label || col.displayName || colName;
|
||||
if (colName) {
|
||||
columnLabels![colName] = labelValue;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ Fallback 컬럼 ${visibleColumns.length}개 로드 완료`);
|
||||
}
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error("❌ Fallback 컬럼 조회 실패:", fallbackError);
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 안전장치: 여전히 컬럼 정보가 없으면 데이터의 키에서 시스템 컬럼만 제외
|
||||
if ((!visibleColumns || visibleColumns.length === 0) && dataToExport.length > 0) {
|
||||
console.log("⚠️ 최종 fallback: 데이터 키에서 시스템 컬럼 제외");
|
||||
const allKeys = Object.keys(dataToExport[0]);
|
||||
visibleColumns = allKeys.filter((key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
// 시스템 컬럼 제외
|
||||
if (SYSTEM_COLUMNS.includes(lowerKey)) return false;
|
||||
// _name, _label 등 조인된 보조 필드 제외
|
||||
if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label")) return false;
|
||||
return true;
|
||||
});
|
||||
// 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨)
|
||||
if (!columnLabels) {
|
||||
columnLabels = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 값들 조회 (한 번만)
|
||||
const categoryMap: Record<string, Record<string, string>> = {};
|
||||
let categoryColumns: string[] = [];
|
||||
|
||||
// 백엔드에서 카테고리 컬럼 정보 가져오기
|
||||
if (context.tableName) {
|
||||
try {
|
||||
const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||
|
|
@ -5072,7 +5130,7 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 🎨 컬럼 필터링 및 라벨 적용 (항상 실행)
|
||||
// 컬럼 필터링 및 라벨 적용
|
||||
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||
dataToExport = dataToExport.map((row: any) => {
|
||||
const filteredRow: Record<string, any> = {};
|
||||
|
|
@ -5165,6 +5223,8 @@ export class ButtonActionExecutor {
|
|||
? config.excelAfterUploadFlows
|
||||
: config.masterDetailExcel?.afterUploadFlows;
|
||||
|
||||
// masterDetailExcel 설정이 명시적으로 있을 때만 간단 모드 (디테일만 업로드)
|
||||
// 설정이 없으면 기본 모드 (마스터+디테일 둘 다 업로드)
|
||||
if (config.masterDetailExcel) {
|
||||
masterDetailExcelConfig = {
|
||||
...config.masterDetailExcel,
|
||||
|
|
@ -5173,25 +5233,13 @@ export class ButtonActionExecutor {
|
|||
detailTable: relationResponse.data.detailTable,
|
||||
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||
// 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑)
|
||||
numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId,
|
||||
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
|
||||
afterUploadFlows,
|
||||
};
|
||||
} else {
|
||||
// 버튼 설정이 없으면 분할 패널 정보만 사용
|
||||
masterDetailExcelConfig = {
|
||||
masterTable: relationResponse.data.masterTable,
|
||||
detailTable: relationResponse.data.detailTable,
|
||||
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||
simpleMode: true, // 기본값으로 간단 모드 사용
|
||||
// 채번 규칙 ID 추가 (excelNumberingRuleId 사용)
|
||||
numberingRuleId: config.excelNumberingRuleId,
|
||||
// 채번은 ExcelUploadModal에서 마스터 테이블 기반 자동 감지
|
||||
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
|
||||
afterUploadFlows,
|
||||
};
|
||||
}
|
||||
// masterDetailExcel 설정 없으면 masterDetailExcelConfig는 undefined 유지
|
||||
// → ExcelUploadModal에서 기본 모드로 동작 (마스터+디테일 둘 다 매핑/업로드)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5233,9 +5281,7 @@ export class ButtonActionExecutor {
|
|||
isMasterDetail,
|
||||
masterDetailRelation,
|
||||
masterDetailExcelConfig,
|
||||
// 🆕 단일 테이블 채번 설정
|
||||
numberingRuleId: config.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.excelNumberingTargetColumn,
|
||||
// 채번은 ExcelUploadModal에서 테이블 타입 관리 기반 자동 감지
|
||||
// 🆕 업로드 후 제어 실행 설정
|
||||
afterUploadFlows: config.excelAfterUploadFlows,
|
||||
onSuccess: () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue