- 엑셀 데이터 내보내기(상품·협력사·협상카드): 현재 검색/페이지 필터를 무시하고 전체를 페이지 순회로 받아(fetchAllForExport, 무언 절삭 방지) 양식과 동일 헤더로 CSV 내보내기(downloadXxxData) + '데이터 다운로드' 메뉴·todayStamp 파일명 → 내려받아 수정 후 재업로드(라운드트립) - 견적상세 ChatTab: 사용 협상카드 매칭을 chat_id→card_id(카드 PK)로 교정, 카드 사용 메시지 본문 script 중복 제거(카드 박스에만 노출), 카드칩→/cards?detail= 상세 링크, 카드 멘트 실가격 마스킹(target_price 변수 미주입+maskPrices) - 견적상세 협상카드 탭: 번호·스크립트 미리보기 컬럼 추가(cardScriptPreview 평문 추출)·카드명 상세 링크 - 상세 드로어: 백드롭 좌상단 '뒤로'(navigate(-1)) 버튼 추가(데스크톱), 공용 Sheet·견적상세 동일 적용 - 협상요약(negosium): 배송 리드타임 라벨을 회사설정(labels.lead_time) 연동 → '표준납기' 표기
76 lines
3.2 KiB
TypeScript
76 lines
3.2 KiB
TypeScript
// 범용 엑셀(CSV) 내보내기. 클라이언트에서 Blob 다운로드 — 의존성 없음.
|
||
// 한글이 Excel에서 깨지지 않도록 UTF-8 BOM을 붙인다. 여러 페이지에서 재사용.
|
||
// 진짜 .xlsx(서식/다중시트)가 필요해지면 이 함수 시그니처 유지한 채 SheetJS로 교체 가능.
|
||
|
||
export type ExcelColumn<T> = {
|
||
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<T>(filename: string, columns: ExcelColumn<T>[], 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<string, string>[] {
|
||
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()])));
|
||
}
|