| @@ -2,6 +2,7 @@ package com.ffii.tsms.modules.data.service | |||||
| import com.ffii.core.support.AbstractBaseEntityService | import com.ffii.core.support.AbstractBaseEntityService | ||||
| import com.ffii.core.support.JdbcDao | 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.* | ||||
| import com.ffii.tsms.modules.data.entity.projections.StaffSearchInfo | import com.ffii.tsms.modules.data.entity.projections.StaffSearchInfo | ||||
| import com.ffii.tsms.modules.data.web.models.NewStaffRequest | 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 org.springframework.transaction.annotation.Transactional | ||||
| import java.util.* | import java.util.* | ||||
| import java.util.stream.Collectors | import java.util.stream.Collectors | ||||
| import kotlin.jvm.optionals.getOrNull | |||||
| @Service | @Service | ||||
| @@ -225,4 +227,10 @@ open class StaffsService( | |||||
| ) | ) | ||||
| return jdbcDao.queryForList(sql.toString(), args) | return jdbcDao.queryForList(sql.toString(), args) | ||||
| } | } | ||||
| open fun currentStaff(): Staff? { | |||||
| return SecurityUtils.getUser().getOrNull()?.let { user -> | |||||
| staffRepository.findByUserId(user.id).getOrNull() | |||||
| } | |||||
| } | |||||
| } | } | ||||
| @@ -4,4 +4,6 @@ import com.ffii.core.support.AbstractRepository | |||||
| interface ProjectTaskRepository : AbstractRepository<ProjectTask, Long> { | interface ProjectTaskRepository : AbstractRepository<ProjectTask, Long> { | ||||
| fun findAllByProject(project: Project): List<ProjectTask> | fun findAllByProject(project: Project): List<ProjectTask> | ||||
| fun findByProjectAndTask(project: Project, task: Task): ProjectTask | |||||
| } | } | ||||
| @@ -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<Long>() { | |||||
| @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 | |||||
| } | |||||
| @@ -0,0 +1,6 @@ | |||||
| package com.ffii.tsms.modules.timesheet.entity; | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| interface LeaveRepository : AbstractRepository<Leave, Long> { | |||||
| } | |||||
| @@ -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<Long>() { | |||||
| @NotNull | |||||
| @Column(name = "name") | |||||
| open var name: String? = null | |||||
| } | |||||
| @@ -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<Long>() { | |||||
| @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 | |||||
| } | |||||
| @@ -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<Timesheet, Long> { | |||||
| fun findAllByStaff(staff: Staff): List<Timesheet> | |||||
| fun deleteAllByStaffAndRecordDate(staff: Staff, recordDate: LocalDate) | |||||
| } | |||||
| @@ -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<LocalDate, List<TimeEntry>>): Map<String, List<TimeEntry>> { | |||||
| // 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<String, List<TimeEntry>> { | |||||
| // Need to be associated with a staff | |||||
| val currentStaff = staffsService.currentStaff() ?: throw BadRequestException() | |||||
| return transformToTimeEntryMap(timesheetRepository.findAllByStaff(currentStaff)) | |||||
| } | |||||
| private fun transformToTimeEntryMap(timesheets: List<Timesheet>): Map<String, List<TimeEntry>> { | |||||
| 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<TimeEntry>): List<TimeEntry> { | |||||
| return entries | |||||
| .groupBy { timeEntry -> Pair(timeEntry.projectId, timeEntry.taskId) } | |||||
| .values.map { timeEntries -> | |||||
| timeEntries.reduce { acc, timeEntry -> acc.copy(inputHours = acc.inputHours + timeEntry.inputHours) } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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<String, List<TimeEntry>>): Map<String, List<TimeEntry>> { | |||||
| 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<String, List<TimeEntry>> { | |||||
| return timesheetsService.getTimesheet() | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| ) | |||||
| @@ -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); | |||||
| @@ -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) | |||||
| ); | |||||
| @@ -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); | |||||