- {/* 날짜 범위 선택 (Input type="date") */}
- {!hideDateInputs && (
-
- onStartDateChange(e.target.value)}
- className="w-[165px]"
- />
- ~
- onEndDateChange(e.target.value)}
- className="w-[165px]"
- />
-
- )}
-
- {/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
- {!hidePresets && presets.length > 0 && (
-
-
- {presets.map((preset) => (
-
- ))}
-
-
- )}
+ // 프리셋 버튼 렌더링
+ const renderPresets = () => {
+ if (hidePresets || presets.length === 0) return null;
+ return (
+
+
+ {presets.map((preset) => (
+
+ ))}
+
+ );
+ };
- {/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
- {extraActions && (
-
+ // presetsPosition이 'below'일 때: 달력+extraActions 같은 줄, 프리셋은 아래 줄
+ if (presetsPosition === 'below') {
+ return (
+
+ {/* 1줄: 날짜 + extraActions */}
+
+
+ {/* 2줄: 프리셋 버튼들 */}
+ {renderPresets()}
+
+ );
+ }
+
+ // presetsPosition이 'inline' (기본값)
+ // PC(1280px+): 달력 | 프리셋버튼 | 검색창 (한 줄)
+ // 태블릿: 달력 / 프리셋버튼 / 검색창 (세 줄)
+ return (
+
);
}
diff --git a/src/components/organisms/StatCards.tsx b/src/components/organisms/StatCards.tsx
index 6732b08b..216c987f 100644
--- a/src/components/organisms/StatCards.tsx
+++ b/src/components/organisms/StatCards.tsx
@@ -22,7 +22,7 @@ interface StatCardsProps {
export function StatCards({ stats }: StatCardsProps) {
return (
-
+
{stats.map((stat, index) => {
const Icon = stat.icon;
const isClickable = !!stat.onClick;
@@ -37,24 +37,24 @@ export function StatCards({ stats }: StatCardsProps) {
}`}
onClick={stat.onClick}
>
-
+
-
-
+
+
{stat.label}
-
+
{stat.value}
{stat.trend && (
-
+
{stat.trend.value}
)}
{Icon && (
)}
diff --git a/src/components/pricing/PricingListClient.tsx b/src/components/pricing/PricingListClient.tsx
index 1100900c..5fc2b29d 100644
--- a/src/components/pricing/PricingListClient.tsx
+++ b/src/components/pricing/PricingListClient.tsx
@@ -223,9 +223,22 @@ export function PricingListClient({
) => {
const { isSelected, onToggle } = handlers;
+ // 행 클릭 핸들러: 등록되지 않은 항목은 등록, 등록된 항목은 수정
+ const handleRowClick = () => {
+ if (item.status === 'not_registered') {
+ handleRegister(item);
+ } else {
+ handleEdit(item);
+ }
+ };
+
return (
-
-
+
+ e.stopPropagation()}>
(null);
+ // 날짜 범위 상태
+ const [startDate, setStartDate] = useState('2025-01-01');
+ const [endDate, setEndDate] = useState('2025-12-31');
+
+ // 검색어 상태
+ const [searchQuery, setSearchQuery] = useState('');
+
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
setIsLoading(true);
@@ -249,36 +256,48 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
clientSideFiltering: true,
itemsPerPage: 20,
- // 탭 필터 함수
- tabFilter: (item: Process, activeTab: string) => {
- if (activeTab === 'all') return true;
- return item.status === activeTab;
+ // 검색창 (공통 컴포넌트에서 자동 생성)
+ hideSearch: true,
+ searchValue: searchQuery,
+ onSearchChange: setSearchQuery,
+
+ // 날짜 범위 선택기
+ dateRangeSelector: {
+ enabled: true,
+ showPresets: true,
+ startDate,
+ endDate,
+ onStartDateChange: setStartDate,
+ onEndDateChange: setEndDate,
},
- // 검색 필터 함수
- searchFilter: (item: Process, searchValue: string) => {
- const search = searchValue.toLowerCase();
+ // 탭 필터 (공통 컴포넌트에서 처리)
+ tabFilter: (item, tabValue) => {
+ if (tabValue === 'all') return true;
+ return item.status === tabValue;
+ },
+
+ // 검색 필터
+ searchFilter: (item, searchValue) => {
+ if (!searchValue || !searchValue.trim()) return true;
+ const search = searchValue.toLowerCase().trim();
return (
- item.processCode.toLowerCase().includes(search) ||
- item.processName.toLowerCase().includes(search) ||
- item.department.toLowerCase().includes(search)
+ (item.processCode || '').toLowerCase().includes(search) ||
+ (item.processName || '').toLowerCase().includes(search) ||
+ (item.department || '').toLowerCase().includes(search)
);
},
- // 탭 설정
+ // 탭 (공통 컴포넌트에서 Card 안에 렌더링)
tabs,
defaultTab: 'all',
- // 검색
- searchPlaceholder: '공정코드, 공정명, 담당부서 검색',
-
- // 헤더 액션
- headerActions: () => (
-
- ),
+ // 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링)
+ createButton: {
+ label: '공정 등록',
+ onClick: handleCreate,
+ icon: Plus,
+ },
// 일괄 삭제 핸들러
onBulkDelete: handleBulkDelete,
@@ -448,12 +467,12 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
);
},
}),
- [tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete]
+ [tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery]
);
return (
<>
-
+
{/* 삭제 확인 다이얼로그 */}
{
dateRangeSelector?: {
enabled: boolean;
showPresets?: boolean;
+ /** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
+ hideDateInputs?: boolean;
startDate?: string;
endDate?: string;
onStartDateChange?: (date: string) => void;
onEndDateChange?: (date: string) => void;
+ /** 추가 액션 (검색창 등) - 프리셋 버튼 옆에 배치 */
+ extraActions?: ReactNode;
};
/**
* 등록 버튼 (오른쪽 끝 배치)
@@ -237,7 +242,7 @@ export function IntegratedListTemplateV2({
onSearchChange,
searchPlaceholder = "검색...",
extraFilters,
- hideSearch = false,
+ hideSearch = true, // 기본값: 타이틀 아래에 검색창 표시 (Card 안 SearchFilter 숨김)
tabs,
activeTab,
onTabChange,
@@ -536,32 +541,71 @@ export function IntegratedListTemplateV2({
/>
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
- {/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
- {(dateRangeSelector?.enabled || createButton || headerActions) && (
+ {/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */}
+ {(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && (
isLoading ? renderHeaderActionSkeleton() : (
-
- {/* 날짜 범위 선택기 (왼쪽) */}
- {dateRangeSelector?.enabled && (
+
)
diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx
index ef8451dd..c513c9a5 100644
--- a/src/components/templates/UniversalListPage/index.tsx
+++ b/src/components/templates/UniversalListPage/index.tsx
@@ -578,7 +578,7 @@ export function UniversalListPage
({
return (
<>
-
+ {
dateRangeSelector?: {
enabled: boolean;
showPresets?: boolean;
+ /** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
+ hideDateInputs?: boolean;
+ /** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
+ presetsPosition?: 'inline' | 'below';
startDate?: string;
endDate?: string;
onStartDateChange?: (date: string) => void;
onEndDateChange?: (date: string) => void;
+ /** 추가 액션 (검색창 등) - presetsPosition이 'below'일 때 달력 옆에 배치됨 */
+ extraActions?: ReactNode;
};
/**
* 등록 버튼 (오른쪽 끝 배치)
diff --git a/src/lib/utils/excel-download.ts b/src/lib/utils/excel-download.ts
new file mode 100644
index 00000000..1b5df27b
--- /dev/null
+++ b/src/lib/utils/excel-download.ts
@@ -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> {
+ /** 엑셀 헤더에 표시될 이름 */
+ header: string;
+ /** 데이터 객체에서 가져올 키 */
+ key: keyof T | string;
+ /** 값 변환 함수 (선택) */
+ transform?: (value: unknown, row: T) => string | number | boolean | null;
+ /** 컬럼 너비 (문자 수 기준, 기본값: 15) */
+ width?: number;
+}
+
+/**
+ * 엑셀 다운로드 옵션
+ */
+export interface ExcelDownloadOptions> {
+ /** 다운로드할 데이터 배열 */
+ data: T[];
+ /** 컬럼 정의 */
+ columns: ExcelColumn[];
+ /** 파일명 (확장자 제외, 기본값: 'export') */
+ filename?: string;
+ /** 시트명 (기본값: 'Sheet1') */
+ sheetName?: string;
+ /** 파일명에 날짜 추가 여부 (기본값: true) */
+ appendDate?: boolean;
+}
+
+/**
+ * 중첩 객체에서 값 추출 (예: 'vendor.name' → vendor 객체의 name 값)
+ */
+function getNestedValue(obj: Record, path: string): unknown {
+ return path.split('.').reduce((current: unknown, key: string) => {
+ if (current && typeof current === 'object' && key in current) {
+ return (current as Record)[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>({
+ data,
+ columns,
+ filename = 'export',
+ sheetName = 'Sheet1',
+ appendDate = true,
+}: ExcelDownloadOptions): 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, 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>({
+ data,
+ selectedIds,
+ idField = 'id',
+ ...options
+}: ExcelDownloadOptions & {
+ selectedIds: string[];
+ idField?: keyof T | string;
+}): void {
+ const selectedData = data.filter((item) => {
+ const id = getNestedValue(item as Record, 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> {
+ /** 파싱 성공 여부 */
+ success: boolean;
+ /** 파싱된 데이터 */
+ data: T[];
+ /** 에러 목록 (행별) */
+ errors: Array<{
+ row: number;
+ column?: string;
+ message: string;
+ }>;
+ /** 전체 행 수 */
+ totalRows: number;
+ /** 유효한 행 수 */
+ validRows: number;
+}
+
+/**
+ * 엑셀 파일을 파싱하여 데이터 배열로 변환
+ *
+ * 사용 예시:
+ * ```tsx
+ * const result = await parseExcelFile(file, {
+ * columns: [
+ * { header: '품목코드', key: 'itemCode', required: true },
+ * { header: '품목명', key: 'itemName', required: true },
+ * ],
+ * skipRows: 2, // 헤더 + 안내 행 스킵
+ * });
+ * ```
+ */
+export async function parseExcelFile>(
+ file: File,
+ options: {
+ columns: TemplateColumn[];
+ /** 스킵할 행 수 (헤더, 안내 행 등) */
+ skipRows?: number;
+ /** 시트 인덱스 (기본값: 0) */
+ sheetIndex?: number;
+ }
+): Promise> {
+ 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>(worksheet, {
+ header: 1, // 배열로 반환
+ defval: '', // 빈 셀 기본값
+ }) as unknown[][];
+
+ // 헤더 행에서 컬럼 인덱스 매핑
+ const headerRow = jsonData[0] as string[];
+ const columnIndexMap = new Map();
+
+ 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 = {};
+ 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);
+ });
+}