ProjectController.java
package de.mirkosertic.powerstaff.project.api;
import de.mirkosertic.powerstaff.project.command.FreelancerAlreadyAssignedException;
import de.mirkosertic.powerstaff.project.command.BothFKsException;
import de.mirkosertic.powerstaff.project.command.Project;
import de.mirkosertic.powerstaff.project.command.ProjectCommandService;
import de.mirkosertic.powerstaff.project.command.ProjectPositionCommandService;
import de.mirkosertic.powerstaff.project.command.RememberedProjectService;
import de.mirkosertic.powerstaff.project.query.ProjectHistoryQueryService;
import de.mirkosertic.powerstaff.project.query.ProjectPositionQueryService;
import de.mirkosertic.powerstaff.project.query.ProjectQueryService;
import de.mirkosertic.powerstaff.project.command.RememberedProjectInfo;
import de.mirkosertic.powerstaff.project.query.ProjectSearchCriteria;
import de.mirkosertic.powerstaff.shared.query.ProjectPositionStatusQueryService;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.security.Principal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
@Controller
@RequestMapping("/project")
public class ProjectController {
private static final int PAGE_SIZE = 20;
private static final DateTimeFormatter AUDIT_DATE_FMT = DateTimeFormatter.ofPattern("dd.MM.yyyy");
private final ProjectCommandService commandService;
private final ProjectQueryService queryService;
private final ProjectHistoryQueryService historyQueryService;
private final ProjectPositionQueryService positionQueryService;
private final ProjectPositionCommandService positionCommandService;
private final ProjectPositionStatusQueryService statusQueryService;
private final RememberedProjectService rememberedProjectService;
public ProjectController(final ProjectCommandService commandService,
final ProjectQueryService queryService,
final ProjectHistoryQueryService historyQueryService,
final ProjectPositionQueryService positionQueryService,
final ProjectPositionCommandService positionCommandService,
final ProjectPositionStatusQueryService statusQueryService,
final RememberedProjectService rememberedProjectService) {
this.commandService = commandService;
this.queryService = queryService;
this.historyQueryService = historyQueryService;
this.positionQueryService = positionQueryService;
this.positionCommandService = positionCommandService;
this.statusQueryService = statusQueryService;
this.rememberedProjectService = rememberedProjectService;
}
// -------------------------------------------------------------------------
// Navigation
// -------------------------------------------------------------------------
@GetMapping
public String index(final Principal principal, final Model model) {
final var rememberedId = rememberedProjectService.get(principal.getName());
if (rememberedId.isPresent() && commandService.findById(rememberedId.get()).isPresent()) {
return "redirect:/project/" + rememberedId.get();
}
populateBlankModel(model, new Project(), principal);
return "project/form";
}
@GetMapping("/first")
public String first(final Principal principal) {
return queryService.findFirst()
.map(p -> setAndRedirect(principal, p.id()))
.orElse("redirect:/project");
}
@GetMapping("/last")
public String last(final Principal principal) {
return queryService.findLast()
.map(p -> setAndRedirect(principal, p.id()))
.orElse("redirect:/project");
}
@GetMapping("/previous/{id}")
public String previous(@PathVariable final long id, final Principal principal) {
return queryService.findPrevious(id)
.map(p -> setAndRedirect(principal, p.id()))
.orElse("redirect:/project");
}
@GetMapping("/next/{id}")
public String next(@PathVariable final long id, final Principal principal) {
return queryService.findNext(id)
.map(p -> setAndRedirect(principal, p.id()))
.orElse("redirect:/project");
}
private String setAndRedirect(final Principal principal, final long id) {
rememberedProjectService.set(principal.getName(), id);
return "redirect:/project/" + id;
}
// -------------------------------------------------------------------------
// Anzeigen / Neuanlage
// -------------------------------------------------------------------------
@GetMapping("/{id}")
public String show(@PathVariable final long id, final Principal principal, final Model model) {
final var project = commandService.findById(id).orElseThrow();
rememberedProjectService.set(principal.getName(), id);
populateModel(model, project, id, principal);
return "project/form";
}
@GetMapping("/new")
public String newForm(final Principal principal, final Model model) {
populateBlankModel(model, new Project(), principal);
return "project/form";
}
@GetMapping("/new-from-kunde/{kundeId}")
public String newFromKunde(@PathVariable final Long kundeId, final Principal principal, final Model model) {
final var project = new Project();
project.setCustomerId(kundeId);
populateBlankModel(model, project, principal);
return "project/form";
}
@GetMapping("/new-from-partner/{partnerId}")
public String newFromPartner(@PathVariable final Long partnerId, final Principal principal, final Model model) {
final var project = new Project();
project.setPartnerId(partnerId);
populateBlankModel(model, project, principal);
return "project/form";
}
private void populateModel(final Model model, final Project project, final Long projectId, final Principal principal) {
model.addAttribute("project", project);
model.addAttribute("history", projectId != null ? historyQueryService.findByProjectId(projectId) : List.of());
model.addAttribute("positions", projectId != null ? positionQueryService.findByProjectId(projectId, null, null) : List.of());
model.addAttribute("positionStatuses", statusQueryService.findAll());
model.addAttribute("rememberedProject", buildRememberedProjectInfo(principal));
model.addAttribute("activePage", "project");
model.addAttribute("auditInfo", buildAuditInfo(
projectId,
project.getCreationDate(), project.getCreationUser(),
project.getChangedDate(), project.getChangedUser()));
}
private String buildAuditInfo(final Long id, final LocalDateTime creationDate, final String creationUser,
final LocalDateTime changedDate, final String changedUser) {
if (id == null) return "Neu, noch nicht gespeichert";
final String created = (creationDate != null ? creationDate.format(AUDIT_DATE_FMT) : "?")
+ " " + (creationUser != null ? creationUser : "?");
String result = "Erfasst: " + created;
if (changedDate != null && !changedDate.equals(creationDate)) {
result += "<br>Geändert: "
+ changedDate.format(AUDIT_DATE_FMT)
+ " " + (changedUser != null ? changedUser : "?");
}
return result;
}
private void populateBlankModel(final Model model, final Project project, final Principal principal) {
populateModel(model, project, null, principal);
}
private RememberedProjectInfo buildRememberedProjectInfo(final Principal principal) {
return rememberedProjectService.getRememberedProjectInfo(principal.getName()).orElse(null);
}
// -------------------------------------------------------------------------
// Speichern
// -------------------------------------------------------------------------
@PostMapping("/save")
@ResponseBody
public ResponseEntity<?> save(@ModelAttribute final Project project,
final Principal principal,
final HttpServletResponse response) throws IOException {
try {
final var saved = commandService.save(project);
rememberedProjectService.set(principal.getName(), saved.getId());
response.sendRedirect("/project/" + saved.getId() + "?saved=true");
return null;
} catch (final OptimisticLockingFailureException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("conflict", true));
} catch (final BothFKsException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("bothFks", true));
}
}
// -------------------------------------------------------------------------
// Löschen
// -------------------------------------------------------------------------
@PostMapping("/delete/{id}")
@ResponseBody
public ResponseEntity<?> delete(@PathVariable final long id,
final Principal principal,
final HttpServletResponse response) throws IOException {
commandService.deleteById(id);
rememberedProjectService.clear(principal.getName());
response.sendRedirect("/project");
return null;
}
// -------------------------------------------------------------------------
// QBE-Suche
// -------------------------------------------------------------------------
@GetMapping("/search")
public String search(@ModelAttribute final ProjectSearchCriteria criteria,
@RequestParam(required = false, defaultValue = "0") final int offset,
final Model model,
final HttpServletResponse response) {
if (offset > 0) {
final var results = queryService.search(criteria, offset, PAGE_SIZE);
final int nextOffset = offset + PAGE_SIZE;
final long total = queryService.countSearch(criteria);
if (nextOffset < total) {
response.setHeader("X-Next-Url", buildSearchMoreUrl(criteria, nextOffset));
}
model.addAttribute("results", results);
return "project/search-results :: results";
}
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
final var results = queryService.search(criteria, 0, PAGE_SIZE);
final long total = queryService.countSearch(criteria);
model.addAttribute("results", results);
model.addAttribute("totalCount", total);
model.addAttribute("criteria", criteria);
model.addAttribute("sortField", criteria.sortField());
model.addAttribute("sortDir", criteria.sortDir());
final String nextUrl = results.size() == PAGE_SIZE ? buildSearchMoreUrl(criteria, PAGE_SIZE) : null;
model.addAttribute("nextUrl", nextUrl);
model.addAttribute("editSearchUrl", buildEditSearchUrl(criteria));
return "project/search-page";
}
// -------------------------------------------------------------------------
// Positionen (AJAX)
// -------------------------------------------------------------------------
@GetMapping("/{id}/positions")
@ResponseBody
public ResponseEntity<?> getPositions(@PathVariable final long id,
@RequestParam(required = false) final String sortField,
@RequestParam(required = false) final String sortDir) {
return ResponseEntity.ok(positionQueryService.findByProjectId(id, sortField, sortDir));
}
record PositionRequest(Long statusId, String konditionen, String kommentar, Long dbVersion) {}
@PostMapping("/{projectId}/positions/{posId}")
@ResponseBody
public ResponseEntity<?> savePosition(@PathVariable final long projectId,
@PathVariable final long posId,
@RequestBody final PositionRequest request) {
try {
positionCommandService.updateEditable(posId, request.statusId(), request.konditionen(), request.kommentar(), request.dbVersion());
return ResponseEntity.ok(positionQueryService.findByProjectId(projectId, null, null));
} catch (final OptimisticLockingFailureException e) {
return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("conflict", true));
}
}
@PostMapping("/{projectId}/positions/{posId}/delete")
@ResponseBody
public ResponseEntity<?> deletePosition(@PathVariable final long projectId,
@PathVariable final long posId) {
positionCommandService.delete(posId);
return ResponseEntity.ok(positionQueryService.findByProjectId(projectId, null, null));
}
record AssignByIdRequest(Long freelancerId) {}
@PostMapping("/{projectId}/positions/assign")
@ResponseBody
public ResponseEntity<?> assignById(@PathVariable final long projectId,
@RequestBody final AssignByIdRequest request) {
if (request.freelancerId() == null) {
return ResponseEntity.badRequest().body(Map.of("error", "freelancerId required"));
}
try {
positionCommandService.assignFreelancerToProject(
request.freelancerId(), projectId, null, null, null);
return ResponseEntity.ok(positionQueryService.findByProjectId(projectId, null, null));
} catch (final FreelancerAlreadyAssignedException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("alreadyAssigned", true));
}
}
private void appendCriteriaParams(final UriComponentsBuilder b, final ProjectSearchCriteria c) {
if (c.projectNumber() != null) b.queryParam("projectNumber", c.projectNumber());
if (c.descriptionShort() != null) b.queryParam("descriptionShort", c.descriptionShort());
if (c.descriptionLong() != null) b.queryParam("descriptionLong", c.descriptionLong());
if (c.skills() != null) b.queryParam("skills", c.skills());
if (c.workplace() != null) b.queryParam("workplace", c.workplace());
if (c.duration() != null) b.queryParam("duration", c.duration());
if (c.status() != null) b.queryParam("status", c.status());
if (c.debitorNr() != null) b.queryParam("debitorNr", c.debitorNr());
if (c.kreditorNr() != null) b.queryParam("kreditorNr", c.kreditorNr());
if (c.sortField() != null) b.queryParam("sortField", c.sortField());
if (c.sortDir() != null) b.queryParam("sortDir", c.sortDir());
}
private String buildEditSearchUrl(final ProjectSearchCriteria c) {
final var b = UriComponentsBuilder.fromPath("/project/new");
appendCriteriaParams(b, c);
return b.encode().build().toUriString();
}
private String buildSearchMoreUrl(final ProjectSearchCriteria c, final int offset) {
final var b = UriComponentsBuilder.fromPath("/project/search").queryParam("offset", offset);
appendCriteriaParams(b, c);
return b.encode().build().toUriString();
}
}