actualsService = $actualsService; } public function index(Request $request): JsonResponse { $validator = Validator::make($request->all(), [ 'month' => ['required', 'date_format:Y-m'], 'project_ids.*' => ['uuid'], 'team_member_ids.*' => ['uuid'], 'include_inactive' => ['boolean'], 'page' => ['integer', 'min:1'], 'per_page' => ['integer', 'min:1', 'max:250'], ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed', 'errors' => $validator->errors(), ], 422); } $monthKey = $request->query('month'); try { $monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth(); } catch (\Throwable) { return response()->json([ 'message' => 'Validation failed', 'errors' => ['month' => ['Invalid month format']], ], 422); } $includeInactive = filter_var($request->query('include_inactive', false), FILTER_VALIDATE_BOOLEAN); $projectIdsFilter = array_filter((array) $request->query('project_ids', [])); $teamMemberIdsFilter = array_filter((array) $request->query('team_member_ids', [])); $searchTerm = $request->query('search'); $searchTerm = is_string($searchTerm) ? trim($searchTerm) : null; if ($searchTerm === '') { $searchTerm = null; } $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}%"))) ->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(); $allocations = collect(); $actuals = collect(); $projectIds = $projects->pluck('id')->all(); $teamMemberIds = $teamMembers->pluck('id')->all(); if (! empty($projectIds) && ! empty($teamMemberIds)) { $allocations = Allocation::query() ->where('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) ->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds)) ->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds)) ->get() ->keyBy(fn (Actual $actual) => $actual->project_id.'-'.$actual->team_member_id); } $teamMemberMap = $teamMembers->mapWithKeys(fn (TeamMember $member) => [$member->id => $this->formatTeamMember($member)])->all(); $rows = []; foreach ($projects as $project) { $projectData = $this->formatProject($project); foreach ($teamMembers as $teamMember) { $key = $project->id.'-'.$teamMember->id; $allocation = $allocations->get($key); $actual = $actuals->get($key); $hasAllocation = $allocation !== null; $hasActual = $actual !== null; $hasData = $hasAllocation || $hasActual; $allocatedHours = $hasAllocation ? (float) $allocation->allocated_hours : 0.0; $actualHours = $hasActual ? (float) $actual->hours_logged : 0.0; $variancePercentage = null; $varianceDisplay = null; if ($hasData) { if ($allocatedHours <= 0.0) { if ($actualHours > 0.0) { $varianceDisplay = '∞%'; } else { $variancePercentage = 0.0; } } else { $variancePercentage = (($actualHours - $allocatedHours) / $allocatedHours) * 100.0; } } $variance = $variancePercentage !== null ? round($variancePercentage, 1) : null; $indicator = $this->getIndicator($variancePercentage, $hasData); $rows[] = [ 'project_id' => $project->id, 'project' => $projectData, 'team_member_id' => $teamMember->id, 'team_member' => $teamMemberMap[$teamMember->id], 'month' => $monthKey, 'allocated_hours' => $this->formatHours($allocatedHours), 'actual_hours' => $this->formatHours($actualHours), 'variance_percentage' => $variance, 'variance_display' => $varianceDisplay, 'variance_indicator' => $indicator, 'notes' => $actual?->notes, 'is_readonly' => $this->isProjectReadonly($project), ]; } } $page = max(1, (int) $request->query('page', 1)); $perPage = max(1, min(250, (int) $request->query('per_page', 25))); $total = count($rows); $currentPageItems = array_slice($rows, ($page - 1) * $perPage, $perPage); $paginator = new LengthAwarePaginator( $currentPageItems, $total, $perPage, $page, ['path' => LengthAwarePaginator::resolveCurrentPath(), 'query' => $request->query()] ); return response()->json([ 'data' => $paginator->items(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'last_page' => $paginator->lastPage(), 'filters' => [ 'month' => $monthKey, 'include_inactive' => $includeInactive, 'search' => $searchTerm, ], ], ]); } public function store(Request $request): JsonResponse { $validator = Validator::make($request->all(), [ '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', 'notes' => 'nullable|string|max:1000', ]); $validator->after(function () use ($request, $validator) { $month = $request->input('month'); if (! $month) { return; } try { $monthDate = Carbon::createFromFormat('Y-m', $month)->startOfMonth(); } catch (\Exception) { return; } if ($monthDate->gt(Carbon::now()->startOfMonth())) { $validator->errors()->add('month', 'Cannot log hours for future months'); } }); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed', 'errors' => $validator->errors(), ], 422); } $project = Project::with('status')->find($request->input('project_id')); if ($project && $project->status && ! $this->actualsService->canLogToInactiveProjects()) { $inactiveStatuses = $this->actualsService->getInactiveProjectStatuses(); if (in_array($project->status->name, $inactiveStatuses, true)) { return response()->json([ 'message' => 'Cannot log hours to completed projects', 'errors' => [ 'project_id' => ['Cannot log hours to completed projects'], ], ], 422); } } $monthKey = $request->input('month'); $monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth()->toDateString(); $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; if ($existing) { $existing->hours_logged = (float) $existing->hours_logged + $hours; if ($notes) { $existing->notes = $this->appendNotes($existing->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); return response()->json([ 'data' => (new ActualResource($actual))->resolve($request), ], $status); } public function show(string $id): JsonResponse { $actual = Actual::with(['project.status', 'teamMember'])->find($id); if (! $actual) { return response()->json([ 'message' => 'Actual not found', ], 404); } $monthKey = $actual->month?->format('Y-m'); if ($monthKey) { $this->hydrateVariance($actual, $monthKey); } return $this->wrapResource(new ActualResource($actual)); } public function update(Request $request, string $id): JsonResponse { $actual = Actual::find($id); if (! $actual) { return response()->json([ 'message' => 'Actual not found', ], 404); } $validator = Validator::make($request->all(), [ 'hours' => 'required|numeric|min:0', 'notes' => 'nullable|string|max:1000', ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed', 'errors' => $validator->errors(), ], 422); } $actual->hours_logged = (float) $request->input('hours'); if ($request->exists('notes')) { $actual->notes = $request->input('notes'); } $actual->save(); $actual->load(['project.status', 'teamMember']); $monthKey = $actual->month?->format('Y-m'); if ($monthKey) { $this->hydrateVariance($actual, $monthKey); } return $this->wrapResource(new ActualResource($actual)); } public function destroy(string $id): JsonResponse { $actual = Actual::find($id); if (! $actual) { return response()->json([ 'message' => 'Actual not found', ], 404); } $actual->delete(); return response()->json([ 'message' => 'Actual deleted successfully', ]); } private function hydrateVariance(Actual $actual, string $month): void { $variance = $this->actualsService->calculateVariance( $actual->project_id, $actual->team_member_id, $month ); $actual->allocated_hours = $variance['allocated']; $actual->variance_percentage = $variance['variance_percentage']; $actual->variance_indicator = $variance['indicator']; } private function formatProject(Project $project): array { $status = $project->status; return [ 'id' => $project->id, 'code' => $project->code, 'title' => $project->title, 'status' => $status ? [ 'id' => $status->id, 'name' => $status->name, 'is_active' => (bool) $status->is_active, ] : null, 'is_active' => $this->isProjectActive($project), ]; } private function formatTeamMember(TeamMember $teamMember): array { return [ 'id' => $teamMember->id, 'name' => $teamMember->name, 'is_active' => (bool) $teamMember->active, ]; } private function isProjectActive(Project $project): bool { return ! $this->isProjectReadonly($project); } private function isProjectReadonly(Project $project): bool { $status = $project->status; if (! $status) { return false; } return in_array($status->name, self::LOCKED_PROJECT_STATUSES, true); } private function formatHours(float $hours): string { return number_format($hours, 2, '.', ''); } private function getIndicator(?float $variancePercentage, bool $hasData): string { if (! $hasData) { return 'gray'; } if ($variancePercentage === null) { return 'red'; } $absolute = abs($variancePercentage); if ($absolute <= 5) { return 'green'; } if ($absolute <= 20) { return 'yellow'; } return 'red'; } private function appendNotes(?string $existing, string $note): string { $entry = now()->format('Y-m-d H:i').': '.$note; if ($existing) { return $existing."\n".$entry; } return $entry; } }