From 8ed56c9f7cc1fa29091190e6517066372abb1406 Mon Sep 17 00:00:00 2001 From: Santhosh Janardhanan Date: Thu, 19 Feb 2026 02:43:05 -0500 Subject: [PATCH] feat(project): Complete Project Lifecycle capability with full TDD workflow - 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 --- backend/.scribe/endpoints.cache/02.yaml | 767 +++++++ backend/.scribe/endpoints/02.yaml | 765 +++++++ .../Controllers/Api/ProjectController.php | 390 ++++ backend/app/Policies/ProjectPolicy.php | 99 + backend/app/Services/ProjectService.php | 237 +++ backend/app/Services/ProjectStatusService.php | 61 + backend/database/seeders/DatabaseSeeder.php | 1 + backend/database/seeders/ProjectSeeder.php | 83 + .../resources/views/scribe/index.blade.php | 1770 +++++++++++++++++ backend/routes/api.php | 9 + backend/tests/Feature/Project/ProjectTest.php | 183 +- .../tests/Unit/Models/ProjectForecastTest.php | 59 +- .../tests/Unit/Models/ProjectModelTest.php | 46 +- .../tests/Unit/Policies/ProjectPolicyTest.php | 40 +- frontend/src/lib/services/projectService.ts | 102 + frontend/src/routes/projects/+page.svelte | 534 ++++- frontend/tests/e2e/projects.spec.ts | 89 +- frontend/tests/e2e/team-members.spec.ts | 18 +- openspec/changes/headroom-foundation/tasks.md | 46 +- 19 files changed, 5126 insertions(+), 173 deletions(-) create mode 100644 backend/.scribe/endpoints.cache/02.yaml create mode 100644 backend/.scribe/endpoints/02.yaml create mode 100644 backend/app/Http/Controllers/Api/ProjectController.php create mode 100644 backend/app/Policies/ProjectPolicy.php create mode 100644 backend/app/Services/ProjectService.php create mode 100644 backend/app/Services/ProjectStatusService.php create mode 100644 backend/database/seeders/ProjectSeeder.php create mode 100644 frontend/src/lib/services/projectService.ts diff --git a/backend/.scribe/endpoints.cache/02.yaml b/backend/.scribe/endpoints.cache/02.yaml new file mode 100644 index 00000000..8a2cd0c5 --- /dev/null +++ b/backend/.scribe/endpoints.cache/02.yaml @@ -0,0 +1,767 @@ +## Autogenerated by Scribe. DO NOT MODIFY. + +name: Projects +description: |- + + Endpoints for managing projects. +endpoints: + - + custom: [] + httpMethods: + - GET + uri: api/projects/types + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Get all project types' + description: '' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + [ + {"id": 1, "name": "Project"}, + {"id": 2, "name": "Support"}, + {"id": 3, "name": "Engagement"} + ] + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - GET + uri: api/projects/statuses + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Get all project statuses' + description: '' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + [ + {"id": 1, "name": "Pre-sales", "order": 1}, + {"id": 2, "name": "SOW Approval", "order": 2}, + {"id": 3, "name": "Gathering Estimates", "order": 3} + ] + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - GET + uri: api/projects + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'List all projects' + description: 'Get a list of all projects with optional filtering by status and type.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: + status_id: + custom: [] + name: status_id + description: 'Filter by status ID.' + required: false + example: 1 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + type_id: + custom: [] + name: type_id + description: 'Filter by type ID.' + required: false + example: 2 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanQueryParameters: + status_id: 1 + type_id: 2 + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + [ + { + "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" + } + ] + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - POST + uri: api/projects + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Create a new project' + description: 'Create a new project with code, title, and type.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + code: + custom: [] + name: code + description: 'Project code (must be unique).' + required: true + example: PROJ-001 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + title: + custom: [] + name: title + description: 'Project title.' + required: true + example: 'Client Dashboard Redesign' + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + type_id: + custom: [] + name: type_id + description: 'Project type ID.' + required: true + example: 1 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + code: PROJ-001 + title: 'Client Dashboard Redesign' + type_id: 1 + fileParameters: [] + responses: + - + custom: [] + status: 201 + content: |- + { + "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"} + } + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - GET + uri: 'api/projects/{id}' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Get a single project' + description: 'Get details of a specific project by ID.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "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} + } + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - PUT + - PATCH + uri: 'api/projects/{id}' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Update a project' + description: 'Update details of an existing project.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + code: + custom: [] + name: code + description: 'Project code (must be unique).' + required: false + example: PROJ-002 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + title: + custom: [] + name: title + description: 'Project title.' + required: false + example: 'Updated Title' + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + type_id: + custom: [] + name: type_id + description: 'Project type ID.' + required: false + example: 2 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + code: PROJ-002 + title: 'Updated Title' + type_id: 2 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-002", + "title": "Updated Title", + "type_id": 2 + } + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - DELETE + uri: 'api/projects/{id}' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Delete a project' + description: 'Delete a project. Cannot delete if project has allocations or actuals.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: '{"message":"Project deleted successfully"}' + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Cannot delete project with allocations"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - PUT + uri: 'api/projects/{project}/status' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Transition project status' + description: 'Transition project to a new status following the state machine rules.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + project: + custom: [] + name: project + description: 'The project.' + required: true + example: architecto + type: string + enumValues: [] + exampleWasSpecified: false + nullable: false + deprecated: false + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + project: architecto + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + status_id: + custom: [] + name: status_id + description: 'Target status ID.' + required: true + example: 2 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + status_id: 2 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": {"id": 2, "name": "SOW Approval"} + } + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Cannot transition from Pre-sales to Done"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - PUT + uri: 'api/projects/{project}/estimate' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Set approved estimate' + description: 'Set the approved billable hours estimate for a project.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + project: + custom: [] + name: project + description: 'The project.' + required: true + example: architecto + type: string + enumValues: [] + exampleWasSpecified: false + nullable: false + deprecated: false + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + project: architecto + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + approved_estimate: + custom: [] + name: approved_estimate + description: 'Approved estimate hours (must be > 0).' + required: true + example: 120.0 + type: number + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + approved_estimate: 120.0 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}' + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Approved estimate must be greater than 0"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - PUT + uri: 'api/projects/{project}/forecast' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Set forecasted effort' + description: 'Set the month-by-month forecasted effort breakdown.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + project: + custom: [] + name: project + description: 'The project.' + required: true + example: architecto + type: string + enumValues: [] + exampleWasSpecified: false + nullable: false + deprecated: false + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + project: architecto + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + forecasted_effort: + custom: [] + name: forecasted_effort + description: 'Monthly effort breakdown.' + required: true + example: + 2024-02: 40 + 2024-03: 60 + type: object + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + forecasted_effort: + 2024-02: 40 + 2024-03: 60 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}' + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Forecasted effort exceeds approved estimate by more than 5%"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null diff --git a/backend/.scribe/endpoints/02.yaml b/backend/.scribe/endpoints/02.yaml new file mode 100644 index 00000000..4c33c095 --- /dev/null +++ b/backend/.scribe/endpoints/02.yaml @@ -0,0 +1,765 @@ +name: Projects +description: |- + + Endpoints for managing projects. +endpoints: + - + custom: [] + httpMethods: + - GET + uri: api/projects/types + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Get all project types' + description: '' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + [ + {"id": 1, "name": "Project"}, + {"id": 2, "name": "Support"}, + {"id": 3, "name": "Engagement"} + ] + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - GET + uri: api/projects/statuses + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Get all project statuses' + description: '' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + [ + {"id": 1, "name": "Pre-sales", "order": 1}, + {"id": 2, "name": "SOW Approval", "order": 2}, + {"id": 3, "name": "Gathering Estimates", "order": 3} + ] + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - GET + uri: api/projects + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'List all projects' + description: 'Get a list of all projects with optional filtering by status and type.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: + status_id: + custom: [] + name: status_id + description: 'Filter by status ID.' + required: false + example: 1 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + type_id: + custom: [] + name: type_id + description: 'Filter by type ID.' + required: false + example: 2 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanQueryParameters: + status_id: 1 + type_id: 2 + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + [ + { + "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" + } + ] + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - POST + uri: api/projects + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Create a new project' + description: 'Create a new project with code, title, and type.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: [] + cleanUrlParameters: [] + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + code: + custom: [] + name: code + description: 'Project code (must be unique).' + required: true + example: PROJ-001 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + title: + custom: [] + name: title + description: 'Project title.' + required: true + example: 'Client Dashboard Redesign' + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + type_id: + custom: [] + name: type_id + description: 'Project type ID.' + required: true + example: 1 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + code: PROJ-001 + title: 'Client Dashboard Redesign' + type_id: 1 + fileParameters: [] + responses: + - + custom: [] + status: 201 + content: |- + { + "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"} + } + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - GET + uri: 'api/projects/{id}' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Get a single project' + description: 'Get details of a specific project by ID.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "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} + } + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - PUT + - PATCH + uri: 'api/projects/{id}' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Update a project' + description: 'Update details of an existing project.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + code: + custom: [] + name: code + description: 'Project code (must be unique).' + required: false + example: PROJ-002 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + title: + custom: [] + name: title + description: 'Project title.' + required: false + example: 'Updated Title' + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + type_id: + custom: [] + name: type_id + description: 'Project type ID.' + required: false + example: 2 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + code: PROJ-002 + title: 'Updated Title' + type_id: 2 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "code": "PROJ-002", + "title": "Updated Title", + "type_id": 2 + } + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - DELETE + uri: 'api/projects/{id}' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Delete a project' + description: 'Delete a project. Cannot delete if project has allocations or actuals.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: [] + cleanBodyParameters: [] + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: '{"message":"Project deleted successfully"}' + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Cannot delete project with allocations"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - PUT + uri: 'api/projects/{project}/status' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Transition project status' + description: 'Transition project to a new status following the state machine rules.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + project: + custom: [] + name: project + description: 'The project.' + required: true + example: architecto + type: string + enumValues: [] + exampleWasSpecified: false + nullable: false + deprecated: false + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + project: architecto + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + status_id: + custom: [] + name: status_id + description: 'Target status ID.' + required: true + example: 2 + type: integer + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + status_id: 2 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: |- + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "status": {"id": 2, "name": "SOW Approval"} + } + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Cannot transition from Pre-sales to Done"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - PUT + uri: 'api/projects/{project}/estimate' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Set approved estimate' + description: 'Set the approved billable hours estimate for a project.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + project: + custom: [] + name: project + description: 'The project.' + required: true + example: architecto + type: string + enumValues: [] + exampleWasSpecified: false + nullable: false + deprecated: false + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + project: architecto + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + approved_estimate: + custom: [] + name: approved_estimate + description: 'Approved estimate hours (must be > 0).' + required: true + example: 120.0 + type: number + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + approved_estimate: 120.0 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}' + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Approved estimate must be greater than 0"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null + - + custom: [] + httpMethods: + - PUT + uri: 'api/projects/{project}/forecast' + metadata: + custom: [] + groupName: Projects + groupDescription: |- + + Endpoints for managing projects. + subgroup: '' + subgroupDescription: '' + title: 'Set forecasted effort' + description: 'Set the month-by-month forecasted effort breakdown.' + authenticated: true + deprecated: false + headers: + Content-Type: application/json + Accept: application/json + urlParameters: + project: + custom: [] + name: project + description: 'The project.' + required: true + example: architecto + type: string + enumValues: [] + exampleWasSpecified: false + nullable: false + deprecated: false + id: + custom: [] + name: id + description: 'Project UUID.' + required: true + example: 550e8400-e29b-41d4-a716-446655440000 + type: string + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanUrlParameters: + project: architecto + id: 550e8400-e29b-41d4-a716-446655440000 + queryParameters: [] + cleanQueryParameters: [] + bodyParameters: + forecasted_effort: + custom: [] + name: forecasted_effort + description: 'Monthly effort breakdown.' + required: true + example: + 2024-02: 40 + 2024-03: 60 + type: object + enumValues: [] + exampleWasSpecified: true + nullable: false + deprecated: false + cleanBodyParameters: + forecasted_effort: + 2024-02: 40 + 2024-03: 60 + fileParameters: [] + responses: + - + custom: [] + status: 200 + content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}' + headers: [] + description: '' + - + custom: [] + status: 404 + content: '{"message":"Project not found"}' + headers: [] + description: '' + - + custom: [] + status: 422 + content: '{"message":"Forecasted effort exceeds approved estimate by more than 5%"}' + headers: [] + description: '' + responseFields: [] + auth: [] + controller: null + method: null + route: null diff --git a/backend/app/Http/Controllers/Api/ProjectController.php b/backend/app/Http/Controllers/Api/ProjectController.php new file mode 100644 index 00000000..c62e0f2a --- /dev/null +++ b/backend/app/Http/Controllers/Api/ProjectController.php @@ -0,0 +1,390 @@ +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', + ]); + } +} diff --git a/backend/app/Policies/ProjectPolicy.php b/backend/app/Policies/ProjectPolicy.php new file mode 100644 index 00000000..dbc237b2 --- /dev/null +++ b/backend/app/Policies/ProjectPolicy.php @@ -0,0 +1,99 @@ +role, ['superuser', 'manager']); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Project $project): bool + { + // Only superusers and managers can update projects + return in_array($user->role, ['superuser', 'manager']); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Project $project): bool + { + // Only superusers and managers can delete projects + return in_array($user->role, ['superuser', 'manager']); + } + + /** + * Determine whether the user can transition project status. + */ + public function updateStatus(User $user, Project $project): bool + { + // Only superusers and managers can transition status + return in_array($user->role, ['superuser', 'manager']); + } + + /** + * Determine whether the user can set approved estimate. + */ + public function setEstimate(User $user, Project $project): bool + { + // Only superusers and managers can set estimates + return in_array($user->role, ['superuser', 'manager']); + } + + /** + * Determine whether the user can set forecasted effort. + */ + public function setForecast(User $user, Project $project): bool + { + // Only superusers and managers can set forecasts + return in_array($user->role, ['superuser', 'manager']); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Project $project): bool + { + // Only superusers and managers can restore projects + return in_array($user->role, ['superuser', 'manager']); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Project $project): bool + { + // Only superusers can force delete projects + return $user->role === 'superuser'; + } +} diff --git a/backend/app/Services/ProjectService.php b/backend/app/Services/ProjectService.php new file mode 100644 index 00000000..231a5bd4 --- /dev/null +++ b/backend/app/Services/ProjectService.php @@ -0,0 +1,237 @@ + + */ + public function getAll(?int $statusId = null, ?int $typeId = null): Collection + { + $query = Project::with([ + 'status:id,name,order', + 'type:id,name', + ]) + ->select('projects.*') + ->leftJoin('project_statuses', 'projects.status_id', '=', 'project_statuses.id'); + + if ($statusId !== null) { + $query->where('projects.status_id', $statusId); + } + + if ($typeId !== null) { + $query->where('projects.type_id', $typeId); + } + + return $query->get(); + } + + /** + * Find a project by ID. + */ + public function findById(string $id): ?Project + { + return Project::with(['status', 'type', 'allocations', 'actuals'])->find($id); + } + + /** + * Create a new project. + * + * @throws ValidationException + */ + public function create(array $data): Project + { + $validator = Validator::make($data, [ + 'code' => 'required|string|max:50|unique:projects,code', + 'title' => 'required|string|max:255', + 'type_id' => 'required|integer|exists:project_types,id', + 'status_id' => 'sometimes|integer|exists:project_statuses,id', + ], [ + 'code.unique' => 'Project code must be unique', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + // Default to first status (Pre-sales) if not provided + if (! isset($data['status_id'])) { + $initialStatus = ProjectStatus::orderBy('order')->first(); + $data['status_id'] = $initialStatus?->id; + } + + $project = Project::create($data); + $project->load(['status', 'type']); + + return $project; + } + + /** + * Update an existing project. + * + * @throws ValidationException + */ + public function update(Project $project, array $data): Project + { + $validator = Validator::make($data, [ + 'code' => 'sometimes|string|max:50|unique:projects,code,'.$project->id, + 'title' => 'sometimes|string|max:255', + 'type_id' => 'sometimes|integer|exists:project_types,id', + 'status_id' => 'sometimes|integer|exists:project_statuses,id', + ], [ + 'code.unique' => 'Project code must be unique', + ]); + + if ($validator->fails()) { + throw new ValidationException($validator); + } + + $project->update($data); + $project->load(['status', 'type']); + + return $project; + } + + /** + * Transition project to a new status. + * + * @throws \RuntimeException + */ + public function transitionStatus(Project $project, int $newStatusId): Project + { + $newStatus = ProjectStatus::find($newStatusId); + + if (! $newStatus) { + throw new \RuntimeException('Invalid status', 422); + } + + $currentStatusName = $project->status->name; + $newStatusName = $newStatus->name; + + // Check if transition is valid + if (! $this->statusService->canTransition($currentStatusName, $newStatusName)) { + throw new \RuntimeException( + "Cannot transition from {$currentStatusName} to {$newStatusName}", + 422 + ); + } + + // Special validation: Estimate Approved requires approved_estimate > 0 + if ($this->statusService->requiresEstimate($newStatusName)) { + if (! $project->approved_estimate || $project->approved_estimate <= 0) { + throw new \RuntimeException( + 'Cannot transition to Estimate Approved without an approved estimate', + 422 + ); + } + } + + $project->update(['status_id' => $newStatusId]); + $project->load(['status', 'type']); + + return $project; + } + + /** + * Set the approved estimate for a project. + * + * @throws \RuntimeException + */ + public function setApprovedEstimate(Project $project, float $estimate): Project + { + if ($estimate <= 0) { + throw new \RuntimeException('Approved estimate must be greater than 0', 422); + } + + $project->update(['approved_estimate' => $estimate]); + $project->load(['status', 'type']); + + return $project; + } + + /** + * Set the forecasted effort for a project. + * + * @param array $forecastedEffort ['2024-01' => 40, '2024-02' => 60, ...] + * + * @throws \RuntimeException + */ + public function setForecastedEffort(Project $project, array $forecastedEffort): Project + { + // Calculate total forecasted hours + $totalForecasted = array_sum($forecastedEffort); + + // If project has approved estimate, validate within tolerance + if ($project->approved_estimate && $project->approved_estimate > 0) { + $approved = (float) $project->approved_estimate; + $difference = $totalForecasted - $approved; + $percentageDiff = ($difference / $approved) * 100; + $tolerancePercent = 5; + + if (abs($percentageDiff) > $tolerancePercent) { + $lowerBound = max(0, round($approved * (1 - $tolerancePercent / 100), 2)); + $upperBound = round($approved * (1 + $tolerancePercent / 100), 2); + $message = sprintf( + 'Forecasted effort (%s h) %s approved estimate (%s h) by %s hours (%s%%). Forecasted effort must be between %s and %s hours for a %s hour estimate.', + number_format($totalForecasted, 2, '.', ''), + $difference > 0 ? 'exceeds' : 'is below', + number_format($approved, 2, '.', ''), + number_format(abs($difference), 2, '.', ''), + number_format(abs($percentageDiff), 2, '.', ''), + number_format($lowerBound, 2, '.', ''), + number_format($upperBound, 2, '.', ''), + number_format($approved, 2, '.', '') + ); + + throw new \RuntimeException($message, 422); + } + } + + $project->update(['forecasted_effort' => $forecastedEffort]); + $project->load(['status', 'type']); + + return $project; + } + + /** + * Check if a project can be deleted. + * + * @return array{canDelete: bool, reason?: string} + */ + public function canDelete(Project $project): array + { + if ($project->allocations()->exists()) { + return [ + 'canDelete' => false, + 'reason' => 'Project has allocations', + ]; + } + + if ($project->actuals()->exists()) { + return [ + 'canDelete' => false, + 'reason' => 'Project has actuals', + ]; + } + + return ['canDelete' => true]; + } +} diff --git a/backend/app/Services/ProjectStatusService.php b/backend/app/Services/ProjectStatusService.php new file mode 100644 index 00000000..cfdab941 --- /dev/null +++ b/backend/app/Services/ProjectStatusService.php @@ -0,0 +1,61 @@ + ['SOW Approval'], + 'SOW Approval' => ['Estimation', 'Pre-sales'], + 'Estimation' => ['Estimate Approved', 'SOW Approval'], + 'Estimate Approved' => ['Resource Allocation', 'Estimate Rework'], + 'Resource Allocation' => ['Sprint 0', 'Estimate Approved'], + 'Sprint 0' => ['In Progress', 'Resource Allocation'], + 'In Progress' => ['UAT', 'Sprint 0', 'On Hold'], + 'UAT' => ['Handover / Sign-off', 'In Progress', 'On Hold'], + 'Handover / Sign-off' => ['Closed', 'UAT'], + 'Estimate Rework' => ['Estimation'], + 'On Hold' => ['In Progress', 'Cancelled'], + 'Cancelled' => [], + 'Closed' => [], + ]; + + /** + * Return the valid target statuses for the provided current status. + */ + public function getValidTransitions(string $currentStatus): array + { + return $this->statusTransitions[$currentStatus] ?? []; + } + + /** + * Determine if a transition from the current status to the target is allowed. + */ + public function canTransition(string $currentStatus, string $targetStatus): bool + { + return in_array($targetStatus, $this->getValidTransitions($currentStatus), true); + } + + /** + * Return statuses that do not allow further transitions. + */ + public function getTerminalStatuses(): array + { + return array_keys(array_filter($this->statusTransitions, static fn (array $targets): bool => $targets === [])); + } + + /** + * Determine if a status requires an approved estimate before entering. + */ + public function requiresEstimate(string $statusName): bool + { + return $statusName === 'Estimate Approved'; + } +} diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index e5d99f5a..22f4c567 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ class DatabaseSeeder extends Seeder RoleSeeder::class, ProjectStatusSeeder::class, ProjectTypeSeeder::class, + ProjectSeeder::class, UserSeeder::class, ]); } diff --git a/backend/database/seeders/ProjectSeeder.php b/backend/database/seeders/ProjectSeeder.php new file mode 100644 index 00000000..825d1ca2 --- /dev/null +++ b/backend/database/seeders/ProjectSeeder.php @@ -0,0 +1,83 @@ +where('name', 'Pre-sales')->first(); + $sowApprovalStatus = DB::table('project_statuses')->where('name', 'SOW Approval')->first(); + $estimationStatus = DB::table('project_statuses')->where('name', 'Estimation')->first(); + $inProgressStatus = DB::table('project_statuses')->where('name', 'In Progress')->first(); + $onHoldStatus = DB::table('project_statuses')->where('name', 'On Hold')->first(); + + $projectType = DB::table('project_types')->where('name', 'Project')->first(); + $supportType = DB::table('project_types')->where('name', 'Support')->first(); + + if (! $preSalesStatus || ! $projectType) { + $this->command->warn('Required statuses or types not found. Run ProjectStatusSeeder and ProjectTypeSeeder first.'); + + return; + } + + $projects = [ + [ + 'id' => Str::uuid()->toString(), + 'code' => 'PROJ-001', + 'title' => 'Website Redesign', + 'status_id' => $preSalesStatus->id, // Pre-sales for transition testing + 'type_id' => $projectType->id, + 'approved_estimate' => null, + 'forecasted_effort' => null, + ], + [ + 'id' => Str::uuid()->toString(), + 'code' => 'PROJ-002', + 'title' => 'API Integration', + 'status_id' => $estimationStatus->id ?? $preSalesStatus->id, + 'type_id' => $projectType->id, + 'approved_estimate' => null, + 'forecasted_effort' => null, + ], + [ + 'id' => Str::uuid()->toString(), + 'code' => 'SUP-001', + 'title' => 'Bug Fixes', + 'status_id' => $onHoldStatus->id ?? $preSalesStatus->id, + 'type_id' => $supportType->id, + 'approved_estimate' => 40.00, + 'forecasted_effort' => json_encode(['2024-02' => 20, '2024-03' => 20]), + ], + [ + 'id' => Str::uuid()->toString(), + 'code' => 'PROJ-003', + 'title' => 'Mobile App Development', + 'status_id' => $inProgressStatus->id ?? $preSalesStatus->id, + 'type_id' => $projectType->id, + 'approved_estimate' => 120.00, + 'forecasted_effort' => json_encode(['2024-02' => 40, '2024-03' => 50, '2024-04' => 30]), + ], + ]; + + foreach ($projects as $project) { + DB::table('projects')->updateOrInsert( + ['code' => $project['code']], + array_merge($project, [ + 'created_at' => now(), + 'updated_at' => now(), + ]) + ); + } + + $this->command->info('Seeded '.count($projects).' projects.'); + } +} diff --git a/backend/resources/views/scribe/index.blade.php b/backend/resources/views/scribe/index.blade.php index a95f5128..18cfc97c 100644 --- a/backend/resources/views/scribe/index.blade.php +++ b/backend/resources/views/scribe/index.blade.php @@ -82,6 +82,43 @@ +