validationService = $validationService; } /** * List allocations / Get allocation matrix * * Get all allocations, optionally filtered by month. * * @authenticated * * @queryParam month string Filter by month (YYYY-MM format). Example: 2026-02 * * @response 200 { * "data": [ * { * "id": "550e8400-e29b-41d4-a716-446655440000", * "project_id": "550e8400-e29b-41d4-a716-446655440001", * "team_member_id": "550e8400-e29b-41d4-a716-446655440002", * "month": "2026-02", * "allocated_hours": 40.00, * "is_untracked": false, * "row_variance": { "allocated_total": 80, "planned_month": 100, "variance": -20, "status": "UNDER" }, * "column_variance": { "allocated": 80, "capacity": 160, "variance": -80, "status": "UNDER" } * } * ] * } */ public function index(Request $request): JsonResponse { $month = $request->query('month'); $query = Allocation::with(['project', 'teamMember']); if ($month) { // Convert YYYY-MM to YYYY-MM-01 for date comparison $monthDate = $month.'-01'; $query->where('month', $monthDate); } $allocations = $query->get(); // Compute variance indicators for each allocation if month is specified if ($month) { $allocations->each(function ($allocation) use ($month) { // Add untracked flag $allocation->is_untracked = $allocation->team_member_id === null; // Add row variance (project level) $rowVariance = $this->varianceCalculator->calculateRowVariance( $allocation->project_id, $month ); $allocation->row_variance = $rowVariance; // Add column variance only for tracked allocations if ($allocation->team_member_id !== null) { $columnVariance = $this->varianceCalculator->calculateColumnVariance( $allocation->team_member_id, $month, $this->capacityService ); $allocation->column_variance = $columnVariance; } else { $allocation->column_variance = null; } }); } return $this->wrapResource(AllocationResource::collection($allocations)); } /** * Create a new allocation * * Allocate hours for a team member to a project for a specific month. * * @authenticated * * @bodyParam project_id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440001 * @bodyParam team_member_id string optional Team member UUID (null for untracked). Example: 550e8400-e29b-41d4-a716-446655440002 * @bodyParam month string required Month (YYYY-MM format). Example: 2026-02 * @bodyParam allocated_hours numeric required Hours to allocate (must be >= 0). Example: 40 * * @response 201 { * "data": { * "id": "550e8400-e29b-41d4-a716-446655440000", * "project_id": "550e8400-e29b-41d4-a716-446655440001", * "team_member_id": "550e8400-e29b-41d4-a716-446655440002", * "month": "2026-02", * "allocated_hours": 40.00 * } * } * @response 422 {"message":"Validation failed","errors":{"allocated_hours":["Allocated hours must be greater than or equal to 0"]}} */ public function store(Request $request): JsonResponse { $validator = Validator::make($request->all(), [ 'project_id' => 'required|uuid|exists:projects,id', 'team_member_id' => 'nullable|uuid|exists:team_members,id', 'month' => 'required|date_format:Y-m', 'allocated_hours' => 'required|numeric|min:0', ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed', 'errors' => $validator->errors(), ], 422); } // Validate against capacity and approved estimate (skip for untracked) $teamMemberId = $request->input('team_member_id'); $capacityValidation = ['valid' => true, 'warning' => null, 'utilization' => 0]; if ($teamMemberId) { $capacityValidation = $this->validationService->validateCapacity( $teamMemberId, $request->input('month'), (float) $request->input('allocated_hours') ); } $estimateValidation = $this->validationService->validateApprovedEstimate( $request->input('project_id'), $request->input('month'), (float) $request->input('allocated_hours') ); // Convert YYYY-MM to YYYY-MM-01 for database storage $data = $request->all(); $data['month'] = $data['month'].'-01'; $allocation = Allocation::create($data); $allocation->load(['project', 'teamMember']); $response = new AllocationResource($allocation); $responseData = $response->toArray($request); // Add variance data $month = $request->input('month'); $responseData['is_untracked'] = $teamMemberId === null; // Row variance (project level) $rowVariance = $this->varianceCalculator->calculateRowVariance( $allocation->project_id, $month ); $responseData['row_variance'] = $rowVariance; // Column variance (member level) - only for tracked allocations if ($teamMemberId) { $columnVariance = $this->varianceCalculator->calculateColumnVariance( $teamMemberId, $month, $this->capacityService ); $responseData['column_variance'] = $columnVariance; $responseData['utilization'] = $capacityValidation['utilization']; } else { $responseData['column_variance'] = null; } // Add validation warnings/info to response $responseData['warnings'] = []; if ($capacityValidation['warning']) { $responseData['warnings'][] = $capacityValidation['warning']; } if ($estimateValidation['message']) { $responseData['warnings'][] = $estimateValidation['message']; } return response()->json(['data' => $responseData], 201); } /** * Get a single allocation * * Get details of a specific allocation by ID. * * @authenticated * * @urlParam id string required Allocation UUID. Example: 550e8400-e29b-41d4-a716-446655440000 * * @response 200 { * "data": { * "id": "550e8400-e29b-41d4-a716-446655440000", * "project_id": "550e8400-e29b-41d4-a716-446655440001", * "team_member_id": "550e8400-e29b-41d4-a716-446655440002", * "month": "2026-02", * "allocated_hours": 40.00 * } * } * @response 404 {"message": "Allocation not found"} */ public function show(string $id): JsonResponse { $allocation = Allocation::with(['project', 'teamMember'])->find($id); if (! $allocation) { return response()->json([ 'message' => 'Allocation not found', ], 404); } return $this->wrapResource(new AllocationResource($allocation)); } /** * Update an allocation * * Update an existing allocation's hours. * * @authenticated * * @urlParam id string required Allocation UUID. Example: 550e8400-e29b-41d4-a716-446655440000 * * @bodyParam allocated_hours numeric required Hours to allocate (must be >= 0). Example: 60 * * @response 200 { * "data": { * "id": "550e8400-e29b-41d4-a716-446655440000", * "project_id": "550e8400-e29b-41d4-a716-446655440001", * "team_member_id": "550e8400-e29b-41d4-a716-446655440002", * "month": "2026-02", * "allocated_hours": 60.00 * } * } * @response 404 {"message": "Allocation not found"} * @response 422 {"message":"Validation failed","errors":{"allocated_hours":["Allocated hours must be greater than or equal to 0"]}} */ public function update(Request $request, string $id): JsonResponse { $allocation = Allocation::find($id); if (! $allocation) { return response()->json([ 'message' => 'Allocation not found', ], 404); } $validator = Validator::make($request->all(), [ 'allocated_hours' => 'required|numeric|min:0', ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed', 'errors' => $validator->errors(), ], 422); } $allocation->update($request->all()); $allocation->load(['project', 'teamMember']); return $this->wrapResource(new AllocationResource($allocation)); } /** * Delete an allocation * * Remove an allocation. * * @authenticated * * @urlParam id string required Allocation UUID. Example: 550e8400-e29b-41d4-a716-446655440000 * * @response 200 {"message": "Allocation deleted successfully"} * @response 404 {"message": "Allocation not found"} */ public function destroy(string $id): JsonResponse { $allocation = Allocation::find($id); if (! $allocation) { return response()->json([ 'message' => 'Allocation not found', ], 404); } $allocation->delete(); return response()->json([ 'message' => 'Allocation deleted successfully', ]); } /** * Bulk create allocations * * Create multiple allocations in a single request. * Supports partial success - valid items are created, invalid items are reported. * * @authenticated * * @bodyParam allocations array required Array of allocations. Example: [{"project_id": "...", "team_member_id": "...", "month": "2026-02", "allocated_hours": 40}] * * @response 201 { * "data": [ * { "index": 0, "id": "...", "status": "created" } * ], * "failed": [ * { "index": 1, "errors": { "allocated_hours": ["..."] } } * ], * "summary": { "created": 1, "failed": 1 } * } */ public function bulkStore(Request $request): JsonResponse { // Basic validation only - individual item validation happens in the loop // This allows partial success even if some items have invalid data $validator = Validator::make($request->all(), [ 'allocations' => 'required|array|min:1', 'allocations.*.project_id' => 'required', 'allocations.*.month' => 'required', 'allocations.*.allocated_hours' => 'required|numeric', ]); if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed', 'errors' => $validator->errors(), ], 422); } $data = []; $failed = []; $created = 0; $failedCount = 0; foreach ($request->input('allocations') as $index => $allocationData) { // Convert YYYY-MM to YYYY-MM-01 for database storage $allocationData['month'] = $allocationData['month'].'-01'; // Validate each item individually (for partial bulk success) $itemValidator = Validator::make($allocationData, [ 'project_id' => 'required|uuid|exists:projects,id', 'team_member_id' => 'nullable|uuid|exists:team_members,id', 'month' => 'required|date', 'allocated_hours' => 'required|numeric|min:0', ]); if ($itemValidator->fails()) { $failed[] = [ 'index' => $index, 'errors' => $itemValidator->errors()->toArray(), ]; $failedCount++; continue; } try { $allocation = Allocation::create($allocationData); $data[] = [ 'index' => $index, 'id' => $allocation->id, 'status' => 'created', ]; $created++; } catch (\Exception $e) { $failed[] = [ 'index' => $index, 'errors' => ['allocation' => ['Failed to create allocation: '.$e->getMessage()]], ]; $failedCount++; } } return response()->json([ 'data' => $data, 'failed' => $failed, 'summary' => [ 'created' => $created, 'failed' => $failedCount, ], ], 201); } }