diff --git a/backend/build.gradle b/backend/build.gradle
index 1635e29..7f3afbf 100644
--- a/backend/build.gradle
+++ b/backend/build.gradle
@@ -34,6 +34,8 @@ dependencies {
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'
implementation 'org.springframework.boot:spring-boot-starter-batch'
implementation 'org.postgresql:postgresql'
+ implementation 'org.apache.poi:poi-ooxml:5.2.5'
+ implementation 'com.opencsv:opencsv:5.9'
implementation 'org.projectlombok:lombok:1.18.38'
annotationProcessor 'org.projectlombok:lombok:1.18.38'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
diff --git a/backend/src/main/java/com/srsystem/controller/EmployeeController.java b/backend/src/main/java/com/srsystem/controller/EmployeeController.java
index 696de78..8e2dfc1 100644
--- a/backend/src/main/java/com/srsystem/controller/EmployeeController.java
+++ b/backend/src/main/java/com/srsystem/controller/EmployeeController.java
@@ -2,6 +2,7 @@ package com.srsystem.controller;
import com.srsystem.config.jwt.CurrentUser;
import com.srsystem.dto.ApiResponse;
+import com.srsystem.dto.BatchResult;
import com.srsystem.dto.ChangePasswordRequest;
import com.srsystem.dto.EmployeeSearchResult;
import com.srsystem.dto.EmployeeSummary;
@@ -14,6 +15,7 @@ import com.srsystem.entity.EquipmentAssignment;
import com.srsystem.entity.enums.DispatchStatus;
import com.srsystem.entity.enums.EmpStatus;
import com.srsystem.entity.enums.RoleCode;
+import com.srsystem.util.ExcelParser;
import com.srsystem.repository.EquipmentAssignmentRepository;
import com.srsystem.service.DispatchHistoryService;
import com.srsystem.service.EmployeeService;
@@ -27,12 +29,16 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.Valid;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -250,4 +256,78 @@ public class EmployeeController {
employeeService.changePassword(currentUser.getEmpId(), request);
return ResponseEntity.ok(ApiResponse.ok("비밀번호가 변경되었습니다", null));
}
+
+ // ---- Batch ----
+
+ @Operation(summary = "직원 일괄 등록", description = "엑셀 파일로 직원을 일괄 등록합니다")
+ @PreAuthorize("hasRole('ADMIN')")
+ @PostMapping("/batch")
+ @Transactional
+ public ResponseEntity>> batchCreate(@RequestParam("file") MultipartFile file) {
+ try {
+ ExcelParser.ParseResult parsed = ExcelParser.parse(
+ file,
+ (row) -> {
+ Employee emp = new Employee();
+ emp.setLoginId(row.getOrDefault("로그인ID", ""));
+ emp.setName(row.getOrDefault("이름", ""));
+ emp.setEmail(row.getOrDefault("이메일", ""));
+ emp.setPhone(row.getOrDefault("전화번호", ""));
+ emp.setRoleCode(RoleCode.valueOf(row.getOrDefault("권한", "EMPLOYEE").toUpperCase()));
+ emp.setEmpStatus(EmpStatus.valueOf(row.getOrDefault("재직상태", "ACTIVE").toUpperCase()));
+ emp.setDispatchStatus(DispatchStatus.valueOf(row.getOrDefault("파견상태", "WAITING").toUpperCase()));
+ emp.setPassword("batch1234!");
+ if (row.containsKey("생년월일")) emp.setBirthDate(java.time.LocalDate.parse(row.get("생년월일")));
+ if (row.containsKey("학력")) emp.setEducation(row.get("학력"));
+ if (row.containsKey("학과")) emp.setMajor(row.get("학과"));
+ if (row.containsKey("입사일")) emp.setJoinedDate(java.time.LocalDate.parse(row.get("입사일")));
+ return emp;
+ },
+ List.of()
+ );
+
+ List failures = new ArrayList<>();
+ List success = new ArrayList<>();
+ for (var pf : parsed.failures()) {
+ failures.add(new BatchResult.Failure(pf.row(), pf.reason()));
+ }
+
+ for (int i = 0; i < parsed.data().size(); i++) {
+ try {
+ Employee emp = employeeService.create(parsed.data().get(i));
+ success.add(emp);
+ } catch (Exception e) {
+ failures.add(new BatchResult.Failure(i + 2, e.getMessage()));
+ }
+ }
+
+ BatchResult result = new BatchResult<>(
+ parsed.data().size() + parsed.failures().size(),
+ success.size(), failures.size(), failures, success
+ );
+ return ResponseEntity.ok(ApiResponse.ok(
+ String.format("%d건 중 %d건 성공, %d건 실패", result.totalCount(), result.successCount(), result.failCount()),
+ result
+ ));
+ } catch (Exception e) {
+ return ResponseEntity.badRequest().body(ApiResponse.error("엑셀 파싱 실패: " + e.getMessage()));
+ }
+ }
+
+ @Operation(summary = "직원 일괄 등록 템플릿 다운로드")
+ @PreAuthorize("hasRole('ADMIN')")
+ @GetMapping("/batch/template")
+ public ResponseEntity downloadTemplate() throws Exception {
+ String[] headers = {"로그인ID", "이름", "이메일", "전화번호", "권한", "재직상태", "파견상태", "생년월일", "학력", "학과", "입사일"};
+ Map dropdowns = Map.of(
+ 4, new String[]{"ADMIN", "MANAGER", "EMPLOYEE"},
+ 5, new String[]{"ACTIVE", "ON_LEAVE", "RESIGNED"},
+ 6, new String[]{"WAITING", "DISPATCHED"}
+ );
+ byte[] data = ExcelParser.generateTemplate(headers, "직원 일괄등록", dropdowns);
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=employee_batch_template.xlsx")
+ .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .body(data);
+ }
}
diff --git a/backend/src/main/java/com/srsystem/controller/EquipmentController.java b/backend/src/main/java/com/srsystem/controller/EquipmentController.java
index 129902b..7c0e1d3 100644
--- a/backend/src/main/java/com/srsystem/controller/EquipmentController.java
+++ b/backend/src/main/java/com/srsystem/controller/EquipmentController.java
@@ -1,6 +1,7 @@
package com.srsystem.controller;
import com.srsystem.dto.ApiResponse;
+import com.srsystem.dto.BatchResult;
import com.srsystem.dto.CreateEquipmentRequest;
import com.srsystem.dto.CreateMaintenanceRequest;
import com.srsystem.dto.EquipmentSummary;
@@ -11,6 +12,7 @@ import com.srsystem.entity.EquipmentMaintenance;
import com.srsystem.entity.enums.EquipmentStatus;
import com.srsystem.entity.enums.EquipmentType;
import com.srsystem.service.EquipmentService;
+import com.srsystem.util.ExcelParser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -19,11 +21,15 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
+import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -158,4 +164,75 @@ public class EquipmentController {
Equipment equipment = equipmentService.removeImage(equipmentId, imageIndex);
return ResponseEntity.ok(ApiResponse.ok("이미지가 삭제되었습니다", equipment));
}
+
+ // ---- Batch ----
+
+ @Operation(summary = "장비 일괄 등록", description = "엑셀 파일로 장비를 일괄 등록합니다")
+ @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
+ @Transactional
+ @PostMapping("/batch")
+ public ResponseEntity>> batchCreate(@RequestParam("file") MultipartFile file) {
+ try {
+ ExcelParser.ParseResult parsed = ExcelParser.parse(
+ file,
+ (row) -> new CreateEquipmentRequest(
+ EquipmentType.valueOf(row.getOrDefault("장비유형", "LAPTOP").toUpperCase()),
+ row.getOrDefault("제조사", ""),
+ row.getOrDefault("모델명", ""),
+ row.getOrDefault("시리얼번호", ""),
+ row.getOrDefault("자산코드", ""),
+ row.getOrDefault("제원정보", ""),
+ EquipmentStatus.AVAILABLE,
+ row.containsKey("구매일") ? java.time.LocalDate.parse(row.get("구매일")) : java.time.LocalDate.now(),
+ null
+ ),
+ List.of()
+ );
+
+ List failures = new ArrayList<>();
+ List success = new ArrayList<>();
+ var parseFailures = parsed.failures();
+ for (var pf : parseFailures) {
+ failures.add(new BatchResult.Failure(pf.row(), pf.reason()));
+ }
+
+ for (int i = 0; i < parsed.data().size(); i++) {
+ try {
+ Equipment eq = equipmentService.createEquipment(parsed.data().get(i));
+ success.add(eq);
+ } catch (Exception e) {
+ failures.add(new BatchResult.Failure(i + 2, e.getMessage()));
+ }
+ }
+
+ BatchResult result = new BatchResult<>(
+ parsed.data().size() + parseFailures.size(),
+ success.size(),
+ failures.size(),
+ failures,
+ success
+ );
+ return ResponseEntity.ok(ApiResponse.ok(
+ String.format("%d건 중 %d건 성공, %d건 실패", result.totalCount(), result.successCount(), result.failCount()),
+ result
+ ));
+ } catch (Exception e) {
+ return ResponseEntity.badRequest().body(ApiResponse.error("엑셀 파싱 실패: " + e.getMessage()));
+ }
+ }
+
+ @Operation(summary = "장비 일괄 등록 템플릿 다운로드")
+ @PreAuthorize("hasAnyRole('MANAGER', 'ADMIN')")
+ @GetMapping("/batch/template")
+ public ResponseEntity downloadTemplate() throws Exception {
+ String[] headers = {"장비유형", "제조사", "모델명", "시리얼번호", "자산코드", "제원정보", "구매일"};
+ Map dropdowns = Map.of(
+ 0, new String[]{"LAPTOP", "MONITOR", "TEST_DEVICE", "ACCESSORY"}
+ );
+ byte[] data = ExcelParser.generateTemplate(headers, "장비 일괄등록", dropdowns);
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=equipment_batch_template.xlsx")
+ .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .body(data);
+ }
}
diff --git a/backend/src/main/java/com/srsystem/dto/BatchResult.java b/backend/src/main/java/com/srsystem/dto/BatchResult.java
new file mode 100644
index 0000000..8341937
--- /dev/null
+++ b/backend/src/main/java/com/srsystem/dto/BatchResult.java
@@ -0,0 +1,28 @@
+package com.srsystem.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public record BatchResult(
+ int totalCount,
+ int successCount,
+ int failCount,
+ List failures,
+ List data
+) {
+ public static BatchResult ok(List data) {
+ return new BatchResult<>(data.size(), data.size(), 0, List.of(), data);
+ }
+
+ public static BatchResult partial(List successData, List failures) {
+ return new BatchResult<>(
+ successData.size() + failures.size(),
+ successData.size(),
+ failures.size(),
+ failures,
+ successData
+ );
+ }
+
+ public record Failure(int row, String reason) {}
+}
diff --git a/backend/src/main/java/com/srsystem/util/ExcelParser.java b/backend/src/main/java/com/srsystem/util/ExcelParser.java
new file mode 100644
index 0000000..78c07cf
--- /dev/null
+++ b/backend/src/main/java/com/srsystem/util/ExcelParser.java
@@ -0,0 +1,147 @@
+package com.srsystem.util;
+
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.CellRangeAddressList;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.*;
+import java.util.function.Function;
+
+public class ExcelParser {
+
+ public static ParseResult parse(MultipartFile file, RowMapper mapper, List> validators) throws IOException {
+ List data = new ArrayList<>();
+ List failures = new ArrayList<>();
+
+ try (InputStream is = file.getInputStream(); Workbook wb = new XSSFWorkbook(is)) {
+ Sheet sheet = wb.getSheetAt(0);
+ if (sheet.getPhysicalNumberOfRows() < 2) {
+ return new ParseResult<>(List.of(), List.of(new ParseFailure(0, "데이터 행이 없습니다")));
+ }
+
+ Row headerRow = sheet.getRow(0);
+ int colCount = headerRow.getPhysicalNumberOfCells();
+ String[] headers = new String[colCount];
+ for (int i = 0; i < colCount; i++) {
+ headers[i] = getCellValue(headerRow.getCell(i)).trim().toLowerCase();
+ }
+
+ for (int r = 1; r <= sheet.getLastRowNum(); r++) {
+ Row row = sheet.getRow(r);
+ if (row == null) continue;
+
+ try {
+ Map rowData = new HashMap<>();
+ for (int c = 0; c < colCount; c++) {
+ String val = getCellValue(row.getCell(c));
+ if (!val.isEmpty()) {
+ rowData.put(headers[c], val);
+ }
+ }
+
+ if (rowData.isEmpty()) continue;
+
+ T item = mapper.map(rowData);
+ for (Validator v : validators) {
+ String err = v.validate(item, rowData);
+ if (err != null) {
+ throw new ValidationException(err);
+ }
+ }
+ data.add(item);
+ } catch (Exception e) {
+ failures.add(new ParseFailure(r + 1, e.getMessage()));
+ }
+ }
+ }
+
+ return new ParseResult<>(data, failures);
+ }
+
+ public static byte[] generateTemplate(String[] headers, String sheetName, Map dropdowns) throws IOException {
+ try (Workbook wb = new XSSFWorkbook(); ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+ Sheet sheet = wb.createSheet(sheetName);
+ Row headerRow = sheet.createRow(0);
+
+ CellStyle headerStyle = wb.createCellStyle();
+ Font headerFont = wb.createFont();
+ headerFont.setBold(true);
+ headerFont.setColor(IndexedColors.WHITE.getIndex());
+ headerStyle.setFont(headerFont);
+ headerStyle.setFillForegroundColor(IndexedColors.INDIGO.getIndex());
+ headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+ headerStyle.setBorderBottom(BorderStyle.THIN);
+ headerStyle.setBorderLeft(BorderStyle.THIN);
+ headerStyle.setBorderRight(BorderStyle.THIN);
+ headerStyle.setBorderTop(BorderStyle.THIN);
+
+ for (int i = 0; i < headers.length; i++) {
+ Cell cell = headerRow.createCell(i);
+ cell.setCellValue(headers[i]);
+ cell.setCellStyle(headerStyle);
+ sheet.autoSizeColumn(i);
+ }
+
+ if (dropdowns != null) {
+ for (Map.Entry entry : dropdowns.entrySet()) {
+ int col = entry.getKey();
+ String[] values = entry.getValue();
+ DataValidationHelper helper = sheet.getDataValidationHelper();
+ DataValidationConstraint constraint = helper.createExplicitListConstraint(values);
+ CellRangeAddressList range = new CellRangeAddressList(1, 1000, col, col);
+ DataValidation validation = helper.createValidation(constraint, range);
+ validation.setShowErrorBox(true);
+ validation.createErrorBox("유효성 검사", "허용된 값만 입력 가능합니다.");
+ sheet.addValidationData(validation);
+ }
+ }
+
+ wb.write(bos);
+ return bos.toByteArray();
+ }
+ }
+
+ private static String getCellValue(Cell cell) {
+ if (cell == null) return "";
+ return switch (cell.getCellType()) {
+ case STRING -> cell.getStringCellValue().trim();
+ case NUMERIC -> {
+ double val = cell.getNumericCellValue();
+ if (val == Math.floor(val) && !Double.isInfinite(val)) {
+ yield String.valueOf((long) val);
+ }
+ yield String.valueOf(val);
+ }
+ case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
+ case FORMULA -> {
+ try { yield String.valueOf((int) cell.getNumericCellValue()); }
+ catch (Exception e) { yield cell.getStringCellValue(); }
+ }
+ default -> "";
+ };
+ }
+
+ public record ParseResult(List data, List failures) {
+ public boolean hasFailures() { return !failures.isEmpty(); }
+ }
+
+ public record ParseFailure(int row, String reason) {}
+
+ @FunctionalInterface
+ public interface RowMapper {
+ T map(Map rowData) throws Exception;
+ }
+
+ @FunctionalInterface
+ public interface Validator {
+ String validate(T item, Map rawData);
+ }
+
+ private static class ValidationException extends RuntimeException {
+ public ValidationException(String message) { super(message); }
+ }
+}
diff --git a/src/app/(main)/settings/batch/employees/page.tsx b/src/app/(main)/settings/batch/employees/page.tsx
new file mode 100644
index 0000000..c690d74
--- /dev/null
+++ b/src/app/(main)/settings/batch/employees/page.tsx
@@ -0,0 +1,45 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { Users } from 'lucide-react';
+import ExcelUploader from '@/components/ui/ExcelUploader';
+import { useAuthStore } from '@/stores';
+
+export default function BatchEmployeesPage() {
+ const router = useRouter();
+ const { user } = useAuthStore();
+ const [checked, setChecked] = useState(false);
+
+ useEffect(() => {
+ if (user?.roleCode !== 'ADMIN') {
+ router.push('/403');
+ } else {
+ setChecked(true);
+ }
+ }, [user, router]);
+
+ if (!checked) return null;
+ if (user?.roleCode !== 'ADMIN') return null;
+
+ return (
+
+
+
+
+
+
직원 일괄 등록
+
엑셀 파일로 여러 직원 계정을 한 번에 등록합니다.
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/itam/BatchRegistrationView.tsx b/src/components/itam/BatchRegistrationView.tsx
index 4483128..15d85f3 100644
--- a/src/components/itam/BatchRegistrationView.tsx
+++ b/src/components/itam/BatchRegistrationView.tsx
@@ -10,32 +10,20 @@ import {
RefreshCcw,
Zap
} from 'lucide-react';
-import equipmentService from '@/services/equipmentService';
-import type { Equipment, EquipmentType } from '@/types';
+import ExcelUploader from '@/components/ui/ExcelUploader';
+import type { EquipmentType } from '@/types';
-interface ParsedItem {
- row: number;
- data: Partial;
- error?: string;
-}
-
-interface ValidationResult {
- row: number;
- status: 'Error' | 'Warning' | 'Success';
- field: string;
- value: string;
- message: string;
+interface BatchRegistrationViewProps {
+ onRegisterSuccess?: () => void;
}
/**
* 대량 등록 (엑셀) 뷰 컴포넌트
- * 엑셀 업로드 및 데이터 유효성 검증 프로세스를 제공합니다.
+ * ExcelUploader를 통한 서버사이드 엑셀 업로드 및 처리를 제공합니다.
*/
-export default function BatchRegistrationView() {
+export default function BatchRegistrationView({ onRegisterSuccess }: BatchRegistrationViewProps) {
const [isUploaded, setIsUploaded] = useState(false);
- const [inputText, setInputText] = useState('');
- const [parsedItems, setParsedItems] = useState([]);
- const [validationResults, setValidationResults] = useState([]);
+ const [validationResults, setValidationResults] = useState([]);
const [isProcessing, setIsProcessing] = useState(false);
const [successCount, setSuccessCount] = useState(0);
const [errorCount, setErrorCount] = useState(0);
@@ -47,100 +35,8 @@ export default function BatchRegistrationView() {
'ACCESSORY': 'ACCESSORY',
};
- const parseInput = () => {
- const lines = inputText.trim().split('\n');
- const items: ParsedItem[] = [];
- const errors: ValidationResult[] = [];
-
- lines.forEach((line, index) => {
- const row = index + 1;
- const parts = line.split(',').map(p => p.trim());
-
- if (parts.length < 7) {
- errors.push({
- row,
- status: 'Error',
- field: 'Format',
- value: line,
- message: '필수 항목이 부족합니다 (7 개 항목 필요)',
- });
- items.push({ row, data: {}, error: 'Invalid format' });
- return;
- }
-
- const [equipmentType, manufacturer, modelName, serialNumber, assetCode, location, purchaseDate] = parts;
-
- if (!typeMapping[equipmentType.toUpperCase()]) {
- errors.push({
- row,
- status: 'Error',
- field: 'EquipmentType',
- value: equipmentType,
- message: '허용되지 않는 장비 유형입니다 (LAPTOP, MONITOR, TEST_DEVICE, ACCESSORY)',
- });
- items.push({ row, data: {}, error: 'Invalid type' });
- return;
- }
-
- items.push({
- row,
- data: {
- equipmentType: typeMapping[equipmentType.toUpperCase()],
- manufacturer,
- modelName,
- serialNumber,
- assetCode,
- specifications: { location },
- status: 'AVAILABLE',
- purchaseDate,
- warrantyExpDate: '',
- },
- });
-
- if (errors.length === 0) {
- errors.push({
- row,
- status: 'Success',
- field: 'All',
- value: '-',
- message: '정상',
- });
- }
- });
-
- setParsedItems(items);
- setValidationResults(errors);
- setIsUploaded(true);
- };
-
- const handleBatchRegister = async () => {
- setIsProcessing(true);
- setSuccessCount(0);
- setErrorCount(0);
-
- const validItems = parsedItems.filter(item => !item.error);
-
- for (const item of validItems) {
- try {
- await equipmentService.create(item.data);
- setSuccessCount(prev => prev + 1);
- } catch (error: any) {
- setErrorCount(prev => prev + 1);
- setValidationResults(prev => prev.map(res =>
- res.row === item.row
- ? { ...res, status: 'Error', message: error.response?.data?.message || '등록 실패' }
- : res
- ));
- }
- }
-
- setIsProcessing(false);
- };
-
const handleReset = () => {
setIsUploaded(false);
- setInputText('');
- setParsedItems([]);
setValidationResults([]);
setSuccessCount(0);
setErrorCount(0);
@@ -168,32 +64,26 @@ export default function BatchRegistrationView() {
- {/* Upload Card - Textarea Input */}
+ {/* Upload Card - ExcelUploader */}
-
2. 데이터 입력
+
2. 엑셀 파일 업로드
- 각 줄마다 장비를 입력하세요: type,manufacturer,model,serial,assetCode,location,purchaseDate
+ 작성한 엑셀 파일(.xlsx)을 업로드하면 서버에서 자동으로 파싱 및 검증 후 등록합니다.
-
-
+
{
+ if (onRegisterSuccess) onRegisterSuccess();
+ }}
+ />
@@ -265,7 +155,6 @@ MONITOR,LG,27UP850,SN-002,AST-2025-002,Busan,2025-02-20`}
주의: 오류가 있는 행을 모두 수정해야 일괄 등록이 가능합니다.
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index e6e6dee..9c9be0e 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -234,6 +234,7 @@ const Sidebar = ({ isCollapsed }: { isCollapsed?: boolean }) => {
{[
{ name: 'Codes', href: '/settings/codes', icon: Settings },
{ name: 'Accounts', href: '/settings/accounts', icon: Users },
+ { name: 'Batch Employees', href: '/settings/batch/employees', icon: FileText },
{ name: 'System', href: '/settings/system', icon: Monitor },
{ name: 'Notification', href: '/settings/notification', icon: FileText },
{ name: 'Audit Log', href: '/settings/audit', icon: History },
diff --git a/src/components/ui/ExcelUploader.tsx b/src/components/ui/ExcelUploader.tsx
new file mode 100644
index 0000000..0871038
--- /dev/null
+++ b/src/components/ui/ExcelUploader.tsx
@@ -0,0 +1,397 @@
+'use client';
+
+import React, { useState, useCallback, useRef } from 'react';
+import {
+ Upload,
+ FileSpreadsheet,
+ Download,
+ CheckCircle2,
+ XCircle,
+ AlertCircle,
+ Loader2,
+} from 'lucide-react';
+
+export interface BatchResult {
+ totalCount: number;
+ successCount: number;
+ failCount: number;
+ failures: { row: number; reason: string }[];
+ data?: any[];
+}
+
+export interface ExcelUploaderProps {
+ title: string;
+ description?: string;
+ templateUrl?: string;
+ uploadUrl: string;
+ onSuccess?: (result: BatchResult) => void;
+ accept?: string;
+}
+
+interface PreviewData {
+ headers: string[];
+ rows: string[][];
+}
+
+export default function ExcelUploader({
+ title,
+ description,
+ templateUrl,
+ uploadUrl,
+ onSuccess,
+ accept = '.xlsx',
+}: ExcelUploaderProps) {
+ const [file, setFile] = useState(null);
+ const [preview, setPreview] = useState(null);
+ const [rowCount, setRowCount] = useState(0);
+ const [isDragging, setIsDragging] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+ const [showFailures, setShowFailures] = useState(false);
+
+ const fileInputRef = useRef(null);
+
+ const resetState = useCallback(() => {
+ setFile(null);
+ setPreview(null);
+ setRowCount(0);
+ setResult(null);
+ setError(null);
+ setShowFailures(false);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ }, []);
+
+ const handleFile = useCallback((selectedFile: File) => {
+ if (!selectedFile.name.endsWith('.xlsx')) {
+ setError('지원하지 않는 파일 형식입니다. .xlsx 파일을 선택해주세요.');
+ return;
+ }
+
+ setError(null);
+ setResult(null);
+ setFile(selectedFile);
+ setShowFailures(false);
+
+ // Parse file for preview (filename + row count estimation)
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ try {
+ // Simple row count from binary content (approximate)
+ // In production, you'd use xlsx library for proper parsing
+ const content = e.target?.result;
+ if (content) {
+ // For preview purposes, we'll just show the filename
+ // A full xlsx parser would be needed for actual cell data
+ setRowCount(0); // Will be updated by server response
+ setPreview(null); // Preview requires xlsx library
+ }
+ } catch {
+ // Ignore parse errors for preview
+ }
+ };
+ reader.readAsArrayBuffer(selectedFile);
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ }, []);
+
+ const handleDrop = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+
+ const droppedFile = e.dataTransfer.files[0];
+ if (droppedFile) {
+ handleFile(droppedFile);
+ }
+ }, [handleFile]);
+
+ const handleFileSelect = useCallback((e: React.ChangeEvent) => {
+ const selectedFile = e.target.files?.[0];
+ if (selectedFile) {
+ handleFile(selectedFile);
+ }
+ }, [handleFile]);
+
+ const handleUpload = useCallback(async () => {
+ if (!file) return;
+
+ setIsUploading(true);
+ setError(null);
+ setResult(null);
+
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await fetch(uploadUrl, {
+ method: 'POST',
+ body: formData,
+ });
+
+ const json = await response.json();
+
+ if (!response.ok || !json.success) {
+ throw new Error(json.message || '업로드 중 오류가 발생했습니다.');
+ }
+
+ const batchResult: BatchResult = json.data;
+ setResult(batchResult);
+ setRowCount(batchResult.totalCount);
+
+ if (batchResult.failCount === 0 && onSuccess) {
+ onSuccess(batchResult);
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.';
+ setError(message);
+ } finally {
+ setIsUploading(false);
+ }
+ }, [file, uploadUrl, onSuccess]);
+
+ const handleDownloadTemplate = useCallback(() => {
+ if (templateUrl) {
+ window.open(templateUrl, '_blank');
+ }
+ }, [templateUrl]);
+
+ return (
+
+ {/* Header */}
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+
+ {/* Drop Zone */}
+
fileInputRef.current?.click()}
+ className={`
+ relative border-2 border-dashed rounded-xl p-8 text-center cursor-pointer
+ transition-colors duration-200
+ ${isDragging
+ ? 'border-indigo-500 bg-indigo-50'
+ : 'border-gray-300 hover:border-indigo-400 hover:bg-gray-50'
+ }
+ `}
+ >
+
+
+
+ 엑셀 파일을 드래그하거나 [파일 선택]
+
+
지원 형식: {accept}
+
+
+ {/* Template Download */}
+ {templateUrl && (
+
+
+
+ )}
+
+ {/* File Info */}
+ {file && (
+
+
+
+
+
+
{file.name}
+
+ {rowCount > 0 ? `${rowCount}행` : '행 수 분석 중...'}
+
+
+
+
+
+
+ )}
+
+ {/* Preview Table */}
+ {preview && (
+
+
미리보기
+
+
+
+
+ {preview.headers.map((header, i) => (
+ |
+ {header}
+ |
+ ))}
+
+
+
+ {preview.rows.slice(0, 5).map((row, i) => (
+
+ {row.map((cell, j) => (
+ |
+ {cell}
+ |
+ ))}
+
+ ))}
+
+
+
+ {preview.rows.length > 5 && (
+
+ 상위 5행만 표시됩니다 (총 {preview.rows.length}행)
+
+ )}
+
+ )}
+
+ {/* Upload Button */}
+ {file && !result && (
+
+
+
+ )}
+
+ {/* Error State */}
+ {error && (
+
+ )}
+
+ {/* Results */}
+ {result && (
+
+ {/* Summary */}
+
+
+ {result.failCount === 0 ? (
+
+ ) : (
+
+ )}
+
+ {result.totalCount}건 중 {result.successCount}건 성공
+ {result.failCount > 0 && `, ${result.failCount}건 실패`}
+
+
+
+
+ {/* Failure Details */}
+ {result.failCount > 0 && (
+
+
+
+ {showFailures && (
+
+ {result.failures.map((failure, i) => (
+
+
+
+ Row {failure.row}
+
+
{failure.reason}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Reset Button */}
+
+
+ )}
+
+ );
+}