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:
유병철
2026-01-26 22:04:36 +09:00
parent ff93ab7fa2
commit 1f6b592b9f
65 changed files with 1974 additions and 503 deletions

View 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);
});
}