feat(WEB): 리스트 페이지 UI 레이아웃 표준화
- 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들] - beforeTableContent → headerActions + createButton 마이그레이션 - DateRangeSelector extraActions prop 활용하여 검색창 통합 - PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가 - 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리 - 건설 관련 페이지 검색 영역 정리 - 부모 메뉴 리다이렉트 컴포넌트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
520
src/lib/utils/excel-download.ts
Normal file
520
src/lib/utils/excel-download.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* 프론트엔드 엑셀 다운로드 유틸리티
|
||||
*
|
||||
* xlsx 라이브러리를 사용하여 브라우저에서 직접 엑셀 파일을 생성합니다.
|
||||
* 모든 리스트 화면에서 공통으로 사용할 수 있습니다.
|
||||
*
|
||||
* 사용 예시:
|
||||
* ```tsx
|
||||
* import { downloadExcel } from '@/lib/utils/excel-download';
|
||||
*
|
||||
* const columns = [
|
||||
* { header: '품목코드', key: 'itemCode' },
|
||||
* { header: '품목명', key: 'itemName' },
|
||||
* ];
|
||||
*
|
||||
* downloadExcel({
|
||||
* data: items,
|
||||
* columns,
|
||||
* filename: '품목목록',
|
||||
* sheetName: '품목',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
/**
|
||||
* 엑셀 컬럼 정의
|
||||
*/
|
||||
export interface ExcelColumn<T = Record<string, unknown>> {
|
||||
/** 엑셀 헤더에 표시될 이름 */
|
||||
header: string;
|
||||
/** 데이터 객체에서 가져올 키 */
|
||||
key: keyof T | string;
|
||||
/** 값 변환 함수 (선택) */
|
||||
transform?: (value: unknown, row: T) => string | number | boolean | null;
|
||||
/** 컬럼 너비 (문자 수 기준, 기본값: 15) */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드 옵션
|
||||
*/
|
||||
export interface ExcelDownloadOptions<T = Record<string, unknown>> {
|
||||
/** 다운로드할 데이터 배열 */
|
||||
data: T[];
|
||||
/** 컬럼 정의 */
|
||||
columns: ExcelColumn<T>[];
|
||||
/** 파일명 (확장자 제외, 기본값: 'export') */
|
||||
filename?: string;
|
||||
/** 시트명 (기본값: 'Sheet1') */
|
||||
sheetName?: string;
|
||||
/** 파일명에 날짜 추가 여부 (기본값: true) */
|
||||
appendDate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩 객체에서 값 추출 (예: 'vendor.name' → vendor 객체의 name 값)
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
return path.split('.').reduce((current: unknown, key: string) => {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
return (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 형식의 파일명 생성
|
||||
*/
|
||||
function generateFilename(baseName: string, appendDate: boolean): string {
|
||||
if (!appendDate) {
|
||||
return `${baseName}.xlsx`;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const timeStr = now.toTimeString().slice(0, 5).replace(/:/g, '');
|
||||
|
||||
return `${baseName}_${dateStr}_${timeStr}.xlsx`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터를 엑셀 파일로 다운로드
|
||||
*/
|
||||
export function downloadExcel<T extends Record<string, unknown>>({
|
||||
data,
|
||||
columns,
|
||||
filename = 'export',
|
||||
sheetName = 'Sheet1',
|
||||
appendDate = true,
|
||||
}: ExcelDownloadOptions<T>): void {
|
||||
if (!data || data.length === 0) {
|
||||
console.warn('[Excel] 다운로드할 데이터가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 헤더 행 생성
|
||||
const headers = columns.map((col) => col.header);
|
||||
|
||||
// 2. 데이터 행 생성
|
||||
const rows = data.map((item) => {
|
||||
return columns.map((col) => {
|
||||
// 값 추출 (중첩 객체 지원)
|
||||
const rawValue = getNestedValue(item as Record<string, unknown>, col.key as string);
|
||||
|
||||
// 변환 함수가 있으면 적용
|
||||
if (col.transform) {
|
||||
return col.transform(rawValue, item);
|
||||
}
|
||||
|
||||
// 기본 값 처리
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// boolean 처리
|
||||
if (typeof rawValue === 'boolean') {
|
||||
return rawValue ? 'Y' : 'N';
|
||||
}
|
||||
|
||||
// 배열 처리
|
||||
if (Array.isArray(rawValue)) {
|
||||
return rawValue.join(', ');
|
||||
}
|
||||
|
||||
// 객체 처리 (JSON 문자열로)
|
||||
if (typeof rawValue === 'object') {
|
||||
return JSON.stringify(rawValue);
|
||||
}
|
||||
|
||||
return rawValue;
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 워크시트 생성
|
||||
const wsData = [headers, ...rows];
|
||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||
|
||||
// 4. 컬럼 너비 설정
|
||||
const colWidths = columns.map((col) => ({
|
||||
wch: col.width || Math.max(15, col.header.length * 2),
|
||||
}));
|
||||
ws['!cols'] = colWidths;
|
||||
|
||||
// 5. 워크북 생성
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
|
||||
// 6. 파일 다운로드
|
||||
const finalFilename = generateFilename(filename, appendDate);
|
||||
XLSX.writeFile(wb, finalFilename);
|
||||
|
||||
console.log(`[Excel] 다운로드 완료: ${finalFilename} (${data.length}건)`);
|
||||
} catch (error) {
|
||||
console.error('[Excel] 다운로드 실패:', error);
|
||||
throw new Error('엑셀 파일 생성에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 항목만 엑셀로 다운로드
|
||||
*/
|
||||
export function downloadSelectedExcel<T extends Record<string, unknown>>({
|
||||
data,
|
||||
selectedIds,
|
||||
idField = 'id',
|
||||
...options
|
||||
}: ExcelDownloadOptions<T> & {
|
||||
selectedIds: string[];
|
||||
idField?: keyof T | string;
|
||||
}): void {
|
||||
const selectedData = data.filter((item) => {
|
||||
const id = getNestedValue(item as Record<string, unknown>, idField as string);
|
||||
return selectedIds.includes(String(id));
|
||||
});
|
||||
|
||||
if (selectedData.length === 0) {
|
||||
console.warn('[Excel] 선택된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
downloadExcel({
|
||||
...options,
|
||||
data: selectedData,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 엑셀 템플릿(양식) 다운로드
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 템플릿 컬럼 정의 (업로드용)
|
||||
*/
|
||||
export interface TemplateColumn {
|
||||
/** 엑셀 헤더에 표시될 이름 */
|
||||
header: string;
|
||||
/** 데이터 키 (업로드 시 매핑용) */
|
||||
key: string;
|
||||
/** 필수 여부 */
|
||||
required?: boolean;
|
||||
/** 데이터 타입 설명 */
|
||||
type?: 'text' | 'number' | 'date' | 'boolean' | 'select';
|
||||
/** 선택 옵션 (type이 'select'일 때) */
|
||||
options?: string[];
|
||||
/** 안내 문구 (예: "YYYY-MM-DD 형식") */
|
||||
description?: string;
|
||||
/** 샘플 값 */
|
||||
sampleValue?: string | number | boolean;
|
||||
/** 컬럼 너비 */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 다운로드 옵션
|
||||
*/
|
||||
export interface TemplateDownloadOptions {
|
||||
/** 컬럼 정의 */
|
||||
columns: TemplateColumn[];
|
||||
/** 파일명 (확장자 제외) */
|
||||
filename?: string;
|
||||
/** 시트명 */
|
||||
sheetName?: string;
|
||||
/** 샘플 데이터 행 포함 여부 (기본값: true) */
|
||||
includeSampleRow?: boolean;
|
||||
/** 안내 행 포함 여부 (기본값: true) */
|
||||
includeGuideRow?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업로드용 엑셀 템플릿(양식) 다운로드
|
||||
*
|
||||
* 사용 예시:
|
||||
* ```tsx
|
||||
* downloadExcelTemplate({
|
||||
* columns: [
|
||||
* { header: '품목코드', key: 'itemCode', required: true, type: 'text', sampleValue: 'KD-FG-001' },
|
||||
* { header: '품목명', key: 'itemName', required: true, type: 'text', sampleValue: '스크린도어' },
|
||||
* { header: '단위', key: 'unit', type: 'select', options: ['EA', 'SET', 'KG'], sampleValue: 'EA' },
|
||||
* ],
|
||||
* filename: '품목등록_양식',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function downloadExcelTemplate({
|
||||
columns,
|
||||
filename = '업로드_양식',
|
||||
sheetName = 'Sheet1',
|
||||
includeSampleRow = true,
|
||||
includeGuideRow = true,
|
||||
}: TemplateDownloadOptions): void {
|
||||
try {
|
||||
const wsData: (string | number | boolean)[][] = [];
|
||||
|
||||
// 1. 헤더 행 (필수 표시 포함)
|
||||
const headers = columns.map((col) => {
|
||||
return col.required ? `${col.header} *` : col.header;
|
||||
});
|
||||
wsData.push(headers);
|
||||
|
||||
// 2. 안내 행 (데이터 타입, 옵션 등)
|
||||
if (includeGuideRow) {
|
||||
const guideRow = columns.map((col) => {
|
||||
const parts: string[] = [];
|
||||
|
||||
// 타입 표시
|
||||
if (col.type === 'select' && col.options) {
|
||||
parts.push(`[${col.options.join('/')}]`);
|
||||
} else if (col.type === 'date') {
|
||||
parts.push('[YYYY-MM-DD]');
|
||||
} else if (col.type === 'number') {
|
||||
parts.push('[숫자]');
|
||||
} else if (col.type === 'boolean') {
|
||||
parts.push('[Y/N]');
|
||||
}
|
||||
|
||||
// 추가 설명
|
||||
if (col.description) {
|
||||
parts.push(col.description);
|
||||
}
|
||||
|
||||
return parts.join(' ') || '';
|
||||
});
|
||||
wsData.push(guideRow);
|
||||
}
|
||||
|
||||
// 3. 샘플 데이터 행
|
||||
if (includeSampleRow) {
|
||||
const sampleRow = columns.map((col) => {
|
||||
if (col.sampleValue !== undefined) {
|
||||
return col.sampleValue;
|
||||
}
|
||||
// 기본 샘플 값
|
||||
if (col.type === 'select' && col.options?.[0]) {
|
||||
return col.options[0];
|
||||
}
|
||||
if (col.type === 'date') {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
if (col.type === 'number') {
|
||||
return 0;
|
||||
}
|
||||
if (col.type === 'boolean') {
|
||||
return 'Y';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
wsData.push(sampleRow);
|
||||
}
|
||||
|
||||
// 4. 워크시트 생성
|
||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||
|
||||
// 5. 컬럼 너비 설정
|
||||
const colWidths = columns.map((col) => ({
|
||||
wch: col.width || Math.max(15, (col.header.length + (col.required ? 2 : 0)) * 2),
|
||||
}));
|
||||
ws['!cols'] = colWidths;
|
||||
|
||||
// 6. 헤더 스타일 (볼드) - xlsx 라이브러리 기본 기능으로는 제한적
|
||||
// 추후 xlsx-style 라이브러리로 확장 가능
|
||||
|
||||
// 7. 워크북 생성 및 다운로드
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
|
||||
const finalFilename = `${filename}.xlsx`;
|
||||
XLSX.writeFile(wb, finalFilename);
|
||||
|
||||
console.log(`[Excel] 템플릿 다운로드 완료: ${finalFilename}`);
|
||||
} catch (error) {
|
||||
console.error('[Excel] 템플릿 다운로드 실패:', error);
|
||||
throw new Error('엑셀 템플릿 생성에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 엑셀 업로드 (파싱)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* 엑셀 파일 파싱 결과
|
||||
*/
|
||||
export interface ExcelParseResult<T = Record<string, unknown>> {
|
||||
/** 파싱 성공 여부 */
|
||||
success: boolean;
|
||||
/** 파싱된 데이터 */
|
||||
data: T[];
|
||||
/** 에러 목록 (행별) */
|
||||
errors: Array<{
|
||||
row: number;
|
||||
column?: string;
|
||||
message: string;
|
||||
}>;
|
||||
/** 전체 행 수 */
|
||||
totalRows: number;
|
||||
/** 유효한 행 수 */
|
||||
validRows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 파일을 파싱하여 데이터 배열로 변환
|
||||
*
|
||||
* 사용 예시:
|
||||
* ```tsx
|
||||
* const result = await parseExcelFile<ItemMaster>(file, {
|
||||
* columns: [
|
||||
* { header: '품목코드', key: 'itemCode', required: true },
|
||||
* { header: '품목명', key: 'itemName', required: true },
|
||||
* ],
|
||||
* skipRows: 2, // 헤더 + 안내 행 스킵
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function parseExcelFile<T = Record<string, unknown>>(
|
||||
file: File,
|
||||
options: {
|
||||
columns: TemplateColumn[];
|
||||
/** 스킵할 행 수 (헤더, 안내 행 등) */
|
||||
skipRows?: number;
|
||||
/** 시트 인덱스 (기본값: 0) */
|
||||
sheetIndex?: number;
|
||||
}
|
||||
): Promise<ExcelParseResult<T>> {
|
||||
const { columns, skipRows = 1, sheetIndex = 0 } = options;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target?.result as ArrayBuffer);
|
||||
const workbook = XLSX.read(data, { type: 'array' });
|
||||
|
||||
// 시트 선택
|
||||
const sheetName = workbook.SheetNames[sheetIndex];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
|
||||
// JSON으로 변환
|
||||
const jsonData = XLSX.utils.sheet_to_json<Record<string, unknown>>(worksheet, {
|
||||
header: 1, // 배열로 반환
|
||||
defval: '', // 빈 셀 기본값
|
||||
}) as unknown[][];
|
||||
|
||||
// 헤더 행에서 컬럼 인덱스 매핑
|
||||
const headerRow = jsonData[0] as string[];
|
||||
const columnIndexMap = new Map<string, number>();
|
||||
|
||||
columns.forEach((col) => {
|
||||
// 필수 표시(*)가 있을 수 있으므로 정규화
|
||||
const headerIndex = headerRow.findIndex(
|
||||
(h) => h?.toString().replace(' *', '').trim() === col.header
|
||||
);
|
||||
if (headerIndex !== -1) {
|
||||
columnIndexMap.set(col.key, headerIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터 행 파싱
|
||||
const parsedData: T[] = [];
|
||||
const errors: ExcelParseResult['errors'] = [];
|
||||
const dataRows = jsonData.slice(skipRows);
|
||||
|
||||
dataRows.forEach((row, rowIndex) => {
|
||||
const rowNumber = rowIndex + skipRows + 1;
|
||||
const rowData: Record<string, unknown> = {};
|
||||
let hasError = false;
|
||||
|
||||
columns.forEach((col) => {
|
||||
const colIndex = columnIndexMap.get(col.key);
|
||||
if (colIndex === undefined) return;
|
||||
|
||||
const rawValue = (row as unknown[])[colIndex];
|
||||
const value = rawValue?.toString().trim() || '';
|
||||
|
||||
// 필수 검사
|
||||
if (col.required && !value) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
column: col.header,
|
||||
message: `${col.header}은(는) 필수입니다`,
|
||||
});
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
// 타입 검사
|
||||
if (value) {
|
||||
if (col.type === 'number' && isNaN(Number(value))) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
column: col.header,
|
||||
message: `${col.header}은(는) 숫자여야 합니다`,
|
||||
});
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (col.type === 'select' && col.options && !col.options.includes(value)) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
column: col.header,
|
||||
message: `${col.header}은(는) [${col.options.join(', ')}] 중 하나여야 합니다`,
|
||||
});
|
||||
hasError = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 값 변환
|
||||
if (col.type === 'number' && value) {
|
||||
rowData[col.key] = Number(value);
|
||||
} else if (col.type === 'boolean') {
|
||||
rowData[col.key] = value.toUpperCase() === 'Y' || value === 'true';
|
||||
} else {
|
||||
rowData[col.key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// 빈 행 스킵
|
||||
const hasData = Object.values(rowData).some((v) => v !== '' && v !== undefined);
|
||||
if (hasData) {
|
||||
parsedData.push(rowData as T);
|
||||
}
|
||||
});
|
||||
|
||||
resolve({
|
||||
success: errors.length === 0,
|
||||
data: parsedData,
|
||||
errors,
|
||||
totalRows: dataRows.length,
|
||||
validRows: parsedData.length - errors.filter((e, i, arr) =>
|
||||
arr.findIndex((x) => x.row === e.row) === i
|
||||
).length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Excel] 파싱 실패:', error);
|
||||
resolve({
|
||||
success: false,
|
||||
data: [],
|
||||
errors: [{ row: 0, message: '파일 형식이 올바르지 않습니다.' }],
|
||||
totalRows: 0,
|
||||
validRows: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
resolve({
|
||||
success: false,
|
||||
data: [],
|
||||
errors: [{ row: 0, message: '파일을 읽는데 실패했습니다.' }],
|
||||
totalRows: 0,
|
||||
validRows: 0,
|
||||
});
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user