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)을 업로드하면 서버에서 자동으로 파싱 및 검증 후 등록합니다.

-