feat: WTM 멀티프로젝트 플랫폼 구축 (BE + FE 전체 구현)
Phase 0: wbx-spring-core 라이브러리 전환 - java-library 플러그인, WbxAutoConfiguration, Admin 조건부 활성화 - 루트 settings.gradle + build.gradle (멀티모듈) Phase 1: wtm-api 모듈 생성 - 23개 JPA Entity, 14개 Controller, 79개 API 엔드포인트 - Flyway V100~V107 MySQL 마이그레이션 - TimesheetRuleEngine, TimesheetApprovalHandler, P6WbsParser Phase 2: wtm-frontend (Vue 3 + PrimeVue 4) - 10개 도메인 모듈, 17개 View, 5개 서브컴포넌트 - 반응형 레이아웃 (AppLayout, AppSidebar, AppTopbar) - BaseCrudTable, BaseFormDialog, BasePageHeader 표준 컴포넌트 - JWT 인터셉터, 역할 기반 메뉴 필터링 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
@@ -0,0 +1,337 @@
|
||||
# 03. 시수 입력 모듈 (3종)
|
||||
|
||||
## 시수 입력 유형
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ 시수 입력 통합 UI (탭 전환) │
|
||||
├──────────────┬──────────────┬────────────────────────┤
|
||||
│ Non-Project │ Other Project│ EPC Project │
|
||||
│ 시수 입력 │ 시수 입력 │ 시수 입력 │
|
||||
├──────────────┼──────────────┼────────────────────────┤
|
||||
│ • 카테고리 │ • 프로젝트 │ • 프로젝트 선택 │
|
||||
│ 선택 │ 선택 │ • Canonical WBS 선택 │
|
||||
│ • Leave │ • 카테고리 │ • TEAL Activity 선택 │
|
||||
│ • Training │ 선택 │ • Revision 관리 (PH1-2)│
|
||||
│ • Admin │ • 시간 입력 │ • 시간 입력 │
|
||||
│ • 시간 입력 │ │ │
|
||||
└──────────────┴──────────────┴────────────────────────┘
|
||||
│
|
||||
┌──────┴──────┐
|
||||
│ 규칙 엔진 │
|
||||
│ 일 8h 제한 │
|
||||
│ 주 52h 제한 │
|
||||
└─────────────┘
|
||||
```
|
||||
|
||||
## 핵심 도메인 모델
|
||||
|
||||
```java
|
||||
// 시수 유형 Enum
|
||||
public enum TimesheetEntryType {
|
||||
NON_PROJECT, // 비프로젝트 (휴가, 교육, 행정)
|
||||
OTHER_PROJECT, // 타 프로젝트
|
||||
EPC // EPC 프로젝트 (핵심)
|
||||
}
|
||||
|
||||
// Non-Project 카테고리
|
||||
public enum NonProjectCategory {
|
||||
ANNUAL_LEAVE("연차"),
|
||||
SICK_LEAVE("병가"),
|
||||
TRAINING("교육"),
|
||||
ADMIN("행정"),
|
||||
PUBLIC_HOLIDAY("공휴일"),
|
||||
OTHER("기타");
|
||||
|
||||
private final String displayName;
|
||||
}
|
||||
|
||||
// Timesheet Entity (주간 단위)
|
||||
@Entity @Table(name = "timesheets")
|
||||
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class Timesheet extends BaseEntity {
|
||||
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private User user;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate weekStartDate;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate weekEndDate;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
private TimesheetStatus status = TimesheetStatus.DRAFT;
|
||||
|
||||
@Column(precision = 10, scale = 2)
|
||||
private BigDecimal totalHours = BigDecimal.ZERO;
|
||||
|
||||
@OneToMany(mappedBy = "timesheet", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<TimesheetEntry> entries = new ArrayList<>();
|
||||
|
||||
private LocalDateTime submittedAt;
|
||||
|
||||
// 비즈니스 메서드
|
||||
public void addEntry(TimesheetEntry entry) {
|
||||
entries.add(entry);
|
||||
entry.setTimesheet(this);
|
||||
recalculateTotal();
|
||||
}
|
||||
|
||||
public void submit() {
|
||||
if (status != TimesheetStatus.DRAFT && status != TimesheetStatus.REJECTED) {
|
||||
throw new BusinessException("DRAFT 또는 REJECTED 상태에서만 제출 가능합니다.");
|
||||
}
|
||||
this.status = TimesheetStatus.SUBMITTED;
|
||||
this.submittedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
private void recalculateTotal() {
|
||||
this.totalHours = entries.stream()
|
||||
.map(TimesheetEntry::getHours)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
|
||||
// TimesheetEntry (일별 상세)
|
||||
@Entity @Table(name = "timesheet_entries")
|
||||
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class TimesheetEntry extends BaseEntity {
|
||||
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "timesheet_id", nullable = false)
|
||||
private Timesheet timesheet;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private TimesheetEntryType entryType;
|
||||
|
||||
@Column(nullable = false)
|
||||
private LocalDate entryDate;
|
||||
|
||||
@Column(nullable = false, precision = 5, scale = 2)
|
||||
private BigDecimal hours;
|
||||
|
||||
// Non-Project
|
||||
@Enumerated(EnumType.STRING)
|
||||
private NonProjectCategory npCategory;
|
||||
|
||||
// Other Project
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "other_project_id")
|
||||
private Project otherProject;
|
||||
private String otherCategory;
|
||||
|
||||
// EPC Project
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "epc_project_id")
|
||||
private Project epcProject;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "canonical_wbs_id")
|
||||
private CanonicalWbs canonicalWbs;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "teal_entry_id")
|
||||
private TealEntry tealEntry;
|
||||
|
||||
private Integer revisionNumber = 1;
|
||||
private String remark;
|
||||
}
|
||||
```
|
||||
|
||||
## 규칙 엔진 (No.43~45)
|
||||
|
||||
```java
|
||||
@Component
|
||||
public class TimesheetRuleEngine {
|
||||
|
||||
private static final BigDecimal MAX_DAILY_HOURS = new BigDecimal("8");
|
||||
private static final BigDecimal MAX_WEEKLY_HOURS = new BigDecimal("52");
|
||||
private static final BigDecimal WARN_DAILY_HOURS = new BigDecimal("10");
|
||||
|
||||
/**
|
||||
* 시수 입력 전 규칙 검증
|
||||
* @return 검증 결과 (errors + warnings)
|
||||
*/
|
||||
public ValidationResult validate(Timesheet timesheet) {
|
||||
var result = new ValidationResult();
|
||||
|
||||
// 1. 일별 시간 제한 (8h 기본, 10h 경고, 24h 하드 리밋)
|
||||
Map<LocalDate, BigDecimal> dailyTotals = timesheet.getEntries().stream()
|
||||
.collect(Collectors.groupingBy(
|
||||
TimesheetEntry::getEntryDate,
|
||||
Collectors.reducing(BigDecimal.ZERO, TimesheetEntry::getHours, BigDecimal::add)
|
||||
));
|
||||
|
||||
for (var entry : dailyTotals.entrySet()) {
|
||||
LocalDate date = entry.getKey();
|
||||
BigDecimal total = entry.getValue();
|
||||
|
||||
if (total.compareTo(new BigDecimal("24")) > 0) {
|
||||
result.addError(date, "일 최대 24시간을 초과할 수 없습니다.");
|
||||
} else if (total.compareTo(WARN_DAILY_HOURS) > 0) {
|
||||
result.addWarning(date,
|
||||
String.format("일 %s시간 입력 — 기준(%sh) 초과", total, MAX_DAILY_HOURS));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 주간 총 시간 제한 (52h)
|
||||
if (timesheet.getTotalHours().compareTo(MAX_WEEKLY_HOURS) > 0) {
|
||||
result.addError(null,
|
||||
String.format("주간 합계 %s시간 — 최대 %sh 초과",
|
||||
timesheet.getTotalHours(), MAX_WEEKLY_HOURS));
|
||||
}
|
||||
|
||||
// 3. EPC 시수 — WBS/TEAL 필수
|
||||
timesheet.getEntries().stream()
|
||||
.filter(e -> e.getEntryType() == TimesheetEntryType.EPC)
|
||||
.forEach(e -> {
|
||||
if (e.getEpcProject() == null)
|
||||
result.addError(e.getEntryDate(), "EPC 시수 — 프로젝트 필수");
|
||||
if (e.getCanonicalWbs() == null)
|
||||
result.addError(e.getEntryDate(), "EPC 시수 — WBS 선택 필수");
|
||||
});
|
||||
|
||||
// 4. 미래 날짜 입력 불가
|
||||
LocalDate today = LocalDate.now();
|
||||
timesheet.getEntries().stream()
|
||||
.filter(e -> e.getEntryDate().isAfter(today))
|
||||
.forEach(e -> result.addError(e.getEntryDate(), "미래 날짜에 시수를 입력할 수 없습니다."));
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## REST API
|
||||
|
||||
```java
|
||||
@RestController
|
||||
@RequestMapping("/api/timesheets")
|
||||
@RequiredArgsConstructor
|
||||
public class TimesheetController {
|
||||
|
||||
private final TimesheetService timesheetService;
|
||||
|
||||
// 주간 시수 조회 (생성 안 되어 있으면 자동 생성)
|
||||
@GetMapping("/week")
|
||||
public TimesheetDto getWeekly(
|
||||
@RequestParam @DateTimeFormat(iso = DATE) LocalDate weekStart) {
|
||||
return timesheetService.getOrCreateWeekly(
|
||||
SecurityUtils.getCurrentUserId(), weekStart);
|
||||
}
|
||||
|
||||
// 시수 항목 저장 (Auto-save)
|
||||
@PostMapping("/{timesheetId}/entries")
|
||||
public TimesheetEntryDto saveEntry(
|
||||
@PathVariable Long timesheetId,
|
||||
@Valid @RequestBody TimesheetEntryRequest request) {
|
||||
return timesheetService.saveEntry(timesheetId, request);
|
||||
}
|
||||
|
||||
// 시수 항목 일괄 저장 (주간 전체)
|
||||
@PutMapping("/{timesheetId}/entries/batch")
|
||||
public TimesheetDto saveBatch(
|
||||
@PathVariable Long timesheetId,
|
||||
@Valid @RequestBody List<TimesheetEntryRequest> entries) {
|
||||
return timesheetService.saveBatch(timesheetId, entries);
|
||||
}
|
||||
|
||||
// 시수 제출 (결재 요청)
|
||||
@PostMapping("/{timesheetId}/submit")
|
||||
public TimesheetDto submit(@PathVariable Long timesheetId) {
|
||||
return timesheetService.submit(timesheetId);
|
||||
}
|
||||
|
||||
// Excel 일괄 업로드
|
||||
@PostMapping("/upload")
|
||||
public UploadResultDto uploadExcel(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam @DateTimeFormat(iso = DATE) LocalDate weekStart) {
|
||||
return timesheetService.uploadExcel(
|
||||
SecurityUtils.getCurrentUserId(), file, weekStart);
|
||||
}
|
||||
|
||||
// 내 시수 이력 (페이징)
|
||||
@GetMapping("/history")
|
||||
public Page<TimesheetSummaryDto> history(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DATE) LocalDate from,
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DATE) LocalDate to,
|
||||
Pageable pageable) {
|
||||
return timesheetService.getHistory(
|
||||
SecurityUtils.getCurrentUserId(), from, to, pageable);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Excel 일괄 업로드 (Apache POI)
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TimesheetExcelService {
|
||||
|
||||
private final TimesheetService timesheetService;
|
||||
|
||||
/**
|
||||
* 표준 템플릿 기반 Excel 파싱
|
||||
* 컬럼: Date | Type | Project | WBS | TEAL | Hours | Remark
|
||||
*/
|
||||
public UploadResult parseAndSave(Long userId, MultipartFile file, LocalDate weekStart) {
|
||||
var result = new UploadResult();
|
||||
|
||||
try (Workbook wb = new XSSFWorkbook(file.getInputStream())) {
|
||||
Sheet sheet = wb.getSheetAt(0);
|
||||
|
||||
for (int i = 1; i <= sheet.getLastRowNum(); i++) { // 헤더 스킵
|
||||
Row row = sheet.getRow(i);
|
||||
if (row == null) continue;
|
||||
|
||||
try {
|
||||
TimesheetEntryRequest entry = parseRow(row, i);
|
||||
timesheetService.saveEntry(/* ... */);
|
||||
result.addSuccess();
|
||||
} catch (Exception e) {
|
||||
result.addError(i, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 시수 입력 화면 구성 (프론트엔드 가이드)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 시수 입력 2025-04-07 ~ 2025-04-11 │
|
||||
│ ┌────────┬────────────┬──────────────┐ │
|
||||
│ │Non-Proj│Other Proj │ EPC Project │ ← 탭 전환 │
|
||||
│ └────────┴────────────┴──────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐│
|
||||
│ │ Project: [EPU-2025-001 ▼] ││
|
||||
│ │ WBS: [E.01.03 Piping Detail ▼] ││
|
||||
│ │ TEAL: [Detail Engineering ▼] ││
|
||||
│ ├──────┬──────┬──────┬──────┬──────┬──────┬──────────┤│
|
||||
│ │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ Total ││
|
||||
│ │ [8.0]│ [8.0]│ [8.0]│ [8.0]│ [4.0]│ [ ]│ 36.0h ││
|
||||
│ └──────┴──────┴──────┴──────┴──────┴──────┴──────────┘│
|
||||
│ │
|
||||
│ [+ 행 추가] 주간 합계: 36.0 / 52h │
|
||||
│ │
|
||||
│ ⚠ 월요일: 기준(8h) 초과 경고 │
|
||||
│ │
|
||||
│ [임시 저장] [제출 (결재 요청)] │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
새 Issue에서 참조
사용자 차단