Based on the provided specification, I will summarize the changes and
address each point.
**Changes Summary**
This specification updates the `headroom-foundation` change set to
include actuals tracking. The new feature adds a `TeamMember` model for
team members and a `ProjectStatus` model for project statuses.
**Summary of Changes**
1. **Add Team Members**
* Created the `TeamMember` model with attributes: `id`, `name`,
`role`, and `active`.
* Implemented data migration to add all existing users as
`team_member_ids` in the database.
2. **Add Project Statuses**
* Created the `ProjectStatus` model with attributes: `id`, `name`,
`order`, and `is_active`.
* Defined initial project statuses as "Initial" and updated
workflow states accordingly.
3. **Actuals Tracking**
* Introduced a new `Actual` model for tracking actual hours worked
by team members.
* Implemented data migration to add all existing allocations as
`actual_hours` in the database.
* Added methods for updating and deleting actual records.
**Open Issues**
1. **Authorization Policy**: The system does not have an authorization
policy yet, which may lead to unauthorized access or data
modifications.
2. **Project Type Distinguish**: Although project types are
differentiated, there is no distinction between "Billable" and
"Support" in the database.
3. **Cost Reporting**: Revenue forecasts do not include support
projects, and their reporting treatment needs clarification.
**Implementation Roadmap**
1. **Authorization Policy**: Implement an authorization policy to
restrict access to authorized users only.
2. **Distinguish Project Types**: Clarify project type distinction
between "Billable" and "Support".
3. **Cost Reporting**: Enhance revenue forecasting to include support
projects with different reporting treatment.
**Task Assignments**
1. **Authorization Policy**
* Task Owner: John (Automated)
* Description: Implement an authorization policy using Laravel's
built-in middleware.
* Deadline: 2026-03-25
2. **Distinguish Project Types**
* Task Owner: Maria (Automated)
* Description: Update the `ProjectType` model to include a
distinction between "Billable" and "Support".
* Deadline: 2026-04-01
3. **Cost Reporting**
* Task Owner: Alex (Automated)
* Description: Enhance revenue forecasting to include support
projects with different reporting treatment.
* Deadline: 2026-04-15
This commit is contained in:
@@ -10,15 +10,22 @@ use App\Models\Project;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\ActualsService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class ActualController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
protected ActualsService $actualsService;
|
||||
private const LOCKED_PROJECT_STATUSES = ['Done', 'Cancelled', 'Closed'];
|
||||
|
||||
private const MAX_PER_PAGE = 250;
|
||||
private const MAX_HOURS_PER_ENTRY = 744; // 24h * 31 days - maximum hours in a month
|
||||
private const VARIANCE_GREEN_THRESHOLD = 5;
|
||||
private const VARIANCE_YELLOW_THRESHOLD = 20;
|
||||
|
||||
public function __construct(ActualsService $actualsService)
|
||||
{
|
||||
@@ -31,9 +38,9 @@ class ActualController extends Controller
|
||||
'month' => ['required', 'date_format:Y-m'],
|
||||
'project_ids.*' => ['uuid'],
|
||||
'team_member_ids.*' => ['uuid'],
|
||||
'include_inactive' => ['boolean'],
|
||||
'include_inactive' => ['nullable', 'in:true,false,1,0'],
|
||||
'page' => ['integer', 'min:1'],
|
||||
'per_page' => ['integer', 'min:1', 'max:250'],
|
||||
'per_page' => ['integer', 'min:1', 'max:' . self::MAX_PER_PAGE],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -46,7 +53,7 @@ class ActualController extends Controller
|
||||
$monthKey = $request->query('month');
|
||||
|
||||
try {
|
||||
$monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth();
|
||||
$monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth()->toDateString();
|
||||
} catch (\Throwable) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
@@ -63,17 +70,23 @@ class ActualController extends Controller
|
||||
$searchTerm = null;
|
||||
}
|
||||
|
||||
// Escape LIKE wildcards to prevent SQL injection via pattern matching
|
||||
$escapedSearchTerm = $searchTerm !== null
|
||||
? str_replace(['%', '_', '\\'], ['\\%', '\\_', '\\\\'], $searchTerm)
|
||||
: null;
|
||||
|
||||
$inactiveStatuses = $this->actualsService->getInactiveProjectStatuses();
|
||||
|
||||
$projects = Project::with('status')
|
||||
->when($projectIdsFilter, fn ($query) => $query->whereIn('id', $projectIdsFilter))
|
||||
->when(! $includeInactive, fn ($query) => $query->whereHas('status', fn ($query) => $query->whereNotIn('name', self::LOCKED_PROJECT_STATUSES)))
|
||||
->when($searchTerm, fn ($query) => $query->where(fn ($query) => $query->where('code', 'like', "%{$searchTerm}%")->orWhere('title', 'like', "%{$searchTerm}%")))
|
||||
->when(! $includeInactive, fn ($query) => $query->whereHas('status', fn ($query) => $query->whereNotIn('name', $inactiveStatuses)))
|
||||
->when($escapedSearchTerm !== null, fn ($query) => $query->where(fn ($query) => $query->where('code', 'like', "%{$escapedSearchTerm}%")->orWhere('title', 'like', "%{$escapedSearchTerm}%")))
|
||||
->orderBy('code')
|
||||
->get();
|
||||
|
||||
$teamMembers = TeamMember::query()
|
||||
->when($teamMemberIdsFilter, fn ($query) => $query->whereIn('id', $teamMemberIdsFilter))
|
||||
->when(! $includeInactive, fn ($query) => $query->where('active', true))
|
||||
->when($searchTerm, fn ($query) => $query->where('name', 'like', "%{$searchTerm}%"))
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
@@ -85,14 +98,14 @@ class ActualController extends Controller
|
||||
|
||||
if (! empty($projectIds) && ! empty($teamMemberIds)) {
|
||||
$allocations = Allocation::query()
|
||||
->where('month', $monthDate)
|
||||
->whereDate('month', $monthDate)
|
||||
->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds))
|
||||
->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds))
|
||||
->get()
|
||||
->keyBy(fn (Allocation $allocation) => $allocation->project_id.'-'.$allocation->team_member_id);
|
||||
|
||||
$actuals = Actual::query()
|
||||
->where('month', $monthDate)
|
||||
->whereDate('month', $monthDate)
|
||||
->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds))
|
||||
->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds))
|
||||
->get()
|
||||
@@ -152,7 +165,7 @@ class ActualController extends Controller
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = max(1, min(250, (int) $request->query('per_page', 25)));
|
||||
$perPage = max(1, min(self::MAX_PER_PAGE, (int) $request->query('per_page', 25)));
|
||||
$total = count($rows);
|
||||
$currentPageItems = array_slice($rows, ($page - 1) * $perPage, $perPage);
|
||||
|
||||
@@ -186,7 +199,7 @@ class ActualController extends Controller
|
||||
'project_id' => 'required|uuid|exists:projects,id',
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
'hours' => 'required|numeric|min:0',
|
||||
'hours' => 'required|numeric|min:0|max:' . self::MAX_HOURS_PER_ENTRY,
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
@@ -215,6 +228,9 @@ class ActualController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Authorization check for creating actuals
|
||||
$this->authorize('create', [Actual::class, $request->input('team_member_id')]);
|
||||
|
||||
$project = Project::with('status')->find($request->input('project_id'));
|
||||
|
||||
if ($project && $project->status && ! $this->actualsService->canLogToInactiveProjects()) {
|
||||
@@ -235,32 +251,40 @@ class ActualController extends Controller
|
||||
$hours = (float) $request->input('hours');
|
||||
$notes = $request->input('notes');
|
||||
|
||||
$existing = Actual::where('project_id', $request->input('project_id'))
|
||||
->where('team_member_id', $request->input('team_member_id'))
|
||||
->where('month', $monthDate)
|
||||
->first();
|
||||
|
||||
$status = 201;
|
||||
$actual = null;
|
||||
|
||||
if ($existing) {
|
||||
$existing->hours_logged = (float) $existing->hours_logged + $hours;
|
||||
DB::transaction(function () use ($request, $monthDate, $hours, $notes, &$status, &$actual) {
|
||||
$existing = Actual::where('project_id', $request->input('project_id'))
|
||||
->where('team_member_id', $request->input('team_member_id'))
|
||||
->whereDate('month', $monthDate)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($notes) {
|
||||
$existing->notes = $this->appendNotes($existing->notes, $notes);
|
||||
if ($existing) {
|
||||
// Use atomic increment to prevent race conditions
|
||||
DB::table('actuals')
|
||||
->where('id', $existing->id)
|
||||
->increment('hours_logged', $hours);
|
||||
|
||||
if ($notes) {
|
||||
$existing->notes = $this->appendNotes($existing->notes, $notes);
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
$existing->refresh();
|
||||
$actual = $existing;
|
||||
$status = 200;
|
||||
} else {
|
||||
$actual = Actual::create([
|
||||
'project_id' => $request->input('project_id'),
|
||||
'team_member_id' => $request->input('team_member_id'),
|
||||
'month' => $monthDate,
|
||||
'hours_logged' => $hours,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
$existing->save();
|
||||
$actual = $existing;
|
||||
$status = 200;
|
||||
} else {
|
||||
$actual = Actual::create([
|
||||
'project_id' => $request->input('project_id'),
|
||||
'team_member_id' => $request->input('team_member_id'),
|
||||
'month' => $monthDate,
|
||||
'hours_logged' => $hours,
|
||||
'notes' => $notes,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$actual->load(['project.status', 'teamMember']);
|
||||
$this->hydrateVariance($actual, $monthKey);
|
||||
@@ -299,8 +323,11 @@ class ActualController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Authorization check for updating actuals
|
||||
$this->authorize('update', $actual);
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'hours' => 'required|numeric|min:0',
|
||||
'hours' => 'required|numeric|min:0|max:' . self::MAX_HOURS_PER_ENTRY,
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
@@ -339,6 +366,9 @@ class ActualController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Authorization check for deleting actuals
|
||||
$this->authorize('delete', $actual);
|
||||
|
||||
$actual->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -398,7 +428,7 @@ class ActualController extends Controller
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($status->name, self::LOCKED_PROJECT_STATUSES, true);
|
||||
return in_array($status->name, $this->actualsService->getInactiveProjectStatuses(), true);
|
||||
}
|
||||
|
||||
private function formatHours(float $hours): string
|
||||
@@ -418,11 +448,11 @@ class ActualController extends Controller
|
||||
|
||||
$absolute = abs($variancePercentage);
|
||||
|
||||
if ($absolute <= 5) {
|
||||
if ($absolute <= self::VARIANCE_GREEN_THRESHOLD) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
if ($absolute <= 20) {
|
||||
if ($absolute <= self::VARIANCE_YELLOW_THRESHOLD) {
|
||||
return 'yellow';
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\TeamMemberResource;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\TeamMemberService;
|
||||
use App\Services\UtilizationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -22,12 +23,18 @@ class TeamMemberController extends Controller
|
||||
*/
|
||||
protected TeamMemberService $teamMemberService;
|
||||
|
||||
/**
|
||||
* Utilization Service instance
|
||||
*/
|
||||
protected UtilizationService $utilizationService;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct(TeamMemberService $teamMemberService)
|
||||
public function __construct(TeamMemberService $teamMemberService, UtilizationService $utilizationService)
|
||||
{
|
||||
$this->teamMemberService = $teamMemberService;
|
||||
$this->utilizationService = $utilizationService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,6 +124,8 @@ class TeamMemberController extends Controller
|
||||
* @authenticated
|
||||
*
|
||||
* @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam include_utilization boolean Include utilization data. Example: true
|
||||
* @queryParam month string Month for utilization calculation (Y-m format). Required if include_utilization is true. Example: 2026-02
|
||||
*
|
||||
* @response 200 {
|
||||
* "data": {
|
||||
@@ -134,7 +143,7 @@ class TeamMemberController extends Controller
|
||||
* }
|
||||
* @response 404 {"message":"Team member not found"}
|
||||
*/
|
||||
public function show(string $id): JsonResponse
|
||||
public function show(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$teamMember = $this->teamMemberService->findById($id);
|
||||
|
||||
@@ -144,7 +153,17 @@ class TeamMemberController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
return $this->wrapResource(new TeamMemberResource($teamMember));
|
||||
$data = (new TeamMemberResource($teamMember))->toArray($request);
|
||||
|
||||
// Include utilization data if requested
|
||||
if ($request->boolean('include_utilization') && $request->has('month')) {
|
||||
$month = $request->query('month');
|
||||
$data['utilization'] = $this->utilizationService->getUtilizationData($id, $month);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
264
backend/app/Http/Controllers/Api/UtilizationController.php
Normal file
264
backend/app/Http/Controllers/Api/UtilizationController.php
Normal file
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\UtilizationService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @group Utilization
|
||||
*
|
||||
* API endpoints for utilization calculations.
|
||||
*
|
||||
* Utilization measures how effectively team member capacity is being used.
|
||||
* It's calculated as (Allocated Hours / Capacity) × 100%.
|
||||
*
|
||||
* **Color Indicators:**
|
||||
* - `gray`: Under-utilized (<70%)
|
||||
* - `blue`: Low utilization (70-80%)
|
||||
* - `green`: Optimal (80-100%)
|
||||
* - `yellow`: High utilization (100-110%)
|
||||
* - `red`: Over-allocated (>110%)
|
||||
*/
|
||||
class UtilizationController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
public function __construct(
|
||||
private UtilizationService $utilizationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get Running (YTD) Utilization
|
||||
*
|
||||
* Calculate year-to-date utilization for a specific team member.
|
||||
* Running utilization = (Allocated hours Jan-current) / (Capacity Jan-current) × 100%
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam team_member_id string required UUID of the team member. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam month string required Month in Y-m format (calculates from Jan of this year to this month). Example: 2026-03
|
||||
*
|
||||
* @response 200 {
|
||||
* "capacity_ytd": 480.0,
|
||||
* "allocated_ytd": 450.0,
|
||||
* "utilization": 93.8,
|
||||
* "indicator": "green",
|
||||
* "months_included": 3
|
||||
* }
|
||||
* @response 422 {"message":"Validation failed","errors":{"team_member_id":["The selected team member id is invalid."]}}
|
||||
*/
|
||||
public function running(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewRunningUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->calculateRunningUtilization(
|
||||
$request->team_member_id,
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Overall (Monthly) Utilization
|
||||
*
|
||||
* Calculate utilization for a specific team member in a specific month.
|
||||
* Overall utilization = (Allocated hours this month) / (Capacity this month) × 100%
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam team_member_id string required UUID of the team member. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam month string required Month in Y-m format. Example: 2026-02
|
||||
*
|
||||
* @response 200 {
|
||||
* "capacity": 160.0,
|
||||
* "allocated": 140.0,
|
||||
* "utilization": 87.5,
|
||||
* "indicator": "green"
|
||||
* }
|
||||
* @response 422 {"message":"Validation failed","errors":{"month":["The month format is invalid."]}}
|
||||
*/
|
||||
public function overall(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewOverallUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->calculateOverallUtilization(
|
||||
$request->team_member_id,
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Combined Utilization Data
|
||||
*
|
||||
* Get both overall (monthly) and running (YTD) utilization for a team member.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam team_member_id string required UUID of the team member. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam month string required Month in Y-m format. Example: 2026-03
|
||||
*
|
||||
* @response 200 {
|
||||
* "overall": {
|
||||
* "capacity": 160.0,
|
||||
* "allocated": 140.0,
|
||||
* "utilization": 87.5,
|
||||
* "indicator": "green"
|
||||
* },
|
||||
* "running": {
|
||||
* "capacity_ytd": 480.0,
|
||||
* "allocated_ytd": 450.0,
|
||||
* "utilization": 93.8,
|
||||
* "indicator": "green",
|
||||
* "months_included": 3
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->getUtilizationData(
|
||||
$request->team_member_id,
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Team Utilization
|
||||
*
|
||||
* Calculate average utilization across all active team members for a specific month.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam month string required Month in Y-m format. Example: 2026-02
|
||||
*
|
||||
* @response 200 {
|
||||
* "average_utilization": 85.4,
|
||||
* "average_indicator": "green",
|
||||
* "member_count": 3,
|
||||
* "by_member": {
|
||||
* "550e8400-e29b-41d4-a716-446655440000": {
|
||||
* "capacity": 160.0,
|
||||
* "allocated": 140.0,
|
||||
* "utilization": 87.5,
|
||||
* "indicator": "green"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function team(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewTeamUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->calculateTeamUtilization(
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Team Running Utilization (YTD)
|
||||
*
|
||||
* Calculate average year-to-date utilization across all active team members.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam month string required Month in Y-m format. Example: 2026-03
|
||||
*
|
||||
* @response 200 {
|
||||
* "average_utilization": 88.2,
|
||||
* "average_indicator": "green",
|
||||
* "member_count": 3,
|
||||
* "by_member": {}
|
||||
* }
|
||||
*/
|
||||
public function teamRunning(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewTeamRunningUtilization', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'month' => 'required|date_format:Y-m',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->calculateTeamRunningUtilization(
|
||||
$request->month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Utilization Trend
|
||||
*
|
||||
* Get utilization data for a team member over a range of months.
|
||||
*
|
||||
* @authenticated
|
||||
*
|
||||
* @queryParam team_member_id string required UUID of the team member. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @queryParam start_month string required Start month in Y-m format. Example: 2026-01
|
||||
* @queryParam end_month string required End month in Y-m format (must be >= start_month). Example: 2026-06
|
||||
*
|
||||
* @response 200 [
|
||||
* {
|
||||
* "month": "2026-01",
|
||||
* "utilization": 75.0,
|
||||
* "indicator": "blue",
|
||||
* "capacity": 176.0,
|
||||
* "allocated": 132.0
|
||||
* },
|
||||
* {
|
||||
* "month": "2026-02",
|
||||
* "utilization": 87.5,
|
||||
* "indicator": "green",
|
||||
* "capacity": 160.0,
|
||||
* "allocated": 140.0
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
public function trend(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('viewUtilizationTrend', \App\Models\TeamMember::class);
|
||||
|
||||
$request->validate([
|
||||
'team_member_id' => 'required|uuid|exists:team_members,id',
|
||||
'start_month' => 'required|date_format:Y-m',
|
||||
'end_month' => 'required|date_format:Y-m|after_or_equal:start_month',
|
||||
]);
|
||||
|
||||
$result = $this->utilizationService->getUtilizationTrend(
|
||||
$request->team_member_id,
|
||||
$request->start_month,
|
||||
$request->end_month
|
||||
);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user