1. Slow Team Member Dropdown - Fixed - Added cached team members store with 5-minute TTL - Dropdown now loads instantly on subsequent visits 2. Error Preventing Capacity Save - Fixed - Added saveAvailability API endpoint - Added backend service method to persist availability overrides - Added proper error handling and success feedback - Cache invalidation on save 3. Filters Not Working - Fixed - Fixed PTOManager to use shared selectedMemberId - Filters now react to team member selection Test Results: - Backend: 76 passed ✅ - Frontend Unit: 10 passed ✅ - E2E: 130 passed, 24 skipped ✅ Refs: openspec/changes/headroom-foundation
199 lines
6.2 KiB
PHP
199 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Resources\CapacityResource;
|
|
use App\Http\Resources\RevenueResource;
|
|
use App\Http\Resources\TeamCapacityResource;
|
|
use App\Http\Resources\TeamMemberAvailabilityResource;
|
|
use App\Models\TeamMember;
|
|
use App\Services\CapacityService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\Rule;
|
|
|
|
class CapacityController extends Controller
|
|
{
|
|
public function __construct(protected CapacityService $capacityService) {}
|
|
|
|
/**
|
|
* Get Individual Capacity
|
|
*
|
|
* Calculate capacity for a specific team member in a given month.
|
|
*
|
|
* @group Capacity Planning
|
|
*
|
|
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
|
* @urlParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* @response {
|
|
* "data": {
|
|
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "month": "2026-02",
|
|
* "working_days": 20,
|
|
* "person_days": 18.5,
|
|
* "hours": 148,
|
|
* "details": [
|
|
* {
|
|
* "date": "2026-02-02",
|
|
* "availability": 1,
|
|
* "is_pto": false
|
|
* }
|
|
* ]
|
|
* }
|
|
* }
|
|
*/
|
|
public function individual(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'month' => 'required|date_format:Y-m',
|
|
'team_member_id' => 'required|exists:team_members,id',
|
|
]);
|
|
|
|
$capacity = $this->capacityService->calculateIndividualCapacity($data['team_member_id'], $data['month']);
|
|
$workingDays = $this->capacityService->calculateWorkingDays($data['month']);
|
|
|
|
$payload = [
|
|
'team_member_id' => $data['team_member_id'],
|
|
'month' => $data['month'],
|
|
'working_days' => $workingDays,
|
|
'person_days' => $capacity['person_days'],
|
|
'hours' => $capacity['hours'],
|
|
'details' => $capacity['details'],
|
|
];
|
|
|
|
return $this->wrapResource(new CapacityResource($payload));
|
|
}
|
|
|
|
/**
|
|
* Get Team Capacity
|
|
*
|
|
* Summarize the combined capacity for all active team members in a month.
|
|
*
|
|
* @group Capacity Planning
|
|
*
|
|
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
|
*
|
|
* @response {
|
|
* "data": {
|
|
* "month": "2026-02",
|
|
* "total_person_days": 180.5,
|
|
* "total_hours": 1444,
|
|
* "members": [
|
|
* {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "name": "Ada Lovelace",
|
|
* "person_days": 18.5,
|
|
* "hours": 148
|
|
* }
|
|
* ]
|
|
* }
|
|
* }
|
|
*/
|
|
public function team(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'month' => 'required|date_format:Y-m',
|
|
]);
|
|
|
|
$payload = $this->capacityService->calculateTeamCapacity($data['month']);
|
|
|
|
return $this->wrapResource(new TeamCapacityResource($payload));
|
|
}
|
|
|
|
/**
|
|
* Get Possible Revenue
|
|
*
|
|
* Estimate monthly revenue based on capacity hours and hourly rates.
|
|
*
|
|
* @group Capacity Planning
|
|
*
|
|
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
|
*
|
|
* @response {
|
|
* "data": {
|
|
* "month": "2026-02",
|
|
* "possible_revenue": 21500.25,
|
|
* "member_revenues": [
|
|
* {
|
|
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "team_member_name": "Ada Lovelace",
|
|
* "hours": 148,
|
|
* "hourly_rate": 150.0,
|
|
* "revenue": 22200.0
|
|
* }
|
|
* ]
|
|
* }
|
|
* }
|
|
*/
|
|
public function revenue(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'month' => 'required|date_format:Y-m',
|
|
]);
|
|
|
|
$revenue = $this->capacityService->calculatePossibleRevenue($data['month']);
|
|
$memberRevenues = [];
|
|
|
|
TeamMember::where('active', true)
|
|
->get()
|
|
->each(function (TeamMember $member) use ($data, &$memberRevenues): void {
|
|
$capacity = $this->capacityService->calculateIndividualCapacity($member->id, $data['month']);
|
|
$hours = $capacity['hours'];
|
|
$hourlyRate = $member->hourly_rate !== null ? (float) $member->hourly_rate : null;
|
|
$memberRevenue = $hourlyRate !== null ? round($hours * $hourlyRate, 2) : 0.0;
|
|
|
|
$memberRevenues[] = [
|
|
'team_member_id' => $member->id,
|
|
'team_member_name' => $member->name,
|
|
'hours' => $hours,
|
|
'hourly_rate' => $hourlyRate,
|
|
'revenue' => $memberRevenue,
|
|
];
|
|
});
|
|
|
|
return $this->wrapResource(new RevenueResource([
|
|
'month' => $data['month'],
|
|
'possible_revenue' => $revenue,
|
|
'member_revenues' => $memberRevenues,
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* Save Team Member Availability
|
|
*
|
|
* Persist a daily availability override and refresh cached capacity totals.
|
|
*
|
|
* @group Capacity Planning
|
|
*
|
|
* @bodyParam team_member_id string required The team member UUID.
|
|
* @bodyParam date string required The date for the availability override (YYYY-MM-DD).
|
|
* @bodyParam availability numeric required The availability value (0, 0.5, 1.0).
|
|
*
|
|
* @response 201 {
|
|
* "data": {
|
|
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "date": "2026-02-03",
|
|
* "availability": 0.5
|
|
* }
|
|
* }
|
|
*/
|
|
public function saveAvailability(Request $request): JsonResponse
|
|
{
|
|
$data = $request->validate([
|
|
'team_member_id' => 'required|exists:team_members,id',
|
|
'date' => 'required|date_format:Y-m-d',
|
|
'availability' => ['required', 'numeric', Rule::in([0, 0.5, 1])],
|
|
]);
|
|
|
|
$entry = $this->capacityService->upsertTeamMemberAvailability(
|
|
$data['team_member_id'],
|
|
$data['date'],
|
|
(float) $data['availability']
|
|
);
|
|
|
|
return $this->wrapResource(new TeamMemberAvailabilityResource($entry), 201);
|
|
}
|
|
}
|