name: generate-import-modal description: Excel 가져오기 모달의 보일러플레이트를 자동 생성합니다. useExcelImport 훅 기반. 새 가져오기 기능 추가 시 사용. argument-hint: "[도메인 이름 (예: 'attendance', 'homework')]"
Purpose
새로운 Excel 가져오기 모달을 프로젝트 표준 패턴에 맞춰 자동 생성합니다:
- 모달 컴포넌트 생성 —
useExcelImport훅을 사용하는 가져오기 모달 - 파서 함수 생성 — xlsx 필드 매핑 및 정규화 로직
- 타입 정의 — 파싱된 행의 TypeScript 인터페이스
- 부모 컴포넌트 연동 안내 — 모달 열기/닫기 및 onImport 콜백 연결
When to Run
- 새 도메인에 Excel 가져오기 기능을 추가할 때
- 기존 가져오기 모달을
useExcelImport훅 기반으로 리팩토링할 때
Related Files
| 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: 타입 정의 생성
파싱된 행의 인터페이스를 모달 파일 상단에 정의합니다:
export interface <PascalCase>ImportRow {
// 사용자가 지정한 필드들
studentName: string;
grade: string;
// ...
// 학생 매칭이 필요한 경우:
matched: boolean;
studentId?: string;
}
Step 3: 모달 컴포넌트 생성
파일: components/<PascalCase>/<PascalCase>ImportModal.tsx
프로젝트 표준 구조:
import React, { useMemo } from 'react';
import { X, Upload, FileSpreadsheet, Loader2, AlertCircle, Check, RotateCcw, Eye } from 'lucide-react';
import { useExcelImport } from '../../hooks/useExcelImport';
// 1. 타입 정의
export interface <PascalCase>ImportRow {
// ...fields
}
// 2. Props 정의
interface <PascalCase>ImportModalProps {
isOpen: boolean;
onClose: () => void;
onImport: (rows: <PascalCase>ImportRow[]) => Promise<{ added: number; skipped: number }>;
// 학생 매칭이 필요한 경우:
// studentIds?: Set<string>;
}
// 3. 파서 함수 (또는 컴포넌트 내부 useCallback)
function parse<PascalCase>Rows(rows: Record<string, any>[]): <PascalCase>ImportRow[] {
return rows
// .filter(row => ...) // 필터 조건이 있는 경우
.map(row => ({
// 필드 매핑
}));
}
// 4. 컴포넌트
export const <PascalCase>ImportModal: React.FC<<PascalCase>ImportModalProps> = ({
isOpen,
onClose,
onImport,
}) => {
const {
fileInputRef,
parsedData,
fileName,
isImporting,
importResult,
handleFileChange,
handleImport,
handleReset,
openFileDialog,
isParsed,
isComplete,
} = useExcelImport({
parser: parse<PascalCase>Rows,
onImport,
});
// 통계 요약 (useMemo)
const summary = useMemo(() => {
if (!isParsed) return null;
return {
totalRecords: parsedData.length,
// ... 도메인별 통계
};
}, [parsedData, isParsed]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-start justify-center pt-[8vh] z-[100]" onClick={onClose}>
<div className="bg-white rounded-sm w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<h2 className="text-sm font-bold text-primary flex items-center gap-2">
<FileSpreadsheet size={18} className="text-emerald-600" />
<도메인명> 데이터 가져오기
</h2>
<button onClick={onClose} className="p-1 rounded-sm hover:bg-gray-100 text-gray-400 hover:text-gray-600">
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-6 space-y-2">
{/* 파일 업로드 섹션 */}
<div className="bg-white border border-gray-200 overflow-hidden">
<div className="flex items-center gap-1 px-2 py-1.5 bg-gray-50 border-b border-gray-200">
<Upload className="w-3 h-3 text-primary" />
<h3 className="text-primary font-bold text-xs">파일 업로드</h3>
</div>
<div className="p-3">
<input ref={fileInputRef} type="file" accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" />
<div className="flex items-center gap-3">
<button
onClick={openFileDialog}
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"
>
<FileSpreadsheet className="w-4 h-4" />
Excel 파일 선택
</button>
{fileName && <span className="text-sm text-gray-600 font-medium">{fileName}</span>}
</div>
</div>
</div>
{/* 데이터 미리보기 섹션 */}
{summary && (
<div className="bg-white border border-gray-200 overflow-hidden">
<div className="flex items-center gap-1 px-2 py-1.5 bg-gray-50 border-b border-gray-200">
<Eye className="w-3 h-3 text-primary" />
<h3 className="text-primary font-bold text-xs">데이터 미리보기</h3>
</div>
<div className="p-3 space-y-3">
{/* 통계 카드 */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-sm">
{/* ... 도메인별 통계 카드 */}
</div>
{/* 미리보기 테이블 */}
<div className="border border-gray-200 rounded-sm overflow-hidden">
<div className="bg-gray-50 px-2 py-1 border-b border-gray-200">
<h4 className="text-xs font-medium text-gray-600">처음 15건 미리보기</h4>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>{/* 도메인별 컬럼 헤더 */}</tr>
</thead>
<tbody className="divide-y divide-gray-100 bg-white">
{parsedData.slice(0, 15).map((row, i) => (
<tr key={i} className="hover:bg-gray-50">
{/* 도메인별 컬럼 데이터 */}
</tr>
))}
</tbody>
</table>
</div>
{parsedData.length > 15 && (
<div className="bg-gray-50 px-2 py-1 border-t border-gray-200">
<p className="text-xxs text-gray-400 text-center">... 외 {parsedData.length - 15}건 더 있음</p>
</div>
)}
</div>
</div>
</div>
)}
{/* 결과 섹션 */}
{importResult && (
<div className="bg-white border border-gray-200 overflow-hidden">
<div className="flex items-center gap-1 px-2 py-1.5 bg-gray-50 border-b border-gray-200">
{importResult.success
? <Check className="w-3 h-3 text-emerald-600" />
: <AlertCircle className="w-3 h-3 text-red-600" />}
<h3 className={`font-bold text-xs ${importResult.success ? 'text-emerald-700' : 'text-red-700'}`}>
{importResult.success ? '가져오기 완료' : '가져오기 실패'}
</h3>
</div>
<div className={`p-3 ${importResult.success ? 'bg-emerald-50' : 'bg-red-50'}`}>
{importResult.success ? (
<div className="flex items-center gap-2">
<Check className="w-5 h-5 text-emerald-600 shrink-0" />
<div className="text-sm text-emerald-700 font-medium">
{importResult.added.toLocaleString()}건 추가 완료
{importResult.skipped > 0 && (
<span className="text-gray-500 font-normal ml-1">(중복 {importResult.skipped.toLocaleString()}건 건너뜀)</span>
)}
</div>
</div>
) : (
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
<span className="text-sm text-red-700 font-medium">데이터 가져오기에 실패했습니다.</span>
</div>
)}
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-3 px-6 py-4 border-t bg-gray-50">
{isComplete ? (
<>
<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">
<RotateCcw className="w-4 h-4" /> 다른 파일 가져오기
</button>
<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>
</>
) : (
<>
<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>
<button
onClick={handleImport}
disabled={!isParsed || isImporting}
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"
>
{isImporting ? (
<><Loader2 className="w-4 h-4 animate-spin" /> 가져오는 중...</>
) : (
<><Upload className="w-4 h-4" /> {isParsed ? `${parsedData.length.toLocaleString()}건 가져오기` : '가져오기'}</>
)}
</button>
</>
)}
</div>
</div>
</div>
);
};
Step 4: barrel export 업데이트
해당 도메인의 index.ts에 export를 추가합니다:
export { <PascalCase>ImportModal } from './<PascalCase>ImportModal';
Step 5: 부모 컴포넌트 연동 안내
가져오기 모달을 호출하는 부모 컴포넌트에서:
// 1. state 추가
const [isImportOpen, setIsImportOpen] = useState(false);
// 2. onImport 콜백 (훅에서 mutation 함수를 가져와 사용)
const handleImport = async (rows: <PascalCase>ImportRow[]) => {
// Firebase batch write or mutation
const batch = writeBatch(db);
// ... batch.set/update
await batch.commit();
return { added: rows.length, skipped: 0 };
};
// 3. 버튼 추가
<button onClick={() => setIsImportOpen(true)}>
xlsx 가져오기
</button>
// 4. 모달 렌더
<<PascalCase>ImportModal
isOpen={isImportOpen}
onClose={() => setIsImportOpen(false)}
onImport={handleImport}
/>
Step 6: 검증
- 컴포넌트 파일 존재 확인
useExcelImportimport 확인- barrel export 존재 확인
- TypeScript 타입 오류 없는지 확인
Output Format
## 가져오기 모달 생성 완료
| 항목 | 상태 | 파일 |
|------|------|------|
| 타입 정의 | 생성됨 | `components/<Name>/<Name>ImportModal.tsx` (상단) |
| 모달 컴포넌트 | 생성됨 | `components/<Name>/<Name>ImportModal.tsx` |
| barrel export | 업데이트 | `components/<Name>/index.ts` |
| useExcelImport | 연동됨 | `hooks/useExcelImport.ts` |
필드 매핑:
- `이름` → studentName (string)
- `학년` → grade (string)
- ...
다음 단계:
1. 부모 컴포넌트에서 모달 열기/닫기 state 추가
2. `onImport` 콜백에서 Firebase batch write 구현
3. 통계 카드와 미리보기 테이블의 컬럼을 도메인에 맞게 커스터마이즈
Design Decisions
useExcelImport 훅 사용 이유
프로젝트에 이미 7개의 가져오기 모달이 존재하며, 모두 동일한 상태 관리 패턴을 반복합니다:
fileInputRef,parsedData,fileName,isImporting,importResultstatehandleFileChange(xlsx 파싱),handleImport,handleReset핸들러
useExcelImport 훅은 이 공통 로직을 캡슐화하여:
- 새 모달 작성 시 코드량 60% 감소
- 상태 관리 버그 방지 (검증된 패턴 재사용)
- 일관된 UX (파일 선택 → 미리보기 → 가져오기 → 결과)
모달 UI 표준
모든 가져오기 모달은 동일한 4섹션 구조를 따릅니다:
- 헤더 — FileSpreadsheet 아이콘 + 도메인명 + 닫기 버튼
- 파일 업로드 — 점선 border 버튼 + hidden input
- 미리보기 — 통계 카드(grid) + 테이블(처음 15건)
- 푸터 — 완료 전: 취소/가져오기 | 완료 후: 다른 파일/닫기
색상 체계: emerald(성공), red(실패), gray(기본), blue(통계)
Exceptions
다음은 문제가 아닙니다:
- 통계 카드/테이블 컬럼이 TODO — 도메인별 커스터마이즈가 필요한 부분이므로 생성 후 수동 조정
- onImport에 Firebase 로직이 없는 것 — 가져오기 모달은 UI만 담당하고, 저장 로직은 부모/훅에서 구현
- 학생 매칭 미포함 — 학생 매칭이 필요한 경우에만
studentIdsprop과studentMatching.ts유틸 연동