// 범용 엑셀(CSV) 내보내기. 클라이언트에서 Blob 다운로드 — 의존성 없음. // 한글이 Excel에서 깨지지 않도록 UTF-8 BOM을 붙인다. 여러 페이지에서 재사용. // 진짜 .xlsx(서식/다중시트)가 필요해지면 이 함수 시그니처 유지한 채 SheetJS로 교체 가능. export type ExcelColumn = { header: string; value: (row: T) => string | number | null | undefined; }; // 일괄(엑셀) 등록 중 서버가 거부한 행. code 로 원본 행과 매칭해 사유를 표시한다. export type BulkFailure = { code: string; message: string }; function escapeCell(v: string | number | null | undefined): string { const s = v == null ? '' : String(v); // 쉼표/따옴표/줄바꿈 포함 시 따옴표로 감싸고 내부 따옴표는 두 개로 이스케이프 return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } // 파일명용 오늘 날짜(YYYYMMDD, 로컬 기준). 내보내기 파일명에 붙인다. export function todayStamp(): string { const d = new Date(); const p = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`; } export function downloadExcel(filename: string, columns: ExcelColumn[], rows: T[]): void { const header = columns.map((c) => escapeCell(c.header)).join(','); const body = rows .map((row) => columns.map((c) => escapeCell(c.value(row))).join(',')) .join('\r\n'); const csv = '' + header + '\r\n' + body; //  = UTF-8 BOM (Excel 한글 인코딩) const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`; a.click(); URL.revokeObjectURL(url); } // CSV 토크나이저 — 따옴표 셀("..."), 이스케이프(""), CRLF/LF, 선두 BOM 처리. function tokenizeCsv(text: string): string[][] { const t = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; // BOM 제거 const rows: string[][] = []; let row: string[] = []; let cell = ''; let inQuotes = false; for (let i = 0; i < t.length; i++) { const ch = t[i]; if (inQuotes) { if (ch === '"') { if (t[i + 1] === '"') { cell += '"'; i++; } // 이스케이프된 따옴표 else inQuotes = false; } else cell += ch; } else if (ch === '"') inQuotes = true; else if (ch === ',') { row.push(cell); cell = ''; } else if (ch === '\n') { row.push(cell); rows.push(row); row = []; cell = ''; } else if (ch !== '\r') cell += ch; } if (cell !== '' || row.length > 0) { row.push(cell); rows.push(row); } return rows; } // CSV 텍스트 → 헤더 키로 매핑된 객체 배열. 첫 행을 헤더로 본다. 여러 페이지 업로드에서 재사용. export function parseCsv(text: string): Record[] { const rows = tokenizeCsv(text); if (rows.length === 0) return []; const headers = rows[0].map((h) => h.trim()); return rows .slice(1) .filter((cells) => cells.some((c) => c.trim() !== '')) // 빈 줄 제외 .map((cells) => Object.fromEntries(headers.map((h, i) => [h, (cells[i] ?? '').trim()]))); }