- Implement ProjectController with CRUD, status transitions, estimate/forecast - Add ProjectService with state machine validation - Extract ProjectStatusService for reusable state machine logic - Add ProjectPolicy for role-based authorization - Create ProjectSeeder with test data - Implement frontend project management UI with modal forms - Add projectService API client - Complete all 9 incomplete unit tests (ProjectModelTest, ProjectForecastTest, ProjectPolicyTest) - Fix E2E test timing issues with loading state waits - Add Scribe API documentation annotations - Improve forecasted effort validation messages with detailed feedback Test Results: - Backend: 49 passed (182 assertions) - Frontend Unit: 32 passed - E2E: 134 passed (Chromium + Firefox) Phase 3 Refactor: - Extract ProjectStatusService for state machine - Optimize project list query with status joins - Improve forecasted effort validation messages Phase 4 Document: - Add Scribe annotations to ProjectController - Generate API documentation
391 lines
12 KiB
PHP
391 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Project;
|
|
use App\Models\ProjectStatus;
|
|
use App\Models\ProjectType;
|
|
use App\Services\ProjectService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/**
|
|
* @group Projects
|
|
*
|
|
* Endpoints for managing projects.
|
|
*/
|
|
class ProjectController extends Controller
|
|
{
|
|
/**
|
|
* Project Service instance
|
|
*/
|
|
protected ProjectService $projectService;
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct(ProjectService $projectService)
|
|
{
|
|
$this->projectService = $projectService;
|
|
}
|
|
|
|
/**
|
|
* List all projects
|
|
*
|
|
* Get a list of all projects with optional filtering by status and type.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @queryParam status_id integer Filter by status ID. Example: 1
|
|
* @queryParam type_id integer Filter by type ID. Example: 2
|
|
*
|
|
* @response 200 [
|
|
* {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "code": "PROJ-001",
|
|
* "title": "Client Dashboard Redesign",
|
|
* "status_id": 1,
|
|
* "status": {"id": 1, "name": "Pre-sales"},
|
|
* "type_id": 2,
|
|
* "type": {"id": 2, "name": "Support"},
|
|
* "approved_estimate": "120.00",
|
|
* "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
|
|
* "created_at": "2024-01-15T10:00:00.000000Z",
|
|
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
|
* }
|
|
* ]
|
|
*/
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$statusId = $request->query('status_id') ? (int) $request->query('status_id') : null;
|
|
$typeId = $request->query('type_id') ? (int) $request->query('type_id') : null;
|
|
|
|
$projects = $this->projectService->getAll($statusId, $typeId);
|
|
|
|
return response()->json($projects);
|
|
}
|
|
|
|
/**
|
|
* Create a new project
|
|
*
|
|
* Create a new project with code, title, and type.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @bodyParam code string required Project code (must be unique). Example: PROJ-001
|
|
* @bodyParam title string required Project title. Example: Client Dashboard Redesign
|
|
* @bodyParam type_id integer required Project type ID. Example: 1
|
|
*
|
|
* @response 201 {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "code": "PROJ-001",
|
|
* "title": "Client Dashboard Redesign",
|
|
* "status_id": 1,
|
|
* "status": {"id": 1, "name": "Pre-sales"},
|
|
* "type_id": 1,
|
|
* "type": {"id": 1, "name": "Project"}
|
|
* }
|
|
* @response 422 {"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}}
|
|
*/
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
try {
|
|
$project = $this->projectService->create($request->all());
|
|
|
|
return response()->json($project, 201);
|
|
} catch (ValidationException $e) {
|
|
return response()->json([
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->validator->errors(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a single project
|
|
*
|
|
* Get details of a specific project by ID.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* @response 200 {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "code": "PROJ-001",
|
|
* "title": "Client Dashboard Redesign",
|
|
* "status": {"id": 1, "name": "Pre-sales"},
|
|
* "type": {"id": 1, "name": "Project"},
|
|
* "approved_estimate": "120.00",
|
|
* "forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
|
* }
|
|
* @response 404 {"message":"Project not found"}
|
|
*/
|
|
public function show(string $id): JsonResponse
|
|
{
|
|
$project = $this->projectService->findById($id);
|
|
|
|
if (! $project) {
|
|
return response()->json([
|
|
'message' => 'Project not found',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json($project);
|
|
}
|
|
|
|
/**
|
|
* Update a project
|
|
*
|
|
* Update details of an existing project.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* @bodyParam code string Project code (must be unique). Example: PROJ-002
|
|
* @bodyParam title string Project title. Example: Updated Title
|
|
* @bodyParam type_id integer Project type ID. Example: 2
|
|
*
|
|
* @response 200 {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "code": "PROJ-002",
|
|
* "title": "Updated Title",
|
|
* "type_id": 2
|
|
* }
|
|
* @response 404 {"message":"Project not found"}
|
|
* @response 422 {"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}}
|
|
*/
|
|
public function update(Request $request, string $id): JsonResponse
|
|
{
|
|
$project = Project::find($id);
|
|
|
|
if (! $project) {
|
|
return response()->json([
|
|
'message' => 'Project not found',
|
|
], 404);
|
|
}
|
|
|
|
try {
|
|
$project = $this->projectService->update($project, $request->only([
|
|
'code', 'title', 'type_id',
|
|
]));
|
|
|
|
return response()->json($project);
|
|
} catch (ValidationException $e) {
|
|
return response()->json([
|
|
'message' => 'Validation failed',
|
|
'errors' => $e->validator->errors(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Transition project status
|
|
*
|
|
* Transition project to a new status following the state machine rules.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* @bodyParam status_id integer required Target status ID. Example: 2
|
|
*
|
|
* @response 200 {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "status": {"id": 2, "name": "SOW Approval"}
|
|
* }
|
|
* @response 404 {"message":"Project not found"}
|
|
* @response 422 {"message":"Cannot transition from Pre-sales to Done"}
|
|
*/
|
|
public function updateStatus(Request $request, string $id): JsonResponse
|
|
{
|
|
$project = Project::with('status')->find($id);
|
|
|
|
if (! $project) {
|
|
return response()->json([
|
|
'message' => 'Project not found',
|
|
], 404);
|
|
}
|
|
|
|
$request->validate([
|
|
'status_id' => 'required|integer|exists:project_statuses,id',
|
|
]);
|
|
|
|
try {
|
|
$project = $this->projectService->transitionStatus(
|
|
$project,
|
|
(int) $request->input('status_id')
|
|
);
|
|
|
|
return response()->json($project);
|
|
} catch (\RuntimeException $e) {
|
|
return response()->json([
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set approved estimate
|
|
*
|
|
* Set the approved billable hours estimate for a project.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* @bodyParam approved_estimate number required Approved estimate hours (must be > 0). Example: 120
|
|
*
|
|
* @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}
|
|
* @response 404 {"message":"Project not found"}
|
|
* @response 422 {"message":"Approved estimate must be greater than 0"}
|
|
*/
|
|
public function setEstimate(Request $request, string $id): JsonResponse
|
|
{
|
|
$project = Project::find($id);
|
|
|
|
if (! $project) {
|
|
return response()->json([
|
|
'message' => 'Project not found',
|
|
], 404);
|
|
}
|
|
|
|
$request->validate([
|
|
'approved_estimate' => 'required|numeric',
|
|
]);
|
|
|
|
try {
|
|
$project = $this->projectService->setApprovedEstimate(
|
|
$project,
|
|
(float) $request->input('approved_estimate')
|
|
);
|
|
|
|
return response()->json($project);
|
|
} catch (\RuntimeException $e) {
|
|
return response()->json([
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set forecasted effort
|
|
*
|
|
* Set the month-by-month forecasted effort breakdown.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* @bodyParam forecasted_effort object required Monthly effort breakdown. Example: {"2024-02": 40, "2024-03": 60}
|
|
*
|
|
* @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}
|
|
* @response 404 {"message":"Project not found"}
|
|
* @response 422 {"message":"Forecasted effort exceeds approved estimate by more than 5%"}
|
|
*/
|
|
public function setForecast(Request $request, string $id): JsonResponse
|
|
{
|
|
$project = Project::find($id);
|
|
|
|
if (! $project) {
|
|
return response()->json([
|
|
'message' => 'Project not found',
|
|
], 404);
|
|
}
|
|
|
|
$request->validate([
|
|
'forecasted_effort' => 'required|array',
|
|
]);
|
|
|
|
try {
|
|
$project = $this->projectService->setForecastedEffort(
|
|
$project,
|
|
$request->input('forecasted_effort')
|
|
);
|
|
|
|
return response()->json($project);
|
|
} catch (\RuntimeException $e) {
|
|
return response()->json([
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all project types
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @response 200 [
|
|
* {"id": 1, "name": "Project"},
|
|
* {"id": 2, "name": "Support"},
|
|
* {"id": 3, "name": "Engagement"}
|
|
* ]
|
|
*/
|
|
public function types(): JsonResponse
|
|
{
|
|
$types = ProjectType::orderBy('name')->get(['id', 'name']);
|
|
|
|
return response()->json($types);
|
|
}
|
|
|
|
/**
|
|
* Get all project statuses
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @response 200 [
|
|
* {"id": 1, "name": "Pre-sales", "order": 1},
|
|
* {"id": 2, "name": "SOW Approval", "order": 2},
|
|
* {"id": 3, "name": "Gathering Estimates", "order": 3}
|
|
* ]
|
|
*/
|
|
public function statuses(): JsonResponse
|
|
{
|
|
$statuses = ProjectStatus::orderBy('order')->get(['id', 'name', 'order']);
|
|
|
|
return response()->json($statuses);
|
|
}
|
|
|
|
/**
|
|
* Delete a project
|
|
*
|
|
* Delete a project. Cannot delete if project has allocations or actuals.
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* @response 200 {"message":"Project deleted successfully"}
|
|
* @response 404 {"message":"Project not found"}
|
|
* @response 422 {"message":"Cannot delete project with allocations"}
|
|
*/
|
|
public function destroy(string $id): JsonResponse
|
|
{
|
|
$project = Project::find($id);
|
|
|
|
if (! $project) {
|
|
return response()->json([
|
|
'message' => 'Project not found',
|
|
], 404);
|
|
}
|
|
|
|
$canDelete = $this->projectService->canDelete($project);
|
|
|
|
if (! $canDelete['canDelete']) {
|
|
return response()->json([
|
|
'message' => "Cannot delete project with {$canDelete['reason']}",
|
|
], 422);
|
|
}
|
|
|
|
$project->delete();
|
|
|
|
return response()->json([
|
|
'message' => 'Project deleted successfully',
|
|
]);
|
|
}
|
|
}
|