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:
GA Pro
2026-05-11 00:14:45 +09:00
parent 87e4e3d1da
commit 2136b38e1b
5 changed files with 411 additions and 7 deletions
@@ -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행
) {}
// ============================================================ // ============================================================
// 내부 헬퍼 // 내부 헬퍼
// ============================================================ // ============================================================
+35
View File
@@ -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 ? '메타 로딩 중...' : '일치하는 필드 없음'}
/> />
), ),
}, },