feat(upload): 매핑 UI 4가지 보강 — 자동 채우기 + Select 옵션 + 미리보기 + 좌우 정렬
엑셀 컬럼 매핑이 더 직관적이도록:
Backend:
- TargetFieldMetaService 신규: 9개 target_table 의 사용 가능 필드 메타데이터
(recruit_ledger 15 / maintain_ledger 11 / contract 10 / agent 10 /
commission_rate 7 / payout_rule 7 / chargeback_rule 6 / exception_ledger 6 /
payment 6) — field/label/type/required
- UploadService.extractHeaders(file, headerRow, sheetIndex, previewRows):
StreamingReader 로 첫 행 헤더 + N행 미리보기 추출, A/B/C 컬럼 라벨 생성
- UploadController 에 3개 엔드포인트 추가:
· POST /api/upload-templates/excel-headers (multipart) → ExcelHeaderResp
· GET /api/upload-templates/target-fields/{targetTable}
· GET /api/upload-templates/target-tables
Frontend:
- api/uploadTemplate.ts: extractHeaders / targetFields / targetTables 함수 추가
+ ExcelHeaderResp / TargetFieldMeta 타입
- ColumnEditor 보강:
1) "샘플 엑셀로 자동 채우기" 버튼 — 사용자가 샘플 파일 올리면 헤더 추출 후
sourceColumn(A,B,C) + sourceHeader(헤더명) 자동 채움 (기존 매핑 있으면
Modal.confirm 으로 덮어쓰기 확인)
2) 대상 필드 Input → Select (target_table 별 메타 옵션) — showSearch +
선택 시 targetType 자동 설정 (필드 메타의 type 으로)
3) 헤더에 target_table tag 표시 (→ recruit_ledger)
4) 자동 채우기 후 첫 5행 샘플 데이터 테이블이 매핑 편집기 위에 표시됨
검증: tsc -b 통과, ga-api 재기동 후 /target-fields 200 (15필드 응답)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,10 @@
|
|||||||
package com.ga.api.controller.upload;
|
package com.ga.api.controller.upload;
|
||||||
|
|
||||||
|
import com.ga.api.service.upload.TargetFieldMetaService;
|
||||||
|
import com.ga.api.service.upload.TargetFieldMetaService.TargetFieldMeta;
|
||||||
import com.ga.api.service.upload.UploadResultResp;
|
import com.ga.api.service.upload.UploadResultResp;
|
||||||
import com.ga.api.service.upload.UploadService;
|
import com.ga.api.service.upload.UploadService;
|
||||||
|
import com.ga.api.service.upload.UploadService.ExcelHeaderResp;
|
||||||
import com.ga.common.annotation.RequirePermission;
|
import com.ga.common.annotation.RequirePermission;
|
||||||
import com.ga.common.model.ApiResponse;
|
import com.ga.common.model.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -19,6 +22,7 @@ import java.util.Map;
|
|||||||
public class UploadController {
|
public class UploadController {
|
||||||
|
|
||||||
private final UploadService uploadService;
|
private final UploadService uploadService;
|
||||||
|
private final TargetFieldMetaService metaService;
|
||||||
|
|
||||||
@Operation(summary = "엑셀 업로드 처리",
|
@Operation(summary = "엑셀 업로드 처리",
|
||||||
description = "templateCode 에 해당하는 템플릿으로 파일을 파싱 · 변환 · 배치 INSERT 한다.")
|
description = "templateCode 에 해당하는 템플릿으로 파일을 파싱 · 변환 · 배치 INSERT 한다.")
|
||||||
@@ -31,7 +35,7 @@ public class UploadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "업로드 미리보기 (상위 5행)",
|
@Operation(summary = "업로드 미리보기 (상위 5행)",
|
||||||
description = "DB 에 저장하지 않고 변환 결과만 반환한다. limit 파라미터로 행 수 조정 가능.")
|
description = "DB 에 저장하지 않고 변환 결과만 반환한다.")
|
||||||
@PostMapping("/api/upload-templates/{templateCode}/preview")
|
@PostMapping("/api/upload-templates/{templateCode}/preview")
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||||
public ApiResponse<List<Map<String, Object>>> preview(
|
public ApiResponse<List<Map<String, Object>>> preview(
|
||||||
@@ -41,4 +45,32 @@ public class UploadController {
|
|||||||
List<Map<String, Object>> rows = uploadService.preview(templateCode, file, limit);
|
List<Map<String, Object>> rows = uploadService.preview(templateCode, file, limit);
|
||||||
return ApiResponse.ok(rows);
|
return ApiResponse.ok(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "샘플 엑셀 헤더 추출",
|
||||||
|
description = "템플릿 등록 전에 샘플 파일 첫 행 헤더 + 미리보기 행을 추출한다. " +
|
||||||
|
"프론트는 이 결과로 sourceColumn(A,B,C..) + sourceHeader 를 자동 채운다.")
|
||||||
|
@PostMapping("/api/upload-templates/excel-headers")
|
||||||
|
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||||
|
public ApiResponse<ExcelHeaderResp> extractHeaders(
|
||||||
|
@RequestParam("file") MultipartFile file,
|
||||||
|
@RequestParam(value = "headerRow", defaultValue = "1") int headerRow,
|
||||||
|
@RequestParam(value = "sheetIndex", defaultValue = "0") int sheetIndex,
|
||||||
|
@RequestParam(value = "previewRows", defaultValue = "5") int previewRows) {
|
||||||
|
return ApiResponse.ok(uploadService.extractHeaders(file, headerRow, sheetIndex, previewRows));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "target_table 별 사용 가능한 컬럼 메타",
|
||||||
|
description = "프론트의 target_field Select 옵션 + 타입 자동 채우기에 사용한다.")
|
||||||
|
@GetMapping("/api/upload-templates/target-fields/{targetTable}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||||
|
public ApiResponse<List<TargetFieldMeta>> targetFields(@PathVariable String targetTable) {
|
||||||
|
return ApiResponse.ok(metaService.getFields(targetTable));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "사용 가능한 target_table 목록")
|
||||||
|
@GetMapping("/api/upload-templates/target-tables")
|
||||||
|
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
||||||
|
public ApiResponse<List<String>> targetTables() {
|
||||||
|
return ApiResponse.ok(metaService.getAvailableTables());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package com.ga.api.service.upload;
|
||||||
|
|
||||||
|
import com.ga.common.exception.BizException;
|
||||||
|
import com.ga.common.exception.ErrorCode;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드 템플릿의 target_table 별로 사용 가능한 컬럼(target_field) 메타데이터.
|
||||||
|
* 화이트리스트 역할 + 프론트 Select 옵션 제공.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class TargetFieldMetaService {
|
||||||
|
|
||||||
|
public record TargetFieldMeta(
|
||||||
|
String field, // DB 컬럼명 (e.g. policy_no)
|
||||||
|
String label, // 한글 표시명
|
||||||
|
String type, // STRING / NUMBER / DECIMAL / DATE / INTEGER
|
||||||
|
boolean required) {}
|
||||||
|
|
||||||
|
private static final Map<String, List<TargetFieldMeta>> META = Map.of(
|
||||||
|
"recruit_ledger", List.of(
|
||||||
|
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
|
||||||
|
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", false),
|
||||||
|
new TargetFieldMeta("contract_id", "계약ID", "INTEGER", false),
|
||||||
|
new TargetFieldMeta("company_code", "보험사코드", "STRING", false),
|
||||||
|
new TargetFieldMeta("product_code", "상품코드", "STRING", false),
|
||||||
|
new TargetFieldMeta("insurance_type", "보험종류", "STRING", false),
|
||||||
|
new TargetFieldMeta("commission_year", "회차", "INTEGER", false),
|
||||||
|
new TargetFieldMeta("premium", "보험료", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("pay_method", "납입주기", "STRING", false),
|
||||||
|
new TargetFieldMeta("company_rate", "회사율(%)", "DECIMAL", false),
|
||||||
|
new TargetFieldMeta("company_amount", "회사수수료", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("payout_rate", "지급율(%)", "DECIMAL", false),
|
||||||
|
new TargetFieldMeta("agent_amount", "설계사수수료", "DECIMAL", false),
|
||||||
|
new TargetFieldMeta("settle_month", "정산월", "STRING", true),
|
||||||
|
new TargetFieldMeta("status", "상태", "STRING", false)
|
||||||
|
),
|
||||||
|
"maintain_ledger", List.of(
|
||||||
|
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
|
||||||
|
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", false),
|
||||||
|
new TargetFieldMeta("contract_id", "계약ID", "INTEGER", false),
|
||||||
|
new TargetFieldMeta("company_code", "보험사코드", "STRING", false),
|
||||||
|
new TargetFieldMeta("product_code", "상품코드", "STRING", false),
|
||||||
|
new TargetFieldMeta("insurance_type", "보험종류", "STRING", false),
|
||||||
|
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("premium", "보험료", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("company_amount", "회사수수료", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("agent_amount", "설계사수수료", "DECIMAL", false),
|
||||||
|
new TargetFieldMeta("settle_month", "정산월", "STRING", true)
|
||||||
|
),
|
||||||
|
"contract", List.of(
|
||||||
|
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
|
||||||
|
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("product_id", "상품ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("contractor_name", "계약자", "STRING", false),
|
||||||
|
new TargetFieldMeta("insured_name", "피보험자", "STRING", false),
|
||||||
|
new TargetFieldMeta("premium", "보험료", "DECIMAL", false),
|
||||||
|
new TargetFieldMeta("pay_cycle", "납입주기", "STRING", false),
|
||||||
|
new TargetFieldMeta("contract_date", "계약일", "DATE", true),
|
||||||
|
new TargetFieldMeta("effective_date", "효력발생일", "DATE", false),
|
||||||
|
new TargetFieldMeta("status", "상태", "STRING", false)
|
||||||
|
),
|
||||||
|
"agent", List.of(
|
||||||
|
new TargetFieldMeta("agent_name", "설계사명", "STRING", true),
|
||||||
|
new TargetFieldMeta("license_no", "사번", "STRING", false),
|
||||||
|
new TargetFieldMeta("org_id", "조직ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("grade_id", "직급ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("phone", "전화번호", "STRING", false),
|
||||||
|
new TargetFieldMeta("email", "이메일", "STRING", false),
|
||||||
|
new TargetFieldMeta("bank_code", "은행코드", "STRING", false),
|
||||||
|
new TargetFieldMeta("account_no", "계좌번호", "STRING", false),
|
||||||
|
new TargetFieldMeta("status", "상태", "STRING", false),
|
||||||
|
new TargetFieldMeta("join_date", "입사일", "DATE", false)
|
||||||
|
),
|
||||||
|
"commission_rate", List.of(
|
||||||
|
new TargetFieldMeta("product_id", "상품ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("rate_pct", "수수료율(%)", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("pay_method_cond", "납입조건", "STRING", false),
|
||||||
|
new TargetFieldMeta("min_premium", "최소보험료", "DECIMAL", false),
|
||||||
|
new TargetFieldMeta("effective_from", "적용시작", "DATE", true),
|
||||||
|
new TargetFieldMeta("effective_to", "적용종료", "DATE", false)
|
||||||
|
),
|
||||||
|
"payout_rule", List.of(
|
||||||
|
new TargetFieldMeta("grade_id", "직급ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("insurance_type", "보험종류", "STRING", true),
|
||||||
|
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("payout_pct", "지급율(%)", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("pay_method_cond", "납입조건", "STRING", false),
|
||||||
|
new TargetFieldMeta("performance_grade","실적등급", "STRING", false),
|
||||||
|
new TargetFieldMeta("effective_from", "적용시작", "DATE", true)
|
||||||
|
),
|
||||||
|
"chargeback_rule", List.of(
|
||||||
|
new TargetFieldMeta("company_code", "보험사코드", "STRING", true),
|
||||||
|
new TargetFieldMeta("insurance_type", "보험종류", "STRING", true),
|
||||||
|
new TargetFieldMeta("lapse_month_from","실효시작월", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("lapse_month_to", "실효종료월", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("cb_rate", "환수율(%)", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("include_override","오버라이드포함", "STRING", false)
|
||||||
|
),
|
||||||
|
"exception_ledger", List.of(
|
||||||
|
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("exception_code", "예외코드", "STRING", true),
|
||||||
|
new TargetFieldMeta("amount", "금액", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("settle_month", "정산월", "STRING", true),
|
||||||
|
new TargetFieldMeta("policy_no", "증권번호", "STRING", false),
|
||||||
|
new TargetFieldMeta("description", "내용", "STRING", false)
|
||||||
|
),
|
||||||
|
"payment", List.of(
|
||||||
|
new TargetFieldMeta("settle_id", "정산ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
|
||||||
|
new TargetFieldMeta("pay_month", "지급월", "STRING", true),
|
||||||
|
new TargetFieldMeta("net_amount", "지급액", "DECIMAL", true),
|
||||||
|
new TargetFieldMeta("bank_code", "은행코드", "STRING", false),
|
||||||
|
new TargetFieldMeta("account_no", "계좌번호", "STRING", false)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
public List<TargetFieldMeta> getFields(String targetTable) {
|
||||||
|
if (targetTable == null) {
|
||||||
|
throw new BizException(ErrorCode.INVALID_PARAMETER, "target_table 누락");
|
||||||
|
}
|
||||||
|
List<TargetFieldMeta> fields = META.get(targetTable);
|
||||||
|
if (fields == null) {
|
||||||
|
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
||||||
|
"허용되지 않은 target_table: " + targetTable + " (가능: " + META.keySet() + ")");
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getAvailableTables() {
|
||||||
|
return List.copyOf(META.keySet());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -223,6 +223,86 @@ public class UploadService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 샘플 엑셀에서 헤더(첫 행) + 미리보기 행 추출. 템플릿 등록 전에 사용.
|
||||||
|
* @param file 엑셀 파일
|
||||||
|
* @param headerRow 헤더가 있는 행 번호 (1-base, 보통 1)
|
||||||
|
* @param sheetIndex 시트 인덱스 (0-base)
|
||||||
|
* @param previewRows 헤더 다음 N행을 함께 반환
|
||||||
|
*/
|
||||||
|
public ExcelHeaderResp extractHeaders(MultipartFile file, int headerRow, int sheetIndex, int previewRows) {
|
||||||
|
List<String> headers = new ArrayList<>();
|
||||||
|
List<List<String>> samples = new ArrayList<>();
|
||||||
|
try (InputStream is = file.getInputStream();
|
||||||
|
Workbook wb = StreamingReader.builder()
|
||||||
|
.rowCacheSize(50).bufferSize(4096).open(is)) {
|
||||||
|
|
||||||
|
Sheet sheet = wb.getSheetAt(sheetIndex);
|
||||||
|
int rowNum = 0;
|
||||||
|
for (Row row : sheet) {
|
||||||
|
rowNum++;
|
||||||
|
if (rowNum == headerRow) {
|
||||||
|
short last = row.getLastCellNum();
|
||||||
|
for (int i = 0; i < last; i++) {
|
||||||
|
var cell = row.getCell(i);
|
||||||
|
headers.add(cell != null ? safeStringValue(cell) : "");
|
||||||
|
}
|
||||||
|
} else if (rowNum > headerRow && samples.size() < previewRows) {
|
||||||
|
List<String> rowVals = new ArrayList<>();
|
||||||
|
for (int i = 0; i < headers.size(); i++) {
|
||||||
|
var cell = row.getCell(i);
|
||||||
|
rowVals.add(cell != null ? safeStringValue(cell) : "");
|
||||||
|
}
|
||||||
|
samples.add(rowVals);
|
||||||
|
}
|
||||||
|
if (rowNum > headerRow + previewRows) break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("extractHeaders error", e);
|
||||||
|
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엑셀 컬럼 라벨 (A, B, C, ... AA, AB ...)
|
||||||
|
List<String> columnLabels = new ArrayList<>();
|
||||||
|
for (int i = 0; i < headers.size(); i++) columnLabels.add(toColumnLabel(i));
|
||||||
|
|
||||||
|
return new ExcelHeaderResp(columnLabels, headers, samples);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeStringValue(org.apache.poi.ss.usermodel.Cell cell) {
|
||||||
|
try {
|
||||||
|
CellType type = cell.getCellType();
|
||||||
|
return switch (type) {
|
||||||
|
case NUMERIC -> cell.getNumericCellValue() == Math.floor(cell.getNumericCellValue())
|
||||||
|
? String.valueOf((long) cell.getNumericCellValue())
|
||||||
|
: String.valueOf(cell.getNumericCellValue());
|
||||||
|
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
|
||||||
|
case FORMULA -> cell.getCellFormula();
|
||||||
|
case BLANK -> "";
|
||||||
|
default -> cell.getStringCellValue();
|
||||||
|
};
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 → A, 25 → Z, 26 → AA */
|
||||||
|
private String toColumnLabel(int index) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int n = index;
|
||||||
|
while (n >= 0) {
|
||||||
|
sb.insert(0, (char) ('A' + (n % 26)));
|
||||||
|
n = n / 26 - 1;
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ExcelHeaderResp(
|
||||||
|
List<String> columns, // ["A","B","C",...]
|
||||||
|
List<String> headers, // ["증권번호","설계사명",...]
|
||||||
|
List<List<String>> samples // 첫 N행
|
||||||
|
) {}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 내부 헬퍼
|
// 내부 헬퍼
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -104,6 +104,21 @@ export interface PreviewRowResp {
|
|||||||
[field: string]: unknown;
|
[field: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 샘플 엑셀 헤더 추출 응답 */
|
||||||
|
export interface ExcelHeaderResp {
|
||||||
|
columns: string[]; // ['A','B','C',...]
|
||||||
|
headers: string[]; // ['증권번호','설계사명',...]
|
||||||
|
samples: string[][]; // 첫 N행
|
||||||
|
}
|
||||||
|
|
||||||
|
/** target_table 별 사용 가능한 컬럼 메타 */
|
||||||
|
export interface TargetFieldMeta {
|
||||||
|
field: string; // policy_no
|
||||||
|
label: string; // 증권번호
|
||||||
|
type: string; // STRING/NUMBER/DECIMAL/DATE/INTEGER
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const uploadTemplateApi = {
|
export const uploadTemplateApi = {
|
||||||
list: (params: UploadTemplateQueryParams) =>
|
list: (params: UploadTemplateQueryParams) =>
|
||||||
unwrap<PageResponse<UploadTemplateResp>>(api.get('/api/upload-templates', { params })),
|
unwrap<PageResponse<UploadTemplateResp>>(api.get('/api/upload-templates', { params })),
|
||||||
@@ -148,4 +163,24 @@ export const uploadTemplateApi = {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 샘플 엑셀 → 헤더 + 미리보기 행 자동 추출 (등록 전 매핑 자동 채우기용) */
|
||||||
|
extractHeaders: (file: File, headerRow = 1, sheetIndex = 0, previewRows = 5) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
return unwrap<ExcelHeaderResp>(
|
||||||
|
api.post('/api/upload-templates/excel-headers', fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
params: { headerRow, sheetIndex, previewRows },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** target_table 별 사용 가능한 DB 컬럼 (target_field Select 옵션) */
|
||||||
|
targetFields: (targetTable: string) =>
|
||||||
|
unwrap<TargetFieldMeta[]>(api.get(`/api/upload-templates/target-fields/${targetTable}`)),
|
||||||
|
|
||||||
|
/** 사용 가능한 target_table 목록 */
|
||||||
|
targetTables: () =>
|
||||||
|
unwrap<string[]>(api.get('/api/upload-templates/target-tables')),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { ProCard, ProTable, ModalForm, ProFormText, ProFormSelect,
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
IconPlus, IconEdit, IconTrash, IconHistory, IconEye, IconDeviceFloppy,
|
IconPlus, IconEdit, IconTrash, IconHistory, IconEye, IconDeviceFloppy,
|
||||||
IconArrowUp, IconArrowDown,
|
IconArrowUp, IconArrowDown, IconWand, IconUpload,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import PageContainer from '@/components/common/PageContainer';
|
import PageContainer from '@/components/common/PageContainer';
|
||||||
import PermissionButton from '@/components/common/PermissionButton';
|
import PermissionButton from '@/components/common/PermissionButton';
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
type UploadTemplateResp,
|
type UploadTemplateResp,
|
||||||
type UploadTemplateColumnVO,
|
type UploadTemplateColumnVO,
|
||||||
type UploadTemplateSaveReq,
|
type UploadTemplateSaveReq,
|
||||||
|
type TargetFieldMeta,
|
||||||
|
type ExcelHeaderResp,
|
||||||
} from '@/api/uploadTemplate';
|
} from '@/api/uploadTemplate';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -301,6 +303,8 @@ function ColumnEditor({ template, onSaved }: {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
|
const [headerSample, setHeaderSample] = useState<ExcelHeaderResp | null>(null);
|
||||||
|
const sampleInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 선택된 템플릿 변경 시 컬럼 로드
|
// 선택된 템플릿 변경 시 컬럼 로드
|
||||||
useQuery({
|
useQuery({
|
||||||
@@ -309,6 +313,57 @@ function ColumnEditor({ template, onSaved }: {
|
|||||||
onSuccess: (data: UploadTemplateColumnVO[]) => setRows(toRows(data)),
|
onSuccess: (data: UploadTemplateColumnVO[]) => setRows(toRows(data)),
|
||||||
} as Parameters<typeof useQuery>[0]);
|
} as Parameters<typeof useQuery>[0]);
|
||||||
|
|
||||||
|
// target_table 별 사용 가능한 컬럼 메타 (target_field Select 옵션)
|
||||||
|
const { data: targetFields = [] } = useQuery<TargetFieldMeta[]>({
|
||||||
|
queryKey: ['uploadTemplate', 'targetFields', template.targetTable],
|
||||||
|
queryFn: () => uploadTemplateApi.targetFields(template.targetTable),
|
||||||
|
enabled: !!template.targetTable,
|
||||||
|
});
|
||||||
|
const targetFieldOptions = targetFields.map((f) => ({
|
||||||
|
value: f.field,
|
||||||
|
label: `${f.label} (${f.field})${f.required ? ' *' : ''}`,
|
||||||
|
type: f.type,
|
||||||
|
}));
|
||||||
|
const targetFieldTypeMap: Record<string, string> = Object.fromEntries(
|
||||||
|
targetFields.map((f) => [f.field, f.type]),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 샘플 엑셀 업로드 → 헤더 추출 → 비어있으면 자동 채움 / 있으면 사용자 확인 후 덮어쓰기 */
|
||||||
|
const onSampleFile = async (file: File) => {
|
||||||
|
try {
|
||||||
|
const meta = await uploadTemplateApi.extractHeaders(file, template.headerRow ?? 1, template.sheetIndex ?? 0, 5);
|
||||||
|
setHeaderSample(meta);
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
// headers 의 길이만큼 행 생성 (sourceColumn=A,B,C, sourceHeader=헤더명)
|
||||||
|
const newRows: ColumnRow[] = meta.headers.map((h, idx) => ({
|
||||||
|
_key: makeKey(),
|
||||||
|
sortOrder: idx + 1,
|
||||||
|
sourceType: 'COLUMN_INDEX',
|
||||||
|
sourceColumn: meta.columns[idx],
|
||||||
|
sourceHeader: h,
|
||||||
|
targetField: '', // 사용자가 Select 로 선택
|
||||||
|
targetType: 'STRING',
|
||||||
|
}));
|
||||||
|
setRows(newRows);
|
||||||
|
message.success(`${meta.headers.length}개 컬럼이 자동 추출되었습니다. 대상 필드만 선택해주세요.`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '기존 매핑이 덮어쓰기 됩니다',
|
||||||
|
content: `현재 ${rows.length}개 매핑이 있습니다. 샘플 엑셀의 ${meta.headers.length}개 컬럼으로 교체하시겠습니까?`,
|
||||||
|
okText: '교체', okType: 'danger',
|
||||||
|
onOk: apply,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// request.ts interceptor 가 토스트 표시
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const updateCell = (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => {
|
const updateCell = (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => {
|
||||||
setRows((prev) =>
|
setRows((prev) =>
|
||||||
prev.map((r) => (r._key === key ? { ...r, [field]: value } : r)),
|
prev.map((r) => (r._key === key ? { ...r, [field]: value } : r)),
|
||||||
@@ -366,13 +421,35 @@ function ColumnEditor({ template, onSaved }: {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
<Space>
|
<Space wrap>
|
||||||
<Text strong>{template.templateName}</Text>
|
<Text strong>{template.templateName}</Text>
|
||||||
<Tag>{template.templateCode}</Tag>
|
<Tag>{template.templateCode}</Tag>
|
||||||
<Tag color="blue">{template.category}</Tag>
|
<Tag color="blue">{template.category}</Tag>
|
||||||
|
<Tag color="purple">→ {template.targetTable}</Tag>
|
||||||
<CodeBadge groupCode="ACTIVE_YN" value={template.isActive} />
|
<CodeBadge groupCode="ACTIVE_YN" value={template.isActive} />
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
|
{/* 샘플 엑셀 자동 채우기 */}
|
||||||
|
<input
|
||||||
|
ref={sampleInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) onSampleFile(f);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip title="샘플 엑셀 파일을 업로드하면 첫 행의 헤더를 자동으로 추출해서 컬럼을 채웁니다">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconWand size={14} />}
|
||||||
|
onClick={() => sampleInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
샘플 엑셀로 자동 채우기
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
<Button size="small" icon={<IconEye size={14} />} onClick={() => setShowPreview(true)}>
|
<Button size="small" icon={<IconEye size={14} />} onClick={() => setShowPreview(true)}>
|
||||||
미리보기
|
미리보기
|
||||||
</Button>
|
</Button>
|
||||||
@@ -390,6 +467,36 @@ function ColumnEditor({ template, onSaved }: {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 샘플 미리보기 (자동 채우기 후 첫 5행) */}
|
||||||
|
{headerSample && headerSample.samples.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
background: '#fafafa', border: '1px solid #f0f0f0', borderRadius: 8,
|
||||||
|
padding: '10px 12px', marginBottom: 12,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, color: '#666', marginBottom: 6 }}>
|
||||||
|
<IconUpload size={12} style={{ verticalAlign: -2, marginRight: 4 }} />
|
||||||
|
샘플 데이터 (첫 5행)
|
||||||
|
</div>
|
||||||
|
<div style={{ overflow: 'auto' }}>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={headerSample.samples.map((row, idx) => ({
|
||||||
|
_idx: idx,
|
||||||
|
...Object.fromEntries(headerSample.headers.map((h, i) => [h, row[i]])),
|
||||||
|
}))}
|
||||||
|
rowKey="_idx"
|
||||||
|
columns={headerSample.headers.map((h, idx) => ({
|
||||||
|
title: <span><Tag color="blue" style={{ marginRight: 4 }}>{headerSample.columns[idx]}</Tag>{h}</span>,
|
||||||
|
dataIndex: h,
|
||||||
|
width: 120,
|
||||||
|
}))}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 컬럼 테이블 */}
|
{/* 컬럼 테이블 */}
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
<Table<ColumnRow>
|
<Table<ColumnRow>
|
||||||
@@ -455,12 +562,25 @@ function ColumnEditor({ template, onSaved }: {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '대상 필드', dataIndex: 'targetField', width: 120,
|
title: '대상 필드', dataIndex: 'targetField', width: 200,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<Input
|
<Select
|
||||||
size="small"
|
size="small"
|
||||||
value={r.targetField}
|
value={r.targetField || undefined}
|
||||||
onChange={(e) => updateCell(r._key, 'targetField', e.target.value)}
|
showSearch
|
||||||
|
allowClear
|
||||||
|
placeholder="DB 컬럼 선택"
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={targetFieldOptions}
|
||||||
|
onChange={(v) => {
|
||||||
|
updateCell(r._key, 'targetField', v ?? '');
|
||||||
|
// 타입 자동 채우기
|
||||||
|
if (v && targetFieldTypeMap[v]) {
|
||||||
|
updateCell(r._key, 'targetType', targetFieldTypeMap[v]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
notFoundContent={targetFields.length === 0 ? '메타 로딩 중...' : '일치하는 필드 없음'}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user