feat: 엑셀 업로드 템플릿 시스템 (V19)
Agent Teams 3명 병렬 작업 (dba / api / frontend) 결과물.
DB (V19__업로드템플릿.sql):
- upload_template / upload_template_column / upload_history 3 테이블
- 인덱스 5종 (category / company_code / template_id / started_at)
- SYSTEM_UPLOAD_TEMPLATE 메뉴 + READ/CREATE/UPDATE/DELETE 권한
- 모든 역할에 권한 부여 (MANAGER 는 READ 만)
Backend:
- ga-core: VO 7개 (Template/Column/History의 VO/Resp/SaveReq/SearchParam) +
Mapper 3개 + XML 3개 (UploadTemplate/Column/History)
- ga-api: TransformEngine (12 변환 규칙: TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/
LOOKUP/CODE_MAP/FIXED/REPLACE/SUBSTRING/CONCAT/DEFAULT) +
LookupCache (O(1) HashMap 캐시) + UploadService (processUpload/preview) +
LookupMapper / DynamicInsertMapper (whitelist 기반 동적 SQL)
- ga-api Controller 2개:
· UploadTemplateController: /api/upload-templates CRUD + columns + history
· UploadController: POST /api/upload/{templateCode}, /preview
Frontend:
- api/uploadTemplate.ts (10 endpoints)
- pages/system/UploadTemplateManager.tsx — 좌(템플릿 목록) 우(컬럼 매핑 편집기)
+ ModalForm 등록/수정 + 미리보기 Modal + 이력 Drawer
- components/common/TemplateUpload.tsx — 범용 컴포넌트 (Select 템플릿 + Dragger
+ 진행률 + 결과 Modal + 에러 파일 다운로드)
- App.tsx 라우트 추가, MenuIcon.tsx 매핑 추가
검증:
- ./gradlew :ga-api:bootJar 통과
- Flyway V19 운영 DB 적용 성공 (200ms)
- ga-api 부팅 7.8s, /api/upload-templates 200 응답
- 메뉴 트리에 SYSTEM_UPLOAD_TEMPLATE 노출 확인
- 프론트 tsc -b / vite build 통과
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import MenuManage from '@/pages/system/MenuManage';
|
||||
import CodeList from '@/pages/system/CodeList';
|
||||
import SystemConfig from '@/pages/system/SystemConfig';
|
||||
import SystemLog from '@/pages/system/SystemLog';
|
||||
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
@@ -88,6 +89,7 @@ export default function App() {
|
||||
<Route path="system/codes" element={<CodeList />} />
|
||||
<Route path="system/config" element={<SystemConfig />} />
|
||||
<Route path="system/logs" element={<SystemLog />} />
|
||||
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import api, { PageResponse, unwrap } from './request';
|
||||
|
||||
export interface UploadTemplateResp {
|
||||
templateId: number;
|
||||
templateCode: string;
|
||||
templateName: string;
|
||||
category: string;
|
||||
targetTable: string;
|
||||
companyCode?: string;
|
||||
headerRow: number;
|
||||
dataStartRow: number;
|
||||
sheetIndex: number;
|
||||
sheetName?: string;
|
||||
batchSize: number;
|
||||
isActive: string;
|
||||
description?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface UploadTemplateDetailResp extends UploadTemplateResp {
|
||||
columns: UploadTemplateColumnVO[];
|
||||
}
|
||||
|
||||
export interface UploadTemplateColumnVO {
|
||||
columnId?: number;
|
||||
templateId?: number;
|
||||
sortOrder: number;
|
||||
sourceType: string;
|
||||
sourceColumn?: string;
|
||||
sourceHeader?: string;
|
||||
targetField: string;
|
||||
targetType: string;
|
||||
transformRule?: string;
|
||||
transformParam?: string;
|
||||
dateFormat?: string;
|
||||
numberDivide?: number;
|
||||
numberMultiply?: number;
|
||||
fixedValue?: string;
|
||||
defaultValue?: string;
|
||||
lookupTable?: string;
|
||||
lookupSourceField?: string;
|
||||
lookupTargetField?: string;
|
||||
codeMapGroup?: string;
|
||||
isRequired?: string;
|
||||
validationRegex?: string;
|
||||
validationMessage?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadTemplateQueryParams {
|
||||
category?: string;
|
||||
companyCode?: string;
|
||||
isActive?: string;
|
||||
searchKeyword?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface UploadTemplateSaveReq {
|
||||
templateCode: string;
|
||||
templateName: string;
|
||||
category: string;
|
||||
targetTable: string;
|
||||
companyCode?: string;
|
||||
headerRow?: number;
|
||||
dataStartRow?: number;
|
||||
sheetIndex?: number;
|
||||
sheetName?: string;
|
||||
batchSize?: number;
|
||||
isActive?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UploadHistoryResp {
|
||||
historyId: number;
|
||||
templateId: number;
|
||||
fileName: string;
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
skipCount: number;
|
||||
uploadedBy?: string;
|
||||
uploadedAt?: string;
|
||||
durationSec?: number;
|
||||
}
|
||||
|
||||
export interface UploadResultResp {
|
||||
totalCount: number;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
skipCount: number;
|
||||
errorFileId?: number;
|
||||
durationSec?: number;
|
||||
errors?: {
|
||||
rowNumber: number;
|
||||
fieldName?: string;
|
||||
value?: string;
|
||||
errorMessage: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PreviewRowResp {
|
||||
rowNumber: number;
|
||||
[field: string]: unknown;
|
||||
}
|
||||
|
||||
export const uploadTemplateApi = {
|
||||
list: (params: UploadTemplateQueryParams) =>
|
||||
unwrap<PageResponse<UploadTemplateResp>>(api.get('/api/upload-templates', { params })),
|
||||
|
||||
detail: (id: number) =>
|
||||
unwrap<UploadTemplateDetailResp>(api.get(`/api/upload-templates/${id}`)),
|
||||
|
||||
create: (req: UploadTemplateSaveReq) =>
|
||||
unwrap<number>(api.post('/api/upload-templates', req)),
|
||||
|
||||
update: (id: number, req: UploadTemplateSaveReq) =>
|
||||
unwrap<void>(api.put(`/api/upload-templates/${id}`, req)),
|
||||
|
||||
remove: (id: number) =>
|
||||
unwrap<void>(api.delete(`/api/upload-templates/${id}`)),
|
||||
|
||||
getColumns: (id: number) =>
|
||||
unwrap<UploadTemplateColumnVO[]>(api.get(`/api/upload-templates/${id}/columns`)),
|
||||
|
||||
saveColumns: (id: number, columns: UploadTemplateColumnVO[]) =>
|
||||
unwrap<void>(api.put(`/api/upload-templates/${id}/columns`, columns)),
|
||||
|
||||
history: (id: number) =>
|
||||
unwrap<UploadHistoryResp[]>(api.get(`/api/upload-templates/${id}/history`)),
|
||||
|
||||
preview: (templateCode: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return unwrap<PreviewRowResp[]>(
|
||||
api.post(`/api/upload-templates/${templateCode}/preview`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
upload: (templateCode: string, file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return unwrap<UploadResultResp>(
|
||||
api.post(`/api/upload/${templateCode}`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -37,6 +37,7 @@ const ICON_MAP: Record<string, ReactNode> = {
|
||||
SYSTEM_LOG: <IconClipboardList size={16} />,
|
||||
SYSTEM_FILE: <IconFolder size={16} />,
|
||||
SYSTEM_CONFIG: <IconSettings size={16} />,
|
||||
SYSTEM_UPLOAD_TEMPLATE: <IconFileSpreadsheet size={16} />,
|
||||
COMPANY: <IconBuildingBank size={16} />,
|
||||
PRODUCT: <IconFileSpreadsheet size={16} />,
|
||||
RECON: <IconReportMoney size={16} />,
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, message, Modal, Progress, Select, Space, Table, Typography, Upload } from 'antd';
|
||||
import type { RcFile, UploadProps } from 'antd/es/upload';
|
||||
import { IconDownload, IconUpload } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { uploadTemplateApi, type UploadResultResp } from '@/api/uploadTemplate';
|
||||
|
||||
const { Dragger } = Upload;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
/** 이 카테고리의 템플릿만 Select 에 표시 */
|
||||
category?: string;
|
||||
/** 업로드 완료 후 콜백 */
|
||||
onComplete?: (result: UploadResultResp) => void;
|
||||
}
|
||||
|
||||
export default function TemplateUpload({ category, onComplete }: Props) {
|
||||
const [templateCode, setTemplateCode] = useState<string | undefined>();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [result, setResult] = useState<UploadResultResp | null>(null);
|
||||
const [resultVisible, setResultVisible] = useState(false);
|
||||
|
||||
// 카테고리에 맞는 템플릿 목록
|
||||
const { data: templates = [], isLoading: templatesLoading } = useQuery({
|
||||
queryKey: ['uploadTemplate', 'list', 'select', category],
|
||||
queryFn: async () => {
|
||||
const res = await uploadTemplateApi.list({
|
||||
category,
|
||||
isActive: 'Y',
|
||||
pageSize: 200,
|
||||
pageNum: 1,
|
||||
});
|
||||
return res.list;
|
||||
},
|
||||
});
|
||||
|
||||
const templateOptions = templates.map((t) => ({
|
||||
value: t.templateCode,
|
||||
label: `[${t.category}] ${t.templateName}`,
|
||||
}));
|
||||
|
||||
const handleUpload = async (file: RcFile): Promise<false> => {
|
||||
if (!templateCode) {
|
||||
message.warning('템플릿을 먼저 선택하세요');
|
||||
return false;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setProgress(10);
|
||||
|
||||
// 진행률 시뮬레이션 (실제 서버 스트림이 없으므로 tick 방식)
|
||||
const ticker = setInterval(() => {
|
||||
setProgress((p) => {
|
||||
if (p >= 85) { clearInterval(ticker); return p; }
|
||||
return p + 5;
|
||||
});
|
||||
}, 400);
|
||||
|
||||
try {
|
||||
const res = await uploadTemplateApi.upload(templateCode, file);
|
||||
clearInterval(ticker);
|
||||
setProgress(100);
|
||||
setResult(res);
|
||||
setResultVisible(true);
|
||||
onComplete?.(res);
|
||||
} catch {
|
||||
clearInterval(ticker);
|
||||
setProgress(0);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
return false; // antd Upload 기본 동작 방지
|
||||
};
|
||||
|
||||
const draggerProps: UploadProps = {
|
||||
name: 'file',
|
||||
accept: '.xlsx,.xls,.csv',
|
||||
multiple: false,
|
||||
showUploadList: false,
|
||||
beforeUpload: handleUpload,
|
||||
disabled: uploading || !templateCode,
|
||||
};
|
||||
|
||||
const handleDownloadError = () => {
|
||||
if (!result?.errorFileId) return;
|
||||
// 오류 파일 다운로드 — 백엔드 파일 ID 기반 URL
|
||||
window.open(`/api/files/${result.errorFileId}/download`, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
{/* 템플릿 선택 */}
|
||||
<div>
|
||||
<Text style={{ display: 'block', marginBottom: 6, fontWeight: 500 }}>
|
||||
업로드 템플릿 선택
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="템플릿을 선택하세요"
|
||||
loading={templatesLoading}
|
||||
options={templateOptions}
|
||||
value={templateCode}
|
||||
onChange={setTemplateCode}
|
||||
style={{ width: '100%' }}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 파일 드래그 업로드 */}
|
||||
<Dragger {...draggerProps} style={{ padding: '8px 0' }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
<IconUpload size={32} color={!templateCode ? '#bbb' : '#185FA5'} />
|
||||
</p>
|
||||
<p style={{ margin: '8px 0 4px', fontSize: 14, fontWeight: 500,
|
||||
color: !templateCode ? '#bbb' : undefined }}>
|
||||
파일을 여기로 드래그하거나 클릭하여 선택
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: 12, color: '#999' }}>
|
||||
Excel (.xlsx, .xls) / CSV 지원
|
||||
{!templateCode && ' · 먼저 템플릿을 선택하세요'}
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{/* 진행률 */}
|
||||
{uploading && (
|
||||
<Progress
|
||||
percent={progress}
|
||||
status={progress === 100 ? 'success' : 'active'}
|
||||
strokeColor={{ '0%': '#185FA5', '100%': '#0F6E56' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 결과 모달 */}
|
||||
<Modal
|
||||
open={resultVisible}
|
||||
title="업로드 결과"
|
||||
onCancel={() => { setResultVisible(false); setProgress(0); }}
|
||||
footer={
|
||||
<Space>
|
||||
{result?.errorFileId && (
|
||||
<Button
|
||||
icon={<IconDownload size={14} />}
|
||||
onClick={handleDownloadError}
|
||||
>
|
||||
오류 파일 다운로드
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" onClick={() => { setResultVisible(false); setProgress(0); }}>
|
||||
확인
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{result && (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={[
|
||||
{ key: 'total', label: '전체', value: result.totalCount },
|
||||
{ key: 'success', label: '성공', value: result.successCount },
|
||||
{ key: 'error', label: '오류', value: result.errorCount },
|
||||
{ key: 'skip', label: '건너뜀', value: result.skipCount },
|
||||
]}
|
||||
columns={[
|
||||
{ title: '구분', dataIndex: 'label', width: 100 },
|
||||
{
|
||||
title: '건수', dataIndex: 'value', align: 'right',
|
||||
render: (v, r) => {
|
||||
if (r.key === 'success') return <Text style={{ color: '#0F6E56', fontWeight: 600 }}>{v}</Text>;
|
||||
if (r.key === 'error' && v > 0) return <Text type="danger" style={{ fontWeight: 600 }}>{v}</Text>;
|
||||
return <Text>{v}</Text>;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{result.durationSec !== undefined && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
소요 시간: {result.durationSec}초
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 오류 상세 (최대 10건) */}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<>
|
||||
<Text strong style={{ color: '#E24B4A' }}>
|
||||
오류 상세 (상위 {Math.min(result.errors.length, 10)}건)
|
||||
</Text>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={result.errors.slice(0, 10)}
|
||||
rowKey="rowNumber"
|
||||
scroll={{ y: 200 }}
|
||||
columns={[
|
||||
{ title: '행', dataIndex: 'rowNumber', width: 60 },
|
||||
{ title: '필드', dataIndex: 'fieldName', width: 100 },
|
||||
{ title: '값', dataIndex: 'value', width: 100, ellipsis: true },
|
||||
{ title: '오류', dataIndex: 'errorMessage', ellipsis: true },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Col, Divider, Drawer, Empty, Form, Input, InputNumber,
|
||||
message, Modal, Row, Select, Space, Spin, Table, Tag, Tooltip, Typography,
|
||||
} from 'antd';
|
||||
import { ProCard, ProTable, ModalForm, ProFormText, ProFormSelect,
|
||||
ProFormDigit, ProFormTextArea, type ActionType, type ProColumns,
|
||||
} from '@ant-design/pro-components';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
IconPlus, IconEdit, IconTrash, IconHistory, IconEye, IconDeviceFloppy,
|
||||
IconArrowUp, IconArrowDown,
|
||||
} from '@tabler/icons-react';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
import PermissionButton from '@/components/common/PermissionButton';
|
||||
import CodeBadge from '@/components/common/CodeBadge';
|
||||
import {
|
||||
uploadTemplateApi,
|
||||
type UploadTemplateResp,
|
||||
type UploadTemplateColumnVO,
|
||||
type UploadTemplateSaveReq,
|
||||
} from '@/api/uploadTemplate';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// transformRule → color tag
|
||||
const RULE_TAG: Record<string, { color: string; label: string }> = {
|
||||
TRIM: { color: 'default', label: 'TRIM' },
|
||||
UPPER: { color: 'default', label: 'UPPER' },
|
||||
LOWER: { color: 'default', label: 'LOWER' },
|
||||
DATE: { color: 'blue', label: 'DATE' },
|
||||
DIVIDE: { color: 'blue', label: 'DIVIDE' },
|
||||
MULTIPLY: { color: 'blue', label: 'MULTIPLY' },
|
||||
LOOKUP: { color: 'green', label: 'LOOKUP' },
|
||||
CODE_MAP: { color: 'green', label: 'CODE_MAP' },
|
||||
FIXED: { color: 'orange', label: 'FIXED' },
|
||||
DEFAULT: { color: 'orange', label: 'DEFAULT' },
|
||||
REPLACE: { color: 'purple', label: 'REPLACE' },
|
||||
SUBSTRING: { color: 'purple', label: 'SUBSTRING' },
|
||||
CONCAT: { color: 'purple', label: 'CONCAT' },
|
||||
};
|
||||
|
||||
const SOURCE_TYPES = [
|
||||
{ value: 'COLUMN_INDEX', label: '열 인덱스' },
|
||||
{ value: 'COLUMN_HEADER', label: '열 헤더명' },
|
||||
{ value: 'FIXED', label: '고정값' },
|
||||
];
|
||||
|
||||
const TARGET_TYPES = [
|
||||
{ value: 'STRING', label: 'STRING' },
|
||||
{ value: 'NUMBER', label: 'NUMBER' },
|
||||
{ value: 'DATE', label: 'DATE' },
|
||||
{ value: 'BOOLEAN', label: 'BOOLEAN' },
|
||||
];
|
||||
|
||||
const TRANSFORM_RULES = [
|
||||
{ value: '', label: '없음' },
|
||||
{ value: 'TRIM', label: 'TRIM' },
|
||||
{ value: 'UPPER', label: 'UPPER' },
|
||||
{ value: 'LOWER', label: 'LOWER' },
|
||||
{ value: 'DATE', label: 'DATE (날짜 변환)' },
|
||||
{ value: 'DIVIDE', label: 'DIVIDE (나누기)' },
|
||||
{ value: 'MULTIPLY', label: 'MULTIPLY (곱하기)' },
|
||||
{ value: 'LOOKUP', label: 'LOOKUP (테이블 조회)' },
|
||||
{ value: 'CODE_MAP', label: 'CODE_MAP (코드 매핑)' },
|
||||
{ value: 'FIXED', label: 'FIXED (고정값)' },
|
||||
{ value: 'DEFAULT', label: 'DEFAULT (기본값)' },
|
||||
{ value: 'REPLACE', label: 'REPLACE (치환)' },
|
||||
{ value: 'SUBSTRING', label: 'SUBSTRING (자르기)' },
|
||||
{ value: 'CONCAT', label: 'CONCAT (연결)' },
|
||||
];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: 'RECRUIT', label: '모집' },
|
||||
{ value: 'MAINTAIN', label: '유지' },
|
||||
{ value: 'EXCEPTION', label: '예외' },
|
||||
{ value: 'PAYMENT', label: '지급' },
|
||||
{ value: 'ETC', label: '기타' },
|
||||
];
|
||||
|
||||
// ─── 컬럼 매핑 행 타입 (로컬 편집용 key 포함) ───────────────────────────────
|
||||
interface ColumnRow extends UploadTemplateColumnVO {
|
||||
_key: string;
|
||||
}
|
||||
|
||||
function makeKey() {
|
||||
return `r_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
function toRows(cols: UploadTemplateColumnVO[]): ColumnRow[] {
|
||||
return cols.map((c) => ({ ...c, _key: makeKey() }));
|
||||
}
|
||||
|
||||
// ─── 변환 규칙에 따라 동적 파라미터 입력 렌더 ──────────────────────────────
|
||||
function RuleParamCell({ row, onChange }: {
|
||||
row: ColumnRow;
|
||||
onChange: (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => void;
|
||||
}) {
|
||||
const rule = row.transformRule ?? '';
|
||||
if (rule === 'DATE') {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="yyyyMMdd"
|
||||
value={row.dateFormat ?? ''}
|
||||
onChange={(e) => onChange(row._key, 'dateFormat', e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (rule === 'DIVIDE') {
|
||||
return (
|
||||
<InputNumber
|
||||
size="small"
|
||||
placeholder="나누는 수"
|
||||
value={row.numberDivide}
|
||||
onChange={(v) => onChange(row._key, 'numberDivide', v ?? undefined)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (rule === 'MULTIPLY') {
|
||||
return (
|
||||
<InputNumber
|
||||
size="small"
|
||||
placeholder="곱하는 수"
|
||||
value={row.numberMultiply}
|
||||
onChange={(v) => onChange(row._key, 'numberMultiply', v ?? undefined)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (rule === 'LOOKUP') {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="테이블.컬럼"
|
||||
value={row.lookupTable ?? ''}
|
||||
onChange={(e) => onChange(row._key, 'lookupTable', e.target.value)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (rule === 'CODE_MAP') {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="그룹코드"
|
||||
value={row.codeMapGroup ?? ''}
|
||||
onChange={(e) => onChange(row._key, 'codeMapGroup', e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (rule === 'FIXED') {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="고정값"
|
||||
value={row.fixedValue ?? ''}
|
||||
onChange={(e) => onChange(row._key, 'fixedValue', e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (rule === 'DEFAULT') {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="기본값"
|
||||
value={row.defaultValue ?? ''}
|
||||
onChange={(e) => onChange(row._key, 'defaultValue', e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (rule === 'REPLACE' || rule === 'SUBSTRING' || rule === 'CONCAT') {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="파라미터"
|
||||
value={row.transformParam ?? ''}
|
||||
onChange={(e) => onChange(row._key, 'transformParam', e.target.value)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text type="secondary" style={{ fontSize: 12 }}>-</Text>;
|
||||
}
|
||||
|
||||
// ─── 미리보기 파일 선택 모달 ───────────────────────────────────────────────
|
||||
function PreviewModal({ templateCode, onClose }: { templateCode: string; onClose: () => void }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewRows, setPreviewRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [columns, setColumns] = useState<{ title: string; dataIndex: string }[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const rows = await uploadTemplateApi.preview(templateCode, file);
|
||||
if (rows.length > 0) {
|
||||
const keys = Object.keys(rows[0]).filter((k) => k !== 'rowNumber');
|
||||
setColumns([
|
||||
{ title: '행번호', dataIndex: 'rowNumber' },
|
||||
...keys.map((k) => ({ title: k, dataIndex: k })),
|
||||
]);
|
||||
}
|
||||
setPreviewRows(rows as Record<string, unknown>[]);
|
||||
} catch {
|
||||
message.error('미리보기 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`매핑 미리보기 — ${templateCode}`}
|
||||
width={900}
|
||||
onCancel={onClose}
|
||||
footer={<Button onClick={onClose}>닫기</Button>}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
icon={<IconEye size={14} />}
|
||||
>
|
||||
파일 선택
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) handleFile(f);
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Excel / CSV 파일 선택 → 첫 5행 결과 표시
|
||||
</Text>
|
||||
</Space>
|
||||
{loading && <Spin />}
|
||||
{previewRows.length > 0 && (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="rowNumber"
|
||||
dataSource={previewRows}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 800 }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 이력 Drawer ──────────────────────────────────────────────────────────
|
||||
function HistoryDrawer({ templateId, onClose }: { templateId: number; onClose: () => void }) {
|
||||
const { data = [], isLoading } = useQuery({
|
||||
queryKey: ['uploadTemplate', 'history', templateId],
|
||||
queryFn: () => uploadTemplateApi.history(templateId),
|
||||
});
|
||||
|
||||
return (
|
||||
<Drawer title="업로드 이력" width={720} open onClose={onClose}>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="historyId"
|
||||
loading={isLoading}
|
||||
dataSource={data}
|
||||
pagination={{ pageSize: 20 }}
|
||||
columns={[
|
||||
{ title: '파일명', dataIndex: 'fileName', ellipsis: true },
|
||||
{ title: '전체', dataIndex: 'totalCount', width: 70, align: 'right' },
|
||||
{ title: '성공', dataIndex: 'successCount', width: 70, align: 'right',
|
||||
render: (v) => <Text style={{ color: '#0F6E56' }}>{v}</Text> },
|
||||
{ title: '오류', dataIndex: 'errorCount', width: 70, align: 'right',
|
||||
render: (v) => v > 0 ? <Text type="danger">{v}</Text> : <Text>{v}</Text> },
|
||||
{ title: '건너뜀', dataIndex: 'skipCount', width: 70, align: 'right' },
|
||||
{ title: '소요(초)', dataIndex: 'durationSec', width: 80, align: 'right' },
|
||||
{ title: '업로더', dataIndex: 'uploadedBy', width: 100 },
|
||||
{ title: '업로드일시', dataIndex: 'uploadedAt', width: 160 },
|
||||
]}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 컬럼 매핑 편집기 ─────────────────────────────────────────────────────
|
||||
function ColumnEditor({ template, onSaved }: {
|
||||
template: UploadTemplateResp;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [rows, setRows] = useState<ColumnRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
// 선택된 템플릿 변경 시 컬럼 로드
|
||||
useQuery({
|
||||
queryKey: ['uploadTemplate', 'columns', template.templateId],
|
||||
queryFn: () => uploadTemplateApi.getColumns(template.templateId),
|
||||
onSuccess: (data: UploadTemplateColumnVO[]) => setRows(toRows(data)),
|
||||
} as Parameters<typeof useQuery>[0]);
|
||||
|
||||
const updateCell = (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => {
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r._key === key ? { ...r, [field]: value } : r)),
|
||||
);
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setRows((prev) => [
|
||||
...prev,
|
||||
{
|
||||
_key: makeKey(),
|
||||
sortOrder: prev.length + 1,
|
||||
sourceType: 'COLUMN_INDEX',
|
||||
targetField: '',
|
||||
targetType: 'STRING',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeRow = (key: string) => {
|
||||
setRows((prev) => prev.filter((r) => r._key !== key).map((r, i) => ({ ...r, sortOrder: i + 1 })));
|
||||
};
|
||||
|
||||
const moveRow = (key: string, dir: 'up' | 'down') => {
|
||||
setRows((prev) => {
|
||||
const idx = prev.findIndex((r) => r._key === key);
|
||||
if (idx < 0) return prev;
|
||||
if (dir === 'up' && idx === 0) return prev;
|
||||
if (dir === 'down' && idx === prev.length - 1) return prev;
|
||||
const next = [...prev];
|
||||
const swap = dir === 'up' ? idx - 1 : idx + 1;
|
||||
[next[idx], next[swap]] = [next[swap], next[idx]];
|
||||
return next.map((r, i) => ({ ...r, sortOrder: i + 1 }));
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const payload: UploadTemplateColumnVO[] = rows.map(({ _key, ...rest }) => rest);
|
||||
await uploadTemplateApi.saveColumns(template.templateId, payload);
|
||||
message.success('컬럼 매핑이 저장되었습니다');
|
||||
onSaved();
|
||||
} catch {
|
||||
// request.ts interceptor handles toast
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Spin style={{ margin: 32 }} />;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 헤더 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<Space>
|
||||
<Text strong>{template.templateName}</Text>
|
||||
<Tag>{template.templateCode}</Tag>
|
||||
<Tag color="blue">{template.category}</Tag>
|
||||
<CodeBadge groupCode="ACTIVE_YN" value={template.isActive} />
|
||||
</Space>
|
||||
<Space>
|
||||
<Button size="small" icon={<IconEye size={14} />} onClick={() => setShowPreview(true)}>
|
||||
미리보기
|
||||
</Button>
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_UPLOAD_TEMPLATE"
|
||||
permCode="UPDATE"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<IconDeviceFloppy size={14} />}
|
||||
loading={saving}
|
||||
onClick={save}
|
||||
>
|
||||
저장
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 테이블 */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<Table<ColumnRow>
|
||||
size="small"
|
||||
rowKey="_key"
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
footer={() => (
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_UPLOAD_TEMPLATE"
|
||||
permCode="UPDATE"
|
||||
size="small"
|
||||
icon={<IconPlus size={14} />}
|
||||
onClick={addRow}
|
||||
>
|
||||
행 추가
|
||||
</PermissionButton>
|
||||
)}
|
||||
columns={[
|
||||
{
|
||||
title: '순서', dataIndex: 'sortOrder', width: 60, align: 'center',
|
||||
render: (_, r) => (
|
||||
<Space size={2}>
|
||||
<Tooltip title="위로">
|
||||
<Button size="small" type="text" icon={<IconArrowUp size={12} />}
|
||||
onClick={() => moveRow(r._key, 'up')} />
|
||||
</Tooltip>
|
||||
<Tooltip title="아래로">
|
||||
<Button size="small" type="text" icon={<IconArrowDown size={12} />}
|
||||
onClick={() => moveRow(r._key, 'down')} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '소스 유형', dataIndex: 'sourceType', width: 120,
|
||||
render: (_, r) => (
|
||||
<Select
|
||||
size="small"
|
||||
value={r.sourceType}
|
||||
options={SOURCE_TYPES}
|
||||
onChange={(v) => updateCell(r._key, 'sourceType', v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '소스 컬럼', dataIndex: 'sourceColumn', width: 80,
|
||||
render: (_, r) => (
|
||||
<Input
|
||||
size="small"
|
||||
value={r.sourceType === 'COLUMN_HEADER' ? (r.sourceHeader ?? '') : (r.sourceColumn ?? '')}
|
||||
placeholder={r.sourceType === 'COLUMN_HEADER' ? '헤더명' : '열 번호'}
|
||||
onChange={(e) => {
|
||||
if (r.sourceType === 'COLUMN_HEADER') {
|
||||
updateCell(r._key, 'sourceHeader', e.target.value);
|
||||
} else {
|
||||
updateCell(r._key, 'sourceColumn', e.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '대상 필드', dataIndex: 'targetField', width: 120,
|
||||
render: (_, r) => (
|
||||
<Input
|
||||
size="small"
|
||||
value={r.targetField}
|
||||
onChange={(e) => updateCell(r._key, 'targetField', e.target.value)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '대상 타입', dataIndex: 'targetType', width: 100,
|
||||
render: (_, r) => (
|
||||
<Select
|
||||
size="small"
|
||||
value={r.targetType}
|
||||
options={TARGET_TYPES}
|
||||
onChange={(v) => updateCell(r._key, 'targetType', v)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '변환 규칙', dataIndex: 'transformRule', width: 140,
|
||||
render: (_, r) => (
|
||||
<Select
|
||||
size="small"
|
||||
value={r.transformRule ?? ''}
|
||||
options={TRANSFORM_RULES}
|
||||
onChange={(v) => updateCell(r._key, 'transformRule', v || undefined)}
|
||||
style={{ width: '100%' }}
|
||||
dropdownMatchSelectWidth={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '파라미터', width: 130,
|
||||
render: (_, r) => <RuleParamCell row={r} onChange={updateCell} />,
|
||||
},
|
||||
{
|
||||
title: '필수', dataIndex: 'isRequired', width: 60, align: 'center',
|
||||
render: (_, r) => (
|
||||
<Select
|
||||
size="small"
|
||||
value={r.isRequired ?? 'N'}
|
||||
options={[{ value: 'Y', label: <Tag color="error" style={{ margin: 0 }}>Y</Tag> }, { value: 'N', label: 'N' }]}
|
||||
onChange={(v) => updateCell(r._key, 'isRequired', v)}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '액션', width: 60, align: 'center', fixed: 'right',
|
||||
render: (_, r) => (
|
||||
<PermissionButton
|
||||
menuCode="SYSTEM_UPLOAD_TEMPLATE"
|
||||
permCode="DELETE"
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<IconTrash size={14} />}
|
||||
onClick={() => removeRow(r._key)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showPreview && (
|
||||
<PreviewModal
|
||||
templateCode={template.templateCode}
|
||||
onClose={() => setShowPreview(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 템플릿 등록 / 수정 모달 폼 ─────────────────────────────────────────
|
||||
function TemplateFormModal({ editTarget, onSuccess, trigger }: {
|
||||
editTarget?: UploadTemplateResp;
|
||||
onSuccess: () => void;
|
||||
trigger: JSX.Element;
|
||||
}) {
|
||||
return (
|
||||
<ModalForm<UploadTemplateSaveReq>
|
||||
title={editTarget ? '템플릿 수정' : '템플릿 등록'}
|
||||
trigger={trigger}
|
||||
width={560}
|
||||
modalProps={{ destroyOnClose: true }}
|
||||
initialValues={editTarget ?? { headerRow: 1, dataStartRow: 2, sheetIndex: 0, batchSize: 1000, isActive: 'Y' }}
|
||||
onFinish={async (values) => {
|
||||
if (editTarget) {
|
||||
await uploadTemplateApi.update(editTarget.templateId, values);
|
||||
message.success('수정 완료');
|
||||
} else {
|
||||
await uploadTemplateApi.create(values);
|
||||
message.success('등록 완료');
|
||||
}
|
||||
onSuccess();
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<ProFormText name="templateCode" label="템플릿 코드" rules={[{ required: true }]}
|
||||
fieldProps={{ disabled: !!editTarget }} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormText name="templateName" label="템플릿 명" rules={[{ required: true }]} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormSelect name="category" label="카테고리" rules={[{ required: true }]}
|
||||
options={CATEGORIES} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormText name="targetTable" label="대상 테이블" rules={[{ required: true }]} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormText name="companyCode" label="보험사 코드" />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormSelect name="isActive" label="활성여부"
|
||||
options={[{ value: 'Y', label: '활성' }, { value: 'N', label: '비활성' }]} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<ProFormDigit name="headerRow" label="헤더 행" min={0} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<ProFormDigit name="dataStartRow" label="데이터 시작 행" min={1} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<ProFormDigit name="sheetIndex" label="시트 인덱스" min={0} />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormText name="sheetName" label="시트 이름" />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormDigit name="batchSize" label="배치 크기" min={1} />
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<ProFormTextArea name="description" label="설명" fieldProps={{ rows: 2 }} />
|
||||
</Col>
|
||||
</Row>
|
||||
</ModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 페이지 ──────────────────────────────────────────────────────────
|
||||
export default function UploadTemplateManager() {
|
||||
const qc = useQueryClient();
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [selected, setSelected] = useState<UploadTemplateResp | null>(null);
|
||||
const [historyTarget, setHistoryTarget] = useState<UploadTemplateResp | null>(null);
|
||||
const [editTarget, setEditTarget] = useState<UploadTemplateResp | undefined>();
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['uploadTemplate', 'list'] });
|
||||
actionRef.current?.reload();
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) =>
|
||||
Modal.confirm({
|
||||
title: '삭제 확인',
|
||||
content: '이 템플릿을 삭제하시겠습니까?',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
await uploadTemplateApi.remove(id);
|
||||
message.success('삭제 완료');
|
||||
if (selected?.templateId === id) setSelected(null);
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const columns: ProColumns<UploadTemplateResp>[] = [
|
||||
{
|
||||
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
|
||||
fieldProps: { placeholder: '코드 / 이름' },
|
||||
},
|
||||
{
|
||||
title: '카테고리', dataIndex: 'category', width: 90, valueType: 'select',
|
||||
valueEnum: Object.fromEntries(CATEGORIES.map((c) => [c.value, { text: c.label }])),
|
||||
render: (_, r) => <Tag>{r.category}</Tag>,
|
||||
},
|
||||
{ title: '코드', dataIndex: 'templateCode', width: 160, search: false, ellipsis: true },
|
||||
{ title: '이름', dataIndex: 'templateName', search: false, ellipsis: true },
|
||||
{
|
||||
title: '활성', dataIndex: 'isActive', width: 70, align: 'center', search: false,
|
||||
render: (_, r) => <Tag color={r.isActive === 'Y' ? 'success' : 'default'}>{r.isActive}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '액션', width: 140, fixed: 'right', search: false,
|
||||
render: (_, r) => (
|
||||
<Space size={2}>
|
||||
<TemplateFormModal
|
||||
editTarget={r}
|
||||
onSuccess={refresh}
|
||||
trigger={
|
||||
<PermissionButton menuCode="SYSTEM_UPLOAD_TEMPLATE" permCode="UPDATE"
|
||||
size="small" type="text" icon={<IconEdit size={14} />}
|
||||
onClick={() => setEditTarget(r)}
|
||||
>
|
||||
수정
|
||||
</PermissionButton>
|
||||
}
|
||||
/>
|
||||
<PermissionButton menuCode="SYSTEM_UPLOAD_TEMPLATE" permCode="DELETE"
|
||||
size="small" type="text" danger icon={<IconTrash size={14} />}
|
||||
onClick={() => handleDelete(r.templateId)}
|
||||
>
|
||||
삭제
|
||||
</PermissionButton>
|
||||
<Tooltip title="이력">
|
||||
<Button size="small" type="text" icon={<IconHistory size={14} />}
|
||||
onClick={() => setHistoryTarget(r)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="업로드 템플릿 관리"
|
||||
description="엑셀 / CSV 업로드 매핑 템플릿 등록 및 컬럼 매핑 편집"
|
||||
extra={
|
||||
<TemplateFormModal
|
||||
onSuccess={refresh}
|
||||
trigger={
|
||||
<PermissionButton menuCode="SYSTEM_UPLOAD_TEMPLATE" permCode="CREATE"
|
||||
type="primary" icon={<IconPlus size={14} />}
|
||||
>
|
||||
템플릿 등록
|
||||
</PermissionButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ProCard split="vertical" style={{ minHeight: 600 }}>
|
||||
{/* 좌측: 템플릿 목록 */}
|
||||
<ProCard colSpan={380} style={{ overflow: 'hidden' }}>
|
||||
<ProTable<UploadTemplateResp>
|
||||
actionRef={actionRef}
|
||||
rowKey="templateId"
|
||||
columns={columns}
|
||||
onRow={(r) => ({
|
||||
onClick: () => setSelected(r),
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
background: r.templateId === selected?.templateId ? '#E6F1FB' : undefined,
|
||||
},
|
||||
})}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, ...rest } = params as {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
category?: string;
|
||||
searchKeyword?: string;
|
||||
};
|
||||
const res = await uploadTemplateApi.list({ ...rest, pageNum: current, pageSize });
|
||||
return { data: res.list, total: res.total, success: true };
|
||||
}}
|
||||
pagination={{ pageSize: 20, showTotal: (t) => `총 ${t}건` }}
|
||||
search={{ labelWidth: 'auto', searchText: '검색', resetText: '초기화', collapsed: false, collapseRender: false }}
|
||||
options={{ density: false, reload: true }}
|
||||
dateFormatter="string"
|
||||
scroll={{ x: 380 }}
|
||||
/>
|
||||
</ProCard>
|
||||
|
||||
{/* 우측: 컬럼 매핑 편집기 */}
|
||||
<ProCard style={{ padding: 16 }}>
|
||||
{selected ? (
|
||||
<ColumnEditor
|
||||
key={selected.templateId}
|
||||
template={selected}
|
||||
onSaved={() => qc.invalidateQueries({ queryKey: ['uploadTemplate', 'columns', selected.templateId] })}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: 400 }}>
|
||||
<Empty description="좌측에서 템플릿을 선택하면 컬럼 매핑을 편집할 수 있습니다" />
|
||||
</div>
|
||||
)}
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
|
||||
{/* 이력 Drawer */}
|
||||
{historyTarget && (
|
||||
<HistoryDrawer
|
||||
templateId={historyTarget.templateId}
|
||||
onClose={() => setHistoryTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user