feat: add admin occupancy and reservation history reports

This commit is contained in:
dualfroz committed 2026-08-27 14:24:07 +02:00
1 parent 2bdd119246
commit aec449cc81
4 files changed
+137

No files matched your search

@@ -0,0 +1,42 @@
package com.slotline.report;
import com.slotline.report.dto.OccupancyReport;
import com.slotline.report.dto.ReservationHistoryEntry;
import com.slotline.security.AuthenticatedUser;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
@RestController
@RequestMapping("/api/admin/reports")
@Tag(name = "Admin reports")
public class AdminReportController {
private final AdminReportService reportService;
public AdminReportController(AdminReportService reportService) {
this.reportService = reportService;
}
@GetMapping("/resources/{resourceId}/occupancy")
@Operation(summary = "Occupancy of a resource over a time window (ADMIN only)")
public OccupancyReport occupancy(@AuthenticationPrincipal AuthenticatedUser actor, @PathVariable Long resourceId,
@RequestParam String from, @RequestParam String until) {
return reportService.occupancy(actor.organizationId(), resourceId, Instant.parse(from), Instant.parse(until));
}
@GetMapping("/resources/{resourceId}/history")
@Operation(summary = "Full reservation history of a resource (ADMIN only)")
public List<ReservationHistoryEntry> history(@AuthenticationPrincipal AuthenticatedUser actor,
@PathVariable Long resourceId) {
return reportService.history(actor.organizationId(), resourceId);
}
}
@@ -0,0 +1,54 @@
package com.slotline.report;
import com.slotline.report.dto.OccupancyReport;
import com.slotline.report.dto.ReservationHistoryEntry;
import com.slotline.reservation.Reservation;
import com.slotline.reservation.ReservationRepository;
import com.slotline.resource.Resource;
import com.slotline.resource.ResourceService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@Service
public class AdminReportService {
private final ReservationRepository reservations;
private final ResourceService resources;
public AdminReportService(ReservationRepository reservations, ResourceService resources) {
this.reservations = reservations;
this.resources = resources;
}
@Transactional(readOnly = true)
public OccupancyReport occupancy(Long organizationId, Long resourceId, Instant from, Instant until) {
Resource resource = resources.require(organizationId, resourceId);
List<Reservation> active = reservations.findActiveForResourceBetween(resource.getId(), from, until);
long bookedMinutes = active.stream()
.mapToLong(reservation -> clampedMinutes(reservation, from, until))
.sum();
long windowMinutes = Duration.between(from, until).toMinutes();
double utilization = windowMinutes > 0 ? (bookedMinutes * 100.0) / windowMinutes : 0.0;
return new OccupancyReport(resource.getId(), resource.getName(), from, until, active.size(), bookedMinutes,
windowMinutes, Math.round(utilization * 100.0) / 100.0);
}
@Transactional(readOnly = true)
public List<ReservationHistoryEntry> history(Long organizationId, Long resourceId) {
Resource resource = resources.require(organizationId, resourceId);
return reservations.findByResourceIdOrderByStartTimeAsc(resource.getId()).stream()
.map(ReservationHistoryEntry::from)
.toList();
}
private long clampedMinutes(Reservation reservation, Instant from, Instant until) {
Instant start = reservation.getStartTime().isBefore(from) ? from : reservation.getStartTime();
Instant end = reservation.getEndTime().isAfter(until) ? until : reservation.getEndTime();
long minutes = Duration.between(start, end).toMinutes();
return Math.max(minutes, 0);
}
}
@@ -0,0 +1,14 @@
package com.slotline.report.dto;
import java.time.Instant;
public record OccupancyReport(
Long resourceId,
String resourceName,
Instant from,
Instant until,
int reservationCount,
long bookedMinutes,
long windowMinutes,
double utilizationPercent) {
}
@@ -0,0 +1,27 @@
package com.slotline.report.dto;
import com.slotline.reservation.Reservation;
import com.slotline.reservation.ReservationStatus;
import java.time.Instant;
public record ReservationHistoryEntry(
Long id,
Long userId,
Instant startTime,
Instant endTime,
ReservationStatus status,
String seriesId,
Instant createdAt) {
public static ReservationHistoryEntry from(Reservation reservation) {
return new ReservationHistoryEntry(
reservation.getId(),
reservation.getUserId(),
reservation.getStartTime(),
reservation.getEndTime(),
reservation.getStatus(),
reservation.getSeriesId(),
reservation.getCreatedAt());
}
}