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:
@@ -9,7 +9,7 @@ import { ProCard, ProTable, ModalForm, ProFormText, ProFormSelect,
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
IconPlus, IconEdit, IconTrash, IconHistory, IconEye, IconDeviceFloppy,
|
||||
IconArrowUp, IconArrowDown,
|
||||
IconArrowUp, IconArrowDown, IconWand, IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type UploadTemplateResp,
|
||||
type UploadTemplateColumnVO,
|
||||
type UploadTemplateSaveReq,
|
||||
type TargetFieldMeta,
|
||||
type ExcelHeaderResp,
|
||||
} from '@/api/uploadTemplate';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -301,6 +303,8 @@ function ColumnEditor({ template, onSaved }: {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [headerSample, setHeaderSample] = useState<ExcelHeaderResp | null>(null);
|
||||
const sampleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 선택된 템플릿 변경 시 컬럼 로드
|
||||
useQuery({
|
||||
@@ -309,6 +313,57 @@ function ColumnEditor({ template, onSaved }: {
|
||||
onSuccess: (data: UploadTemplateColumnVO[]) => setRows(toRows(data)),
|
||||
} 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) => {
|
||||
setRows((prev) =>
|
||||
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', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Text strong>{template.templateName}</Text>
|
||||
<Tag>{template.templateCode}</Tag>
|
||||
<Tag color="blue">{template.category}</Tag>
|
||||
<Tag color="purple">→ {template.targetTable}</Tag>
|
||||
<CodeBadge groupCode="ACTIVE_YN" value={template.isActive} />
|
||||
</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>
|
||||
@@ -390,6 +467,36 @@ function ColumnEditor({ template, onSaved }: {
|
||||
</Space>
|
||||
</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' }}>
|
||||
<Table<ColumnRow>
|
||||
@@ -455,12 +562,25 @@ function ColumnEditor({ template, onSaved }: {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '대상 필드', dataIndex: 'targetField', width: 120,
|
||||
title: '대상 필드', dataIndex: 'targetField', width: 200,
|
||||
render: (_, r) => (
|
||||
<Input
|
||||
<Select
|
||||
size="small"
|
||||
value={r.targetField}
|
||||
onChange={(e) => updateCell(r._key, 'targetField', e.target.value)}
|
||||
value={r.targetField || undefined}
|
||||
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