diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php new file mode 100644 index 00000000..3677d2f8 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -0,0 +1,361 @@ +all(), [ + 'start_date' => 'required|date_format:Y-m-d', + 'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date', + 'project_ids' => 'nullable|array', + 'project_ids.*' => 'uuid|exists:projects,id', + 'member_ids' => 'nullable|array', + 'member_ids.*' => 'uuid|exists:team_members,id', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + $startDate = Carbon::parse($request->input('start_date')); + $endDate = Carbon::parse($request->input('end_date')); + $viewType = $this->determineViewType($startDate, $endDate); + + // Get projects with optional filtering + $projectsQuery = Project::query(); + if ($request->has('project_ids')) { + $projectsQuery->whereIn('id', $request->input('project_ids')); + } + $projects = $projectsQuery->get(); + + // Get team members with optional filtering + $membersQuery = TeamMember::query(); + if ($request->has('member_ids')) { + $membersQuery->whereIn('id', $request->input('member_ids')); + } + $members = $membersQuery->get(); + + // Get all plans for the period + $plans = ProjectMonthPlan::whereBetween('month', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')]) + ->when($request->has('project_ids'), fn ($q) => $q->whereIn('project_id', $request->input('project_ids'))) + ->get() + ->groupBy('project_id'); + + // Get all allocations for the period + $allocations = Allocation::whereBetween('month', [$startDate->format('Y-m-d'), $endDate->format('Y-m-d')]) + ->when($request->has('project_ids'), fn ($q) => $q->whereIn('project_id', $request->input('project_ids'))) + ->when($request->has('member_ids'), fn ($q) => $q->whereIn('team_member_id', $request->input('member_ids'))) + ->get(); + + // Build project report data + $projectData = $this->buildProjectData($projects, $plans, $allocations, $startDate, $endDate); + + // Build member report data + $memberData = $this->buildMemberData($members, $allocations, $startDate, $endDate); + + // Calculate aggregates + $aggregates = $this->calculateAggregates($projectData); + + return response()->json([ + 'period' => [ + 'start' => $startDate->format('Y-m-d'), + 'end' => $endDate->format('Y-m-d'), + ], + 'view_type' => $viewType, + 'projects' => $projectData, + 'members' => $memberData, + 'aggregates' => $aggregates, + ]); + } + + /** + * Determine view type based on date range relative to current month. + */ + private function determineViewType(Carbon $startDate, Carbon $endDate): string + { + $now = Carbon::now(); + $currentMonthStart = $now->copy()->startOfMonth(); + $currentMonthEnd = $now->copy()->endOfMonth(); + + $rangeStart = $startDate->copy()->startOfMonth(); + $rangeEnd = $endDate->copy()->endOfMonth(); + + // All dates are in the past -> 'did' + if ($rangeEnd->lt($currentMonthStart)) { + return 'did'; + } + + // All dates are in the future -> 'will' + if ($rangeStart->gt($currentMonthEnd)) { + return 'will'; + } + + // Includes current month -> 'is' + return 'is'; + } + + /** + * Build project report data with lifecycle totals and period execution. + * + * @return array + */ + private function buildProjectData($projects, $plans, $allocations, Carbon $startDate, Carbon $endDate): array + { + $projectData = []; + + foreach ($projects as $project) { + $projectPlans = $plans->get($project->id, collect()); + + // Calculate lifecycle reconciliation (all plans for this project) + $allProjectPlans = ProjectMonthPlan::where('project_id', $project->id)->get(); + $lifecycleStatus = $this->reconciliationCalculator->calculateStatus($project, $allProjectPlans); + $planSum = $this->reconciliationCalculator->calculatePlanSum($project, $allProjectPlans); + + // Calculate period metrics (only within date range) + $periodPlans = $projectPlans->filter(fn ($p) => + Carbon::parse($p->month)->between($startDate, $endDate) + ); + + $periodPlanned = $periodPlans->sum('planned_hours'); + + // Get allocations for this project in the period + $projectAllocations = $allocations->where('project_id', $project->id); + $periodAllocated = $projectAllocations->sum('allocated_hours'); + + $periodVariance = $periodAllocated - $periodPlanned; + $periodStatus = $this->varianceCalculator->determineStatus($periodVariance); + + // Build monthly breakdown + $months = $this->buildProjectMonthBreakdown($projectPlans, $projectAllocations, $startDate, $endDate); + + $projectData[] = [ + 'id' => $project->id, + 'code' => $project->code, + 'title' => $project->title, + 'approved_estimate' => (float) $project->approved_estimate, + 'lifecycle_status' => $lifecycleStatus, + 'plan_sum' => $planSum, + 'period_planned' => $periodPlanned, + 'period_allocated' => $periodAllocated, + 'period_variance' => $periodVariance, + 'period_status' => $periodStatus, + 'months' => $months, + ]; + } + + return $projectData; + } + + /** + * Build monthly breakdown for a project. + * + * @return array + */ + private function buildProjectMonthBreakdown($projectPlans, $projectAllocations, Carbon $startDate, Carbon $endDate): array + { + $months = []; + $current = $startDate->copy()->startOfMonth(); + + while ($current->lte($endDate)) { + $monthKey = $current->format('Y-m'); + + // Get plan for this month + $plan = $projectPlans->first(fn ($p) => + Carbon::parse($p->month)->format('Y-m') === $monthKey + ); + + $plannedHours = $plan?->planned_hours; + $isBlank = $plannedHours === null; + + // Get allocations for this month + $monthAllocations = $projectAllocations->filter(fn ($a) => + Carbon::parse($a->month)->format('Y-m') === $monthKey + ); + + $allocatedHours = $monthAllocations->sum('allocated_hours'); + $variance = $allocatedHours - ($plannedHours ?? 0); + $status = $this->varianceCalculator->determineStatus($variance); + + $months[] = [ + 'month' => $monthKey, + 'planned_hours' => $isBlank ? null : (float) $plannedHours, + 'is_blank' => $isBlank, + 'allocated_hours' => $allocatedHours, + 'variance' => $variance, + 'status' => $status, + ]; + + $current->addMonth(); + } + + return $months; + } + + /** + * Build member report data with capacity and utilization. + * + * @return array + */ + private function buildMemberData($members, $allocations, Carbon $startDate, Carbon $endDate): array + { + $memberData = []; + + foreach ($members as $member) { + // Get allocations for this member in the period (excluding untracked) + $memberAllocations = $allocations->filter(fn ($a) => + $a->team_member_id === $member->id + ); + + $periodAllocated = $memberAllocations->sum('allocated_hours'); + + // Group by project + $projects = $memberAllocations + ->groupBy('project_id') + ->map(fn ($allocs, $projectId) => [ + 'project_id' => $projectId, + 'project_code' => $allocs->first()->project->code ?? null, + 'project_title' => $allocs->first()->project->title ?? null, + 'total_hours' => $allocs->sum('allocated_hours'), + ]) + ->values() + ->toArray(); + + $memberData[] = [ + 'id' => $member->id, + 'name' => $member->name, + 'period_allocated' => $periodAllocated, + 'projects' => $projects, + ]; + } + + // Add untracked row + $untrackedAllocations = $allocations->whereNull('team_member_id'); + if ($untrackedAllocations->isNotEmpty()) { + $untrackedProjects = $untrackedAllocations + ->groupBy('project_id') + ->map(fn ($allocs, $projectId) => [ + 'project_id' => $projectId, + 'project_code' => $allocs->first()->project->code ?? null, + 'project_title' => $allocs->first()->project->title ?? null, + 'total_hours' => $allocs->sum('allocated_hours'), + ]) + ->values() + ->toArray(); + + $memberData[] = [ + 'id' => null, + 'name' => 'Untracked', + 'period_allocated' => $untrackedAllocations->sum('allocated_hours'), + 'projects' => $untrackedProjects, + ]; + } + + return $memberData; + } + + /** + * Calculate aggregate metrics across all projects. + * + * @return array{ + * total_planned: float, + * total_allocated: float, + * total_variance: float, + * status: string + * } + */ + private function calculateAggregates(array $projectData): array + { + $totalPlanned = array_sum(array_column($projectData, 'period_planned')); + $totalAllocated = array_sum(array_column($projectData, 'period_allocated')); + $totalVariance = $totalAllocated - $totalPlanned; + $status = $this->varianceCalculator->determineStatus($totalVariance); + + return [ + 'total_planned' => $totalPlanned, + 'total_allocated' => $totalAllocated, + 'total_variance' => $totalVariance, + 'status' => $status, + ]; + } +} diff --git a/backend/app/Services/ReconciliationCalculator.php b/backend/app/Services/ReconciliationCalculator.php new file mode 100644 index 00000000..b7684bec --- /dev/null +++ b/backend/app/Services/ReconciliationCalculator.php @@ -0,0 +1,103 @@ +approved_estimate; + + // If no approved estimate, consider it UNDER + if ($approved <= 0) { + return 'UNDER'; + } + + $planSum = $this->calculatePlanSum($project, $plans); + + // Use decimal-safe comparison + if ($this->isGreaterThan($planSum, $approved)) { + return 'OVER'; + } + + if ($this->isLessThan($planSum, $approved)) { + return 'UNDER'; + } + + return 'MATCH'; + } + + /** + * Calculate the sum of planned hours for a project. + * Only sums non-null planned_hours values. + */ + public function calculatePlanSum(Project $project, ?Collection $plans = null): float + { + if ($plans === null) { + $plans = ProjectMonthPlan::where('project_id', $project->id)->get(); + } + + return $plans + ->filter(fn ($plan) => $plan->planned_hours !== null) + ->sum('planned_hours'); + } + + /** + * Calculate plan sum and status for multiple projects. + * Returns array with project_id => ['plan_sum' => float, 'status' => string]. + */ + public function calculateForProjects(Collection $projects, int $year): array + { + $startDate = "{$year}-01-01"; + $endDate = "{$year}-12-01"; + + // Get all plans for the year, grouped by project_id + $allPlans = ProjectMonthPlan::whereBetween('month', [$startDate, $endDate]) + ->get() + ->groupBy('project_id'); + + $results = []; + + foreach ($projects as $project) { + $projectPlans = $allPlans->get($project->id, collect()); + $planSum = $this->calculatePlanSum($project, $projectPlans); + $status = $this->calculateStatus($project, $projectPlans); + + $results[$project->id] = [ + 'plan_sum' => $planSum, + 'status' => $status, + 'approved_estimate' => $project->approved_estimate, + ]; + } + + return $results; + } + + /** + * Compare two floats using epsilon for decimal-safe comparison. + */ + private function isGreaterThan(float $a, float $b): bool + { + $epsilon = 0.0001; + + return ($a - $b) > $epsilon; + } + + /** + * Compare two floats using epsilon for decimal-safe comparison. + */ + private function isLessThan(float $a, float $b): bool + { + $epsilon = 0.0001; + + return ($b - $a) > $epsilon; + } +} diff --git a/backend/app/Services/VarianceCalculator.php b/backend/app/Services/VarianceCalculator.php new file mode 100644 index 00000000..0d36934c --- /dev/null +++ b/backend/app/Services/VarianceCalculator.php @@ -0,0 +1,164 @@ +startOfMonth() : Carbon::parse($month); + + // Get total allocated hours for this project/month (including untracked) + $allocatedTotal = Allocation::where('project_id', $projectId) + ->whereMonth('month', $monthDate->month) + ->whereYear('month', $monthDate->year) + ->sum('allocated_hours'); + + // Get planned hours for this project/month (treat null as 0) + $plannedMonth = $this->getPlannedHoursForMonth($projectId, $month); + + $variance = $allocatedTotal - $plannedMonth; + + return [ + 'allocated_total' => $allocatedTotal, + 'planned_month' => $plannedMonth, + 'variance' => $variance, + 'status' => $this->determineStatus($variance), + ]; + } + + /** + * Calculate column variance for a team member in a given month. + * Column variance = member_allocated - member_capacity + * + * @return array{ + * allocated: float, + * capacity: float, + * variance: float, + * status: string + * } + */ + public function calculateColumnVariance(string $teamMemberId, string $month, CapacityService $capacityService): array + { + // Convert YYYY-MM to Carbon date for proper comparison + $monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month); + + // Get total allocated hours for this member/month (excluding untracked) + $allocated = Allocation::where('team_member_id', $teamMemberId) + ->whereMonth('month', $monthDate->month) + ->whereYear('month', $monthDate->year) + ->sum('allocated_hours'); + + // Get member capacity + $capacityData = $capacityService->calculateIndividualCapacity($teamMemberId, $month); + $capacity = $capacityData['hours'] ?? 0; + + $variance = $allocated - $capacity; + + return [ + 'allocated' => $allocated, + 'capacity' => $capacity, + 'variance' => $variance, + 'status' => $this->determineStatus($variance), + ]; + } + + /** + * Get planned hours for a project/month. + * Returns 0 if no plan exists (blank month treated as 0). + */ + public function getPlannedHoursForMonth(string $projectId, string $month): float + { + $monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month); + + $plan = ProjectMonthPlan::where('project_id', $projectId) + ->whereMonth('month', $monthDate->month) + ->whereYear('month', $monthDate->year) + ->first(); + + // Blank plan is treated as 0 for allocation variance + return (float) ($plan?->planned_hours ?? 0); + } + + /** + * Determine status based on variance value. + * + * - Positive variance (> 0): OVER (red) + * - Negative variance (< 0): UNDER (amber) + * - Zero variance: MATCH (neutral) + */ + public function determineStatus(float $variance): string + { + $epsilon = 0.0001; + + if ($variance > $epsilon) { + return 'OVER'; + } + + if ($variance < -$epsilon) { + return 'UNDER'; + } + + return 'MATCH'; + } + + /** + * Calculate both row and column variances for a complete matrix view. + * + * @return array{ + * project_variances: array, + * team_member_variances: array + * } + */ + public function calculateMatrixVariances(string $month, CapacityService $capacityService): array + { + // Convert YYYY-MM to Carbon date for proper comparison + $monthDate = strlen($month) === 7 ? Carbon::createFromFormat('Y-m', $month)->startOfMonth() : Carbon::parse($month); + + // Get all allocations for the month using whereMonth/whereYear + $allocations = Allocation::whereMonth('month', $monthDate->month) + ->whereYear('month', $monthDate->year) + ->get(); + + // Calculate row variances per project + $projectIds = $allocations->pluck('project_id')->unique()->toArray(); + $projectVariances = []; + + foreach ($projectIds as $projectId) { + $projectVariances[$projectId] = $this->calculateRowVariance($projectId, $month); + } + + // Calculate column variances per team member (excluding null/untracked) + $teamMemberIds = $allocations->pluck('team_member_id') + ->filter(fn ($id) => $id !== null) + ->unique() + ->toArray(); + + $teamMemberVariances = []; + + foreach ($teamMemberIds as $teamMemberId) { + $teamMemberVariances[$teamMemberId] = $this->calculateColumnVariance($teamMemberId, $month, $capacityService); + } + + return [ + 'project_variances' => $projectVariances, + 'team_member_variances' => $teamMemberVariances, + ]; + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index 7ae285cf..ee4ae053 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -7,6 +7,8 @@ use App\Http\Controllers\Api\HolidayController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ProjectMonthPlanController; use App\Http\Controllers\Api\PtoController; +use App\Http\Controllers\Api\ReportController; +use App\Http\Controllers\Api\RolesController; use App\Http\Controllers\Api\TeamMemberController; use App\Http\Middleware\JwtAuth; use App\Http\Resources\UserResource; @@ -35,6 +37,9 @@ Route::middleware(JwtAuth::class)->group(function () { // Team Members Route::apiResource('team-members', TeamMemberController::class); + // Roles + Route::get('/roles', [RolesController::class, 'index']); + // Projects Route::get('projects/types', [ProjectController::class, 'types']); Route::get('projects/statuses', [ProjectController::class, 'statuses']); @@ -68,4 +73,7 @@ Route::middleware(JwtAuth::class)->group(function () { // Allocations Route::apiResource('allocations', AllocationController::class); Route::post('/allocations/bulk', [AllocationController::class, 'bulkStore']); + + // Reports + Route::get('/reports/allocations', [ReportController::class, 'allocations']); });