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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user