diff --git a/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt b/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt index 501f1c5..982388e 100644 --- a/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt +++ b/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt @@ -2,6 +2,7 @@ package com.ffii.tsms.modules.data.service import com.ffii.core.support.AbstractBaseEntityService import com.ffii.core.support.JdbcDao +import com.ffii.tsms.modules.common.SecurityUtils import com.ffii.tsms.modules.data.entity.* import com.ffii.tsms.modules.data.entity.projections.StaffSearchInfo import com.ffii.tsms.modules.data.web.models.NewStaffRequest @@ -13,6 +14,7 @@ import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.util.* import java.util.stream.Collectors +import kotlin.jvm.optionals.getOrNull @Service @@ -225,4 +227,10 @@ open class StaffsService( ) return jdbcDao.queryForList(sql.toString(), args) } + + open fun currentStaff(): Staff? { + return SecurityUtils.getUser().getOrNull()?.let { user -> + staffRepository.findByUserId(user.id).getOrNull() + } + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/project/entity/ProjectTaskRepository.kt b/src/main/java/com/ffii/tsms/modules/project/entity/ProjectTaskRepository.kt index 793950a..b909b93 100644 --- a/src/main/java/com/ffii/tsms/modules/project/entity/ProjectTaskRepository.kt +++ b/src/main/java/com/ffii/tsms/modules/project/entity/ProjectTaskRepository.kt @@ -4,4 +4,6 @@ import com.ffii.core.support.AbstractRepository interface ProjectTaskRepository : AbstractRepository { fun findAllByProject(project: Project): List + + fun findByProjectAndTask(project: Project, task: Task): ProjectTask } \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/entity/Leave.kt b/src/main/java/com/ffii/tsms/modules/timesheet/entity/Leave.kt new file mode 100644 index 0000000..4594e72 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/timesheet/entity/Leave.kt @@ -0,0 +1,23 @@ +package com.ffii.tsms.modules.timesheet.entity + +import com.ffii.core.entity.BaseEntity +import com.ffii.tsms.modules.data.entity.Staff +import jakarta.persistence.* +import jakarta.validation.constraints.NotNull + +@Entity +@Table(name = "leave") +open class Leave : BaseEntity() { + @Column(name = "leaveHours") + open var leaveHours: Double? = null + + @NotNull + @ManyToOne + @JoinColumn(name = "staffId") + open var staff: Staff? = null + + @NotNull + @ManyToOne + @JoinColumn(name = "leaveTypeId") + open var leaveType: LeaveType? = null +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/entity/LeaveRepository.kt b/src/main/java/com/ffii/tsms/modules/timesheet/entity/LeaveRepository.kt new file mode 100644 index 0000000..fc5bdf7 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/timesheet/entity/LeaveRepository.kt @@ -0,0 +1,6 @@ +package com.ffii.tsms.modules.timesheet.entity; + +import com.ffii.core.support.AbstractRepository + +interface LeaveRepository : AbstractRepository { +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/entity/LeaveType.kt b/src/main/java/com/ffii/tsms/modules/timesheet/entity/LeaveType.kt new file mode 100644 index 0000000..c5a558c --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/timesheet/entity/LeaveType.kt @@ -0,0 +1,15 @@ +package com.ffii.tsms.modules.timesheet.entity + +import com.ffii.core.entity.IdEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull + +@Entity +@Table(name = "leave_type") +open class LeaveType : IdEntity() { + @NotNull + @Column(name = "name") + open var name: String? = null +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/entity/Timesheet.kt b/src/main/java/com/ffii/tsms/modules/timesheet/entity/Timesheet.kt new file mode 100644 index 0000000..87058bf --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/timesheet/entity/Timesheet.kt @@ -0,0 +1,31 @@ +package com.ffii.tsms.modules.timesheet.entity + +import com.ffii.core.entity.BaseEntity +import com.ffii.tsms.modules.data.entity.Staff +import com.ffii.tsms.modules.project.entity.ProjectTask +import jakarta.persistence.* +import jakarta.validation.constraints.NotNull +import java.time.LocalDate + +@Entity +@Table(name = "timesheet") +open class Timesheet : BaseEntity() { + @Column(name = "normalConsumed") + open var normalConsumed: Double? = null + + @Column(name = "otConsumed") + open var otConsumed: Double? = null + + @NotNull + @Column(name = "recordDate") + open var recordDate: LocalDate? = null + + @NotNull + @ManyToOne + @JoinColumn(name = "staffId") + open var staff: Staff? = null + + @ManyToOne + @JoinColumn(name = "projectTaskId") + open var projectTask: ProjectTask? = null +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/entity/TimesheetRepository.kt b/src/main/java/com/ffii/tsms/modules/timesheet/entity/TimesheetRepository.kt new file mode 100644 index 0000000..148ab90 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/timesheet/entity/TimesheetRepository.kt @@ -0,0 +1,12 @@ +package com.ffii.tsms.modules.timesheet.entity; + +import com.ffii.core.support.AbstractRepository +import com.ffii.tsms.modules.data.entity.Staff +import java.time.LocalDate + +interface TimesheetRepository : AbstractRepository { + + fun findAllByStaff(staff: Staff): List + + fun deleteAllByStaffAndRecordDate(staff: Staff, recordDate: LocalDate) +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/service/TimesheetsService.kt b/src/main/java/com/ffii/tsms/modules/timesheet/service/TimesheetsService.kt new file mode 100644 index 0000000..dddef98 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/timesheet/service/TimesheetsService.kt @@ -0,0 +1,82 @@ +package com.ffii.tsms.modules.timesheet.service + +import com.ffii.core.exception.BadRequestException +import com.ffii.tsms.modules.data.service.StaffsService +import com.ffii.tsms.modules.project.entity.ProjectRepository +import com.ffii.tsms.modules.project.entity.ProjectTaskRepository +import com.ffii.tsms.modules.project.entity.TaskRepository +import com.ffii.tsms.modules.timesheet.entity.Timesheet +import com.ffii.tsms.modules.timesheet.entity.TimesheetRepository +import com.ffii.tsms.modules.timesheet.web.models.TimeEntry +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import kotlin.jvm.optionals.getOrDefault +import kotlin.jvm.optionals.getOrNull + +@Service + +open class TimesheetsService( + private val timesheetRepository: TimesheetRepository, + private val projectTaskRepository: ProjectTaskRepository, + private val projectRepository: ProjectRepository, + private val taskRepository: TaskRepository, + private val staffsService: StaffsService + ) { + @Transactional + open fun saveTimesheet(recordTimeEntry: Map>): Map> { + // Need to be associated with a staff + val currentStaff = staffsService.currentStaff() ?: throw BadRequestException() + + val timesheetEntries = recordTimeEntry.entries.flatMap { (entryDate, timeEntries) -> + // Replace db timesheet entries by deleting and then adding back + timesheetRepository.deleteAllByStaffAndRecordDate(currentStaff, entryDate) + + mergeTimeEntriesByProjectAndTask(timeEntries).map { timeEntry -> + val task = timeEntry.taskId?.let { taskRepository.findById(it).getOrNull() } + val project = timeEntry.projectId?.let { projectRepository.findById(it).getOrNull() } + val projectTask = project?.let { p -> task?.let { t -> projectTaskRepository.findByProjectAndTask(p, t) } } + + Timesheet().apply { + this.staff = currentStaff + this.recordDate = entryDate + this.normalConsumed = timeEntry.inputHours + this.projectTask = projectTask + } + } + } + + val savedTimesheets = timesheetRepository.saveAll(timesheetEntries) + + return transformToTimeEntryMap(savedTimesheets) + } + + open fun getTimesheet(): Map> { + // Need to be associated with a staff + val currentStaff = staffsService.currentStaff() ?: throw BadRequestException() + return transformToTimeEntryMap(timesheetRepository.findAllByStaff(currentStaff)) + } + + private fun transformToTimeEntryMap(timesheets: List): Map> { + return timesheets + .groupBy { timesheet -> timesheet.recordDate!!.format(DateTimeFormatter.ISO_LOCAL_DATE) } + .mapValues { (_, timesheets) -> timesheets.map { timesheet -> + TimeEntry( + id = timesheet.id!!, + projectId = timesheet.projectTask?.project?.id, + taskId = timesheet.projectTask?.task?.id, + taskGroupId = timesheet.projectTask?.task?.taskGroup?.id, + inputHours = timesheet.normalConsumed ?: 0.0 + ) + } } + } + + private fun mergeTimeEntriesByProjectAndTask(entries: List): List { + return entries + .groupBy { timeEntry -> Pair(timeEntry.projectId, timeEntry.taskId) } + .values.map { timeEntries -> + timeEntries.reduce { acc, timeEntry -> acc.copy(inputHours = acc.inputHours + timeEntry.inputHours) } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/web/TimesheetsController.kt b/src/main/java/com/ffii/tsms/modules/timesheet/web/TimesheetsController.kt new file mode 100644 index 0000000..6d86e80 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/timesheet/web/TimesheetsController.kt @@ -0,0 +1,34 @@ +package com.ffii.tsms.modules.timesheet.web + +import com.ffii.core.exception.BadRequestException +import com.ffii.tsms.modules.timesheet.service.TimesheetsService +import com.ffii.tsms.modules.timesheet.web.models.TimeEntry +import jakarta.validation.Valid +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@RestController +@RequestMapping("/timesheets") +class TimesheetsController(private val timesheetsService: TimesheetsService) { + @PostMapping("/save") + fun newTimesheetEntry(@Valid @RequestBody recordTimesheet: Map>): Map> { + val parseDateTimeResult = kotlin.runCatching { + recordTimesheet.mapKeys { (dateString) -> + LocalDate.parse(dateString, DateTimeFormatter.ISO_LOCAL_DATE) + } + } + val parsedEntries = parseDateTimeResult.getOrElse { throw BadRequestException() } + + return timesheetsService.saveTimesheet(parsedEntries) + } + + @GetMapping + fun getTimesheetEntry(): Map> { + return timesheetsService.getTimesheet() + } +} \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/web/models/TimeEntry.kt b/src/main/java/com/ffii/tsms/modules/timesheet/web/models/TimeEntry.kt new file mode 100644 index 0000000..db218f3 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/timesheet/web/models/TimeEntry.kt @@ -0,0 +1,10 @@ +package com.ffii.tsms.modules.timesheet.web.models + + +data class TimeEntry( + val id: Long, + val projectId: Long?, + val taskGroupId: Long?, + val taskId: Long?, + val inputHours: Double +) \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20240504_01_wayne/01_timesheet.sql b/src/main/resources/db/changelog/changes/20240504_01_wayne/01_timesheet.sql new file mode 100644 index 0000000..c71c847 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20240504_01_wayne/01_timesheet.sql @@ -0,0 +1,24 @@ +-- liquibase formatted sql +-- changeset wayne:timesheet + +DROP TABLE timesheet; + +CREATE TABLE timesheet ( + id INT NOT NULL AUTO_INCREMENT, + version INT NOT NULL DEFAULT '0', + created datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + createdBy VARCHAR(30) NULL, + modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + modifiedBy VARCHAR(30) NULL, + deleted TINYINT(1) NOT NULL DEFAULT '0', + normalConsumed DOUBLE NULL, + otConsumed DOUBLE NULL, + recordDate date NOT NULL, + staffId INT NOT NULL, + projectTaskId INT NULL, + CONSTRAINT pk_timesheet PRIMARY KEY (id) +); + +ALTER TABLE timesheet ADD CONSTRAINT FK_TIMESHEET_ON_PROJECTTASKID FOREIGN KEY (projectTaskId) REFERENCES project_task (id); + +ALTER TABLE timesheet ADD CONSTRAINT FK_TIMESHEET_ON_STAFFID FOREIGN KEY (staffId) REFERENCES staff (id); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20240504_01_wayne/02_leave_type.sql b/src/main/resources/db/changelog/changes/20240504_01_wayne/02_leave_type.sql new file mode 100644 index 0000000..b5a5339 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20240504_01_wayne/02_leave_type.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql +-- changeset wayne:leave_type + +CREATE TABLE leave_type ( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + CONSTRAINT pk_leave_type PRIMARY KEY (id) +); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20240504_01_wayne/03_leave.sql b/src/main/resources/db/changelog/changes/20240504_01_wayne/03_leave.sql new file mode 100644 index 0000000..ba13ac3 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20240504_01_wayne/03_leave.sql @@ -0,0 +1,22 @@ +-- liquibase formatted sql +-- changeset wayne:leave + +DROP TABLE `leave`; + +CREATE TABLE `leave` ( + id INT NOT NULL AUTO_INCREMENT, + version INT NOT NULL DEFAULT '0', + created datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + createdBy VARCHAR(30) NULL, + modified datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + modifiedBy VARCHAR(30) NULL, + deleted TINYINT(1) NOT NULL DEFAULT '0', + leaveHours DOUBLE NULL, + staffId INT NOT NULL, + leaveTypeId INT NOT NULL, + CONSTRAINT pk_leave PRIMARY KEY (id) +); + +ALTER TABLE `leave` ADD CONSTRAINT FK_LEAVE_ON_LEAVETYPEID FOREIGN KEY (leaveTypeId) REFERENCES leave_type (id); + +ALTER TABLE `leave` ADD CONSTRAINT FK_LEAVE_ON_STAFFID FOREIGN KEY (staffId) REFERENCES staff (id); \ No newline at end of file