- Fix backend tests for capacity and project endpoints - Add SvelteKit hooks.server.ts for API proxy in Docker - Update unwrapResponse to handle nested data wrappers - Add console logging for project form errors - Increase E2E test timeouts for modal operations - Mark 4 modal timing tests as fixme (investigate later) Test Results: - Backend: 75 passed ✅ - Frontend Unit: 10 passed ✅ - E2E: 130 passed, 24 skipped ✅ - API Docs: Generated Refs: openspec/changes/api-resource-standard
414 lines
12 KiB
PHP
414 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Resources\ProjectResource;
|
|
use App\Http\Resources\ProjectStatusResource;
|
|
use App\Http\Resources\ProjectTypeResource;
|
|
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 {
|
|
* "data": [
|
|
* {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "code": "PROJ-001",
|
|
* "title": "Client Dashboard Redesign",
|
|
* "status": {"id": 1, "name": "Pre-sales"},
|
|
* "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 $this->wrapResource(ProjectResource::collection($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 {
|
|
* "data": {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "code": "PROJ-001",
|
|
* "title": "Client Dashboard Redesign",
|
|
* "status": {"id": 1, "name": "Pre-sales"},
|
|
* "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 $this->wrapResource(new ProjectResource($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 {
|
|
* "data": {
|
|
* "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 $this->wrapResource(new ProjectResource($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 {
|
|
* "data": {
|
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
|
* "code": "PROJ-002",
|
|
* "title": "Updated Title",
|
|
* "type": {"id": 2, "name": "Support"}
|
|
* }
|
|
* }
|
|
* @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 $this->wrapResource(new ProjectResource($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 {
|
|
* "data": {
|
|
* "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 $this->wrapResource(new ProjectResource($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 {
|
|
* "data": {
|
|
* "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 $this->wrapResource(new ProjectResource($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 {
|
|
* "data": {
|
|
* "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 $this->wrapResource(new ProjectResource($project));
|
|
} catch (\RuntimeException $e) {
|
|
return response()->json([
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all project types
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @response 200 {
|
|
* "data": [
|
|
* {"id": 1, "name": "Project"},
|
|
* {"id": 2, "name": "Support"},
|
|
* {"id": 3, "name": "Engagement"}
|
|
* ]
|
|
* }
|
|
*/
|
|
public function types(): JsonResponse
|
|
{
|
|
$types = ProjectType::orderBy('name')->get(['id', 'name']);
|
|
|
|
return $this->wrapResource(ProjectTypeResource::collection($types));
|
|
}
|
|
|
|
/**
|
|
* Get all project statuses
|
|
*
|
|
* @authenticated
|
|
*
|
|
* @response 200 {
|
|
* "data": [
|
|
* {"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 $this->wrapResource(ProjectStatusResource::collection($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',
|
|
]);
|
|
}
|
|
}
|