<< All versions
Skill v1.0.1
currentAutomated scan100/100majiayu000/claude-skill-registry/generate-import-modal
1 files
──Details
PublishedMay 15, 2026 at 05:12 AM
Content Hashsha256:76c03fbacabe5912...
Git SHA4a67e6f2e6a1
Bump Typepatch
──Files
Files (1 file, 14.4 KB)
SKILL.md14.4 KBactive
SKILL.md · 370 lines · 14.4 KB
version: "1.0.1" 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: 타입 정의 생성
파싱된 행의 인터페이스를 모달 파일 상단에 정의합니다:
typescript
export interface <PascalCase>ImportRow {// 사용자가 지정한 필드들studentName: string;grade: string;// ...// 학생 매칭이 필요한 경우:matched: boolean;studentId?: string;}
Step 3: 모달 컴포넌트 생성
파일: components/<PascalCase>/<PascalCase>ImportModal.tsx
프로젝트 표준 구조:
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"><buttononClick={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><buttononClick={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를 추가합니다:
typescript
export { <PascalCase>ImportModal } from './<PascalCase>ImportModal';
Step 5: 부모 컴포넌트 연동 안내
가져오기 모달을 호출하는 부모 컴포넌트에서:
tsx
// 1. state 추가const [isImportOpen, setIsImportOpen] = useState(false);// 2. onImport 콜백 (훅에서 mutation 함수를 가져와 사용)const handleImport = async (rows: <PascalCase>ImportRow[]) => {// Firebase batch write or mutationconst batch = writeBatch(db);// ... batch.set/updateawait batch.commit();return { added: rows.length, skipped: 0 };};// 3. 버튼 추가<button onClick={() => setIsImportOpen(true)}>xlsx 가져오기</button>// 4. 모달 렌더<<PascalCase>ImportModalisOpen={isImportOpen}onClose={() => setIsImportOpen(false)}onImport={handleImport}/>
Step 6: 검증
- 컴포넌트 파일 존재 확인
useExcelImportimport 확인- barrel export 존재 확인
- TypeScript 타입 오류 없는지 확인
Output Format
markdown
## 가져오기 모달 생성 완료| 항목 | 상태 | 파일 ||------|------|------|| 타입 정의 | 생성됨 | `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유틸 연동