Purpose
새로운 Excel 가져오기 모달을 프로젝트 표준 패턴에 맞춰 자동 생성합니다:
- 모달 컴포넌트 생성 —
useExcelImport 훅을 사용하는 가져오기 모달
- 파서 함수 생성 — xlsx 필드 매핑 및 정규화 로직
- 타입 정의 — 파싱된 행의 TypeScript 인터페이스
- 부모 컴포넌트 연동 안내 — 모달 열기/닫기 및 onImport 콜백 연결
When to Run
- 새 도메인에 Excel 가져오기 기능을 추가할 때
- 기존 가져오기 모달을
useExcelImport 훅 기반으로 리팩토링할 때
| File | Purpose |
|---|
hooks/useExcelImport.ts | Excel 가져오기 공통 훅 (파일 읽기, 상태 관리, 가져오기 실행) |
components/Textbooks/TextbookImportModal.tsx | 참조 구현: 교재 수납 가져오기 |
components/Billing/BillingImportModal.tsx | 참조 구현: 수납 데이터 가져오기 |
components/StudentManagement/StudentMigrationModal.tsx | 참조 구현: 학생 마이그레이션 |
utils/studentMatching.ts | 학생 매칭 유틸 (가져오기 시 학생 DB 매칭이 필요한 경우 사용) |
Workflow
Step 1: 사용자 입력 수집
AskUserQuestion을 사용하여 다음을 확인합니다:
- 도메인 이름 (예:
attendance, homework, shuttle)
- Excel 필드 매핑 — 어떤 열을 읽을지 (예: 이름, 학년, 학교, 점수)
- 필터 조건 (선택) — 특정 행만 가져올지 (예: 구분 === '교재')
- 학생 매칭 필요 여부 —
studentMatching.ts 연동 필요 여부
- 소속 탭 — 이 모달이 열리는 탭 컴포넌트 이름
Step 2: 타입 정의 생성
파싱된 행의 인터페이스를 모달 파일 상단에 정의합니다:
typescript
1export interface <PascalCase>ImportRow {
2 // 사용자가 지정한 필드들
3 studentName: string;
4 grade: string;
5 // ...
6 // 학생 매칭이 필요한 경우:
7 matched: boolean;
8 studentId?: string;
9}
Step 3: 모달 컴포넌트 생성
파일: components/<PascalCase>/<PascalCase>ImportModal.tsx
프로젝트 표준 구조:
tsx
1import React, { useMemo } from 'react';
2import { X, Upload, FileSpreadsheet, Loader2, AlertCircle, Check, RotateCcw, Eye } from 'lucide-react';
3import { useExcelImport } from '../../hooks/useExcelImport';
4
5// 1. 타입 정의
6export interface <PascalCase>ImportRow {
7 // ...fields
8}
9
10// 2. Props 정의
11interface <PascalCase>ImportModalProps {
12 isOpen: boolean;
13 onClose: () => void;
14 onImport: (rows: <PascalCase>ImportRow[]) => Promise<{ added: number; skipped: number }>;
15 // 학생 매칭이 필요한 경우:
16 // studentIds?: Set<string>;
17}
18
19// 3. 파서 함수 (또는 컴포넌트 내부 useCallback)
20function parse<PascalCase>Rows(rows: Record<string, any>[]): <PascalCase>ImportRow[] {
21 return rows
22 // .filter(row => ...) // 필터 조건이 있는 경우
23 .map(row => ({
24 // 필드 매핑
25 }));
26}
27
28// 4. 컴포넌트
29export const <PascalCase>ImportModal: React.FC<<PascalCase>ImportModalProps> = ({
30 isOpen,
31 onClose,
32 onImport,
33}) => {
34 const {
35 fileInputRef,
36 parsedData,
37 fileName,
38 isImporting,
39 importResult,
40 handleFileChange,
41 handleImport,
42 handleReset,
43 openFileDialog,
44 isParsed,
45 isComplete,
46 } = useExcelImport({
47 parser: parse<PascalCase>Rows,
48 onImport,
49 });
50
51 // 통계 요약 (useMemo)
52 const summary = useMemo(() => {
53 if (!isParsed) return null;
54 return {
55 totalRecords: parsedData.length,
56 // ... 도메인별 통계
57 };
58 }, [parsedData, isParsed]);
59
60 if (!isOpen) return null;
61
62 return (
63 <div className="fixed inset-0 bg-black/50 flex items-start justify-center pt-[8vh] z-[100]" onClick={onClose}>
64 <div className="bg-white rounded-sm w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden" onClick={e => e.stopPropagation()}>
65 {/* Header */}
66 <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
67 <h2 className="text-sm font-bold text-primary flex items-center gap-2">
68 <FileSpreadsheet size={18} className="text-emerald-600" />
69 <도메인명> 데이터 가져오기
70 </h2>
71 <button onClick={onClose} className="p-1 rounded-sm hover:bg-gray-100 text-gray-400 hover:text-gray-600">
72 <X size={18} />
73 </button>
74 </div>
75
76 {/* Body */}
77 <div className="flex-1 overflow-auto p-6 space-y-2">
78 {/* 파일 업로드 섹션 */}
79 <div className="bg-white border border-gray-200 overflow-hidden">
80 <div className="flex items-center gap-1 px-2 py-1.5 bg-gray-50 border-b border-gray-200">
81 <Upload className="w-3 h-3 text-primary" />
82 <h3 className="text-primary font-bold text-xs">파일 업로드</h3>
83 </div>
84 <div className="p-3">
85 <input ref={fileInputRef} type="file" accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" />
86 <div className="flex items-center gap-3">
87 <button
88 onClick={openFileDialog}
89 className="flex items-center gap-2 px-4 py-2 border-2 border-dashed border-gray-300 rounded-sm hover:border-emerald-500 hover:bg-emerald-50 transition-colors text-sm"
90 >
91 <FileSpreadsheet className="w-4 h-4" />
92 Excel 파일 선택
93 </button>
94 {fileName && <span className="text-sm text-gray-600 font-medium">{fileName}</span>}
95 </div>
96 </div>
97 </div>
98
99 {/* 데이터 미리보기 섹션 */}
100 {summary && (
101 <div className="bg-white border border-gray-200 overflow-hidden">
102 <div className="flex items-center gap-1 px-2 py-1.5 bg-gray-50 border-b border-gray-200">
103 <Eye className="w-3 h-3 text-primary" />
104 <h3 className="text-primary font-bold text-xs">데이터 미리보기</h3>
105 </div>
106 <div className="p-3 space-y-3">
107 {/* 통계 카드 */}
108 <div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-sm">
109 {/* ... 도메인별 통계 카드 */}
110 </div>
111
112 {/* 미리보기 테이블 */}
113 <div className="border border-gray-200 rounded-sm overflow-hidden">
114 <div className="bg-gray-50 px-2 py-1 border-b border-gray-200">
115 <h4 className="text-xs font-medium text-gray-600">처음 15건 미리보기</h4>
116 </div>
117 <div className="overflow-x-auto">
118 <table className="w-full text-xs">
119 <thead className="bg-gray-50 border-b border-gray-200">
120 <tr>{/* 도메인별 컬럼 헤더 */}</tr>
121 </thead>
122 <tbody className="divide-y divide-gray-100 bg-white">
123 {parsedData.slice(0, 15).map((row, i) => (
124 <tr key={i} className="hover:bg-gray-50">
125 {/* 도메인별 컬럼 데이터 */}
126 </tr>
127 ))}
128 </tbody>
129 </table>
130 </div>
131 {parsedData.length > 15 && (
132 <div className="bg-gray-50 px-2 py-1 border-t border-gray-200">
133 <p className="text-xxs text-gray-400 text-center">... 외 {parsedData.length - 15}건 더 있음</p>
134 </div>
135 )}
136 </div>
137 </div>
138 </div>
139 )}
140
141 {/* 결과 섹션 */}
142 {importResult && (
143 <div className="bg-white border border-gray-200 overflow-hidden">
144 <div className="flex items-center gap-1 px-2 py-1.5 bg-gray-50 border-b border-gray-200">
145 {importResult.success
146 ? <Check className="w-3 h-3 text-emerald-600" />
147 : <AlertCircle className="w-3 h-3 text-red-600" />}
148 <h3 className={`font-bold text-xs ${importResult.success ? 'text-emerald-700' : 'text-red-700'}`}>
149 {importResult.success ? '가져오기 완료' : '가져오기 실패'}
150 </h3>
151 </div>
152 <div className={`p-3 ${importResult.success ? 'bg-emerald-50' : 'bg-red-50'}`}>
153 {importResult.success ? (
154 <div className="flex items-center gap-2">
155 <Check className="w-5 h-5 text-emerald-600 shrink-0" />
156 <div className="text-sm text-emerald-700 font-medium">
157 {importResult.added.toLocaleString()}건 추가 완료
158 {importResult.skipped > 0 && (
159 <span className="text-gray-500 font-normal ml-1">(중복 {importResult.skipped.toLocaleString()}건 건너뜀)</span>
160 )}
161 </div>
162 </div>
163 ) : (
164 <div className="flex items-start gap-2">
165 <AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
166 <span className="text-sm text-red-700 font-medium">데이터 가져오기에 실패했습니다.</span>
167 </div>
168 )}
169 </div>
170 </div>
171 )}
172 </div>
173
174 {/* Footer */}
175 <div className="flex gap-3 px-6 py-4 border-t bg-gray-50">
176 {isComplete ? (
177 <>
178 <button onClick={handleReset} className="flex-1 flex items-center justify-center gap-2 px-4 py-2 border border-gray-300 rounded-sm hover:bg-gray-100 text-sm font-medium">
179 <RotateCcw className="w-4 h-4" /> 다른 파일 가져오기
180 </button>
181 <button onClick={onClose} className="flex-1 px-4 py-2 bg-emerald-600 text-white rounded-sm hover:bg-emerald-700 text-sm font-medium">닫기</button>
182 </>
183 ) : (
184 <>
185 <button onClick={onClose} className="flex-1 px-4 py-2 border border-gray-300 rounded-sm hover:bg-gray-100 text-sm font-medium">취소</button>
186 <button
187 onClick={handleImport}
188 disabled={!isParsed || isImporting}
189 className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-sm hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
190 >
191 {isImporting ? (
192 <><Loader2 className="w-4 h-4 animate-spin" /> 가져오는 중...</>
193 ) : (
194 <><Upload className="w-4 h-4" /> {isParsed ? `${parsedData.length.toLocaleString()}건 가져오기` : '가져오기'}</>
195 )}
196 </button>
197 </>
198 )}
199 </div>
200 </div>
201 </div>
202 );
203};
Step 4: barrel export 업데이트
해당 도메인의 index.ts에 export를 추가합니다:
typescript
1export { <PascalCase>ImportModal } from './<PascalCase>ImportModal';
Step 5: 부모 컴포넌트 연동 안내
가져오기 모달을 호출하는 부모 컴포넌트에서:
tsx
1// 1. state 추가
2const [isImportOpen, setIsImportOpen] = useState(false);
3
4// 2. onImport 콜백 (훅에서 mutation 함수를 가져와 사용)
5const handleImport = async (rows: <PascalCase>ImportRow[]) => {
6 // Firebase batch write or mutation
7 const batch = writeBatch(db);
8 // ... batch.set/update
9 await batch.commit();
10 return { added: rows.length, skipped: 0 };
11};
12
13// 3. 버튼 추가
14<button onClick={() => setIsImportOpen(true)}>
15 xlsx 가져오기
16</button>
17
18// 4. 모달 렌더
19<<PascalCase>ImportModal
20 isOpen={isImportOpen}
21 onClose={() => setIsImportOpen(false)}
22 onImport={handleImport}
23/>
Step 6: 검증
- 컴포넌트 파일 존재 확인
useExcelImport import 확인
- barrel export 존재 확인
- TypeScript 타입 오류 없는지 확인
markdown
1## 가져오기 모달 생성 완료
2
34|------|------|------|
5| 타입 정의 | 생성됨 | `components/<Name>/<Name>ImportModal.tsx` (상단) |
6| 모달 컴포넌트 | 생성됨 | `components/<Name>/<Name>ImportModal.tsx` |
7| barrel export | 업데이트 | `components/<Name>/index.ts` |
8| useExcelImport | 연동됨 | `hooks/useExcelImport.ts` |
9
10필드 매핑:
11- `이름` → studentName (string)
12- `학년` → grade (string)
13- ...
14
15다음 단계:
161. 부모 컴포넌트에서 모달 열기/닫기 state 추가
172. `onImport` 콜백에서 Firebase batch write 구현
183. 통계 카드와 미리보기 테이블의 컬럼을 도메인에 맞게 커스터마이즈
Design Decisions
useExcelImport 훅 사용 이유
프로젝트에 이미 7개의 가져오기 모달이 존재하며, 모두 동일한 상태 관리 패턴을 반복합니다:
fileInputRef, parsedData, fileName, isImporting, importResult state
handleFileChange (xlsx 파싱), handleImport, handleReset 핸들러
useExcelImport 훅은 이 공통 로직을 캡슐화하여:
- 새 모달 작성 시 코드량 60% 감소
- 상태 관리 버그 방지 (검증된 패턴 재사용)
- 일관된 UX (파일 선택 → 미리보기 → 가져오기 → 결과)
모달 UI 표준
모든 가져오기 모달은 동일한 4섹션 구조를 따릅니다:
- 헤더 — FileSpreadsheet 아이콘 + 도메인명 + 닫기 버튼
- 파일 업로드 — 점선 border 버튼 + hidden input
- 미리보기 — 통계 카드(grid) + 테이블(처음 15건)
- 푸터 — 완료 전: 취소/가져오기 | 완료 후: 다른 파일/닫기
색상 체계: emerald(성공), red(실패), gray(기본), blue(통계)
Exceptions
다음은 문제가 아닙니다:
- 통계 카드/테이블 컬럼이 TODO — 도메인별 커스터마이즈가 필요한 부분이므로 생성 후 수동 조정
- onImport에 Firebase 로직이 없는 것 — 가져오기 모달은 UI만 담당하고, 저장 로직은 부모/훅에서 구현
- 학생 매칭 미포함 — 학생 매칭이 필요한 경우에만
studentIds prop과 studentMatching.ts 유틸 연동