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 @@ +
Optional refresh token to invalidate immediately. Example: abc123def456
Endpoints for managing projects.
+ ++requires authentication +
+ + + + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/projects/types" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json"const url = new URL(
+ "http://localhost/api/projects/types"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+}).then(response => response.json());++Example response (200):
+
+
+[
+ {
+ "id": 1,
+ "name": "Project"
+ },
+ {
+ "id": 2,
+ "name": "Support"
+ },
+ {
+ "id": 3,
+ "name": "Engagement"
+ }
+]
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ + + + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/projects/statuses" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json"const url = new URL(
+ "http://localhost/api/projects/statuses"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+}).then(response => response.json());++Example response (200):
+
+
+[
+ {
+ "id": 1,
+ "name": "Pre-sales",
+ "order": 1
+ },
+ {
+ "id": 2,
+ "name": "SOW Approval",
+ "order": 2
+ },
+ {
+ "id": 3,
+ "name": "Gathering Estimates",
+ "order": 3
+ }
+]
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ +Get a list of all projects with optional filtering by status and type.
+ + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/projects?status_id=1&type_id=2" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json"const url = new URL(
+ "http://localhost/api/projects"
+);
+
+const params = {
+ "status_id": "1",
+ "type_id": "2",
+};
+Object.keys(params)
+ .forEach(key => url.searchParams.append(key, params[key]));
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+}).then(response => response.json());++Example 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"
+ }
+]
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ +Create a new project with code, title, and type.
+ + +Example request:+ + +
curl --request POST \
+ "http://localhost/api/projects" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"code\": \"PROJ-001\",
+ \"title\": \"Client Dashboard Redesign\",
+ \"type_id\": 1
+}"
+const url = new URL(
+ "http://localhost/api/projects"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "code": "PROJ-001",
+ "title": "Client Dashboard Redesign",
+ "type_id": 1
+};
+
+fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example 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"
+ }
+}
+
+ ++Example response (422):
+
+
+{
+ "message": "Validation failed",
+ "errors": {
+ "code": [
+ "Project code must be unique"
+ ],
+ "title": [
+ "The title field is required."
+ ]
+ }
+}
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ +Get details of a specific project by ID.
+ + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/projects/550e8400-e29b-41d4-a716-446655440000" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json"const url = new URL(
+ "http://localhost/api/projects/550e8400-e29b-41d4-a716-446655440000"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+}).then(response => response.json());++Example 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
+ }
+}
+
+ ++Example response (404):
+
+
+{
+ "message": "Project not found"
+}
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ +Update details of an existing project.
+ + +Example request:+ + +
curl --request PUT \
+ "http://localhost/api/projects/550e8400-e29b-41d4-a716-446655440000" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"code\": \"PROJ-002\",
+ \"title\": \"Updated Title\",
+ \"type_id\": 2
+}"
+const url = new URL(
+ "http://localhost/api/projects/550e8400-e29b-41d4-a716-446655440000"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "code": "PROJ-002",
+ "title": "Updated Title",
+ "type_id": 2
+};
+
+fetch(url, {
+ method: "PUT",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "code": "PROJ-002",
+ "title": "Updated Title",
+ "type_id": 2
+}
+
+ ++Example response (404):
+
+
+{
+ "message": "Project not found"
+}
+
+ ++Example response (422):
+
+
+{
+ "message": "Validation failed",
+ "errors": {
+ "type_id": [
+ "The selected type id is invalid."
+ ]
+ }
+}
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ +Delete a project. Cannot delete if project has allocations or actuals.
+ + +Example request:+ + +
curl --request DELETE \
+ "http://localhost/api/projects/550e8400-e29b-41d4-a716-446655440000" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json"const url = new URL(
+ "http://localhost/api/projects/550e8400-e29b-41d4-a716-446655440000"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+fetch(url, {
+ method: "DELETE",
+ headers,
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "message": "Project deleted successfully"
+}
+
+ ++Example response (404):
+
+
+{
+ "message": "Project not found"
+}
+
+ ++Example response (422):
+
+
+{
+ "message": "Cannot delete project with allocations"
+}
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ +Transition project to a new status following the state machine rules.
+ + +Example request:+ + +
curl --request PUT \
+ "http://localhost/api/projects/architecto/status" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"status_id\": 2
+}"
+const url = new URL(
+ "http://localhost/api/projects/architecto/status"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "status_id": 2
+};
+
+fetch(url, {
+ method: "PUT",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "status": {
+ "id": 2,
+ "name": "SOW Approval"
+ }
+}
+
+ ++Example response (404):
+
+
+{
+ "message": "Project not found"
+}
+
+ ++Example response (422):
+
+
+{
+ "message": "Cannot transition from Pre-sales to Done"
+}
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ +Set the approved billable hours estimate for a project.
+ + +Example request:+ + +
curl --request PUT \
+ "http://localhost/api/projects/architecto/estimate" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"approved_estimate\": 120
+}"
+const url = new URL(
+ "http://localhost/api/projects/architecto/estimate"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "approved_estimate": 120
+};
+
+fetch(url, {
+ method: "PUT",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "approved_estimate": "120.00"
+}
+
+ ++Example response (404):
+
+
+{
+ "message": "Project not found"
+}
+
+ ++Example response (422):
+
+
+{
+ "message": "Approved estimate must be greater than 0"
+}
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
+
+ +requires authentication +
+ +Set the month-by-month forecasted effort breakdown.
+ + +Example request:+ + +
curl --request PUT \
+ "http://localhost/api/projects/architecto/forecast" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"forecasted_effort\": {
+ \"2024-02\": 40,
+ \"2024-03\": 60
+ }
+}"
+const url = new URL(
+ "http://localhost/api/projects/architecto/forecast"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "forecasted_effort": {
+ "2024-02": 40,
+ "2024-03": 60
+ }
+};
+
+fetch(url, {
+ method: "PUT",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "forecasted_effort": {
+ "2024-02": 40,
+ "2024-03": 60
+ }
+}
+
+ ++Example response (404):
+
+
+{
+ "message": "Project not found"
+}
+
+ ++Example response (422):
+
+
+{
+ "message": "Forecasted effort exceeds approved estimate by more than 5%"
+}
+
+
+
+ Received response: ++
+
+
+ Request failed with error:+
+
+Tip: Check that you're properly connected to the network.
+If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
+You can check the Dev Tools console for debugging information.
+
+
diff --git a/backend/routes/api.php b/backend/routes/api.php
index 96580fb2..a9f1cca3 100644
--- a/backend/routes/api.php
+++ b/backend/routes/api.php
@@ -1,6 +1,7 @@
group(function () {
// Team Members
Route::apiResource('team-members', TeamMemberController::class);
+
+ // Projects
+ Route::get('projects/types', [ProjectController::class, 'types']);
+ Route::get('projects/statuses', [ProjectController::class, 'statuses']);
+ Route::apiResource('projects', ProjectController::class);
+ Route::put('projects/{project}/status', [ProjectController::class, 'updateStatus']);
+ Route::put('projects/{project}/estimate', [ProjectController::class, 'setEstimate']);
+ Route::put('projects/{project}/forecast', [ProjectController::class, 'setForecast']);
});
diff --git a/backend/tests/Feature/Project/ProjectTest.php b/backend/tests/Feature/Project/ProjectTest.php
index bc8df343..71fe981a 100644
--- a/backend/tests/Feature/Project/ProjectTest.php
+++ b/backend/tests/Feature/Project/ProjectTest.php
@@ -2,12 +2,15 @@
namespace Tests\Feature\Project;
-use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
-use App\Models\User;
use App\Models\Project;
use App\Models\ProjectStatus;
use App\Models\ProjectType;
+use App\Models\User;
+use Database\Seeders\ProjectStatusSeeder;
+use Database\Seeders\ProjectTypeSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Str;
+use Tests\TestCase;
class ProjectTest extends TestCase
{
@@ -16,6 +19,10 @@ class ProjectTest extends TestCase
protected function setUp(): void
{
parent::setUp();
+ $this->seed([
+ ProjectStatusSeeder::class,
+ ProjectTypeSeeder::class,
+ ]);
}
protected function loginAsManager()
@@ -35,57 +42,209 @@ class ProjectTest extends TestCase
return $response->json('access_token');
}
+ private function projectPayload(array $overrides = []): array
+ {
+ $type = ProjectType::first();
+
+ return array_merge([
+ 'code' => 'TEST-'.strtoupper(Str::random(4)),
+ 'title' => 'New Project',
+ 'type_id' => $type->id,
+ ], $overrides);
+ }
+
+ private function statusId(string $name): int
+ {
+ return ProjectStatus::where('name', $name)->value('id');
+ }
+
+ private function transitionProjectStatus(string $projectId, string $statusName, string $token)
+ {
+ return $this->withToken($token)->putJson("/api/projects/{$projectId}/status", [
+ 'status_id' => $this->statusId($statusName),
+ ]);
+ }
+
// 3.1.13 API test: POST /api/projects creates project
public function test_post_projects_creates_project()
{
- $this->markTestIncomplete('3.1.13: Implement POST /api/projects endpoint');
+ $token = $this->loginAsManager();
+ $payload = $this->projectPayload();
+
+ $response = $this->withToken($token)
+ ->postJson('/api/projects', $payload);
+
+ $response->assertStatus(201)
+ ->assertJsonFragment([
+ 'code' => $payload['code'],
+ 'title' => $payload['title'],
+ ]);
+
+ $this->assertDatabaseHas('projects', [
+ 'code' => $payload['code'],
+ 'title' => $payload['title'],
+ ]);
}
// 3.1.14 API test: Project code must be unique
public function test_project_code_must_be_unique()
{
- $this->markTestIncomplete('3.1.14: Implement project code uniqueness validation');
+ $token = $this->loginAsManager();
+ $payload = $this->projectPayload();
+
+ $this->withToken($token)->postJson('/api/projects', $payload)
+ ->assertStatus(201);
+
+ $this->withToken($token)->postJson('/api/projects', $payload)
+ ->assertStatus(422)
+ ->assertJsonStructure([
+ 'message',
+ 'errors' => ['code'],
+ ]);
}
// 3.1.15 API test: Status transition validation
public function test_status_transition_validation()
{
- $this->markTestIncomplete('3.1.15: Implement status state machine validation');
+ $token = $this->loginAsManager();
+ $payload = $this->projectPayload();
+ $projectId = $this->withToken($token)
+ ->postJson('/api/projects', $payload)
+ ->json('id');
+
+ $invalidStatus = $this->statusId('In Progress');
+
+ $this->withToken($token)
+ ->putJson("/api/projects/{$projectId}/status", ['status_id' => $invalidStatus])
+ ->assertStatus(422)
+ ->assertJsonFragment([
+ 'message' => 'Cannot transition from Pre-sales to In Progress',
+ ]);
+
+ $this->transitionProjectStatus($projectId, 'SOW Approval', $token)
+ ->assertStatus(200)
+ ->assertJsonPath('status.name', 'SOW Approval');
}
// 3.1.16 API test: Estimate approved requires estimate value
public function test_estimate_approved_requires_estimate_value()
{
- $this->markTestIncomplete('3.1.16: Implement estimate approved validation');
+ $token = $this->loginAsManager();
+ $projectId = $this->withToken($token)
+ ->postJson('/api/projects', $this->projectPayload())
+ ->json('id');
+
+ $this->transitionProjectStatus($projectId, 'SOW Approval', $token)
+ ->assertStatus(200);
+
+ $this->transitionProjectStatus($projectId, 'Estimation', $token)
+ ->assertStatus(200);
+
+ $this->transitionProjectStatus($projectId, 'Estimate Approved', $token)
+ ->assertStatus(422)
+ ->assertJsonFragment([
+ 'message' => 'Cannot transition to Estimate Approved without an approved estimate',
+ ]);
}
// 3.1.17 API test: Full workflow state machine
public function test_full_workflow_state_machine()
{
- $this->markTestIncomplete('3.1.17: Implement full workflow progression');
+ $token = $this->loginAsManager();
+ $payload = $this->projectPayload(['approved_estimate' => 120]);
+ $projectId = $this->withToken($token)
+ ->postJson('/api/projects', $payload)
+ ->json('id');
+
+ $workflow = [
+ 'Pre-sales',
+ 'SOW Approval',
+ 'Estimation',
+ 'Estimate Approved',
+ 'Resource Allocation',
+ 'Sprint 0',
+ 'In Progress',
+ 'UAT',
+ 'Handover / Sign-off',
+ 'Closed',
+ ];
+
+ foreach (array_slice($workflow, 1) as $statusName) {
+ $this->transitionProjectStatus($projectId, $statusName, $token)
+ ->assertStatus(200)
+ ->assertJsonPath('status.name', $statusName);
+ }
}
// 3.1.18 API test: PUT /api/projects/{id}/status transitions
public function test_put_projects_status_transitions()
{
- $this->markTestIncomplete('3.1.18: Implement PUT /api/projects/{id}/status endpoint');
+ $token = $this->loginAsManager();
+ $projectId = $this->withToken($token)
+ ->postJson('/api/projects', $this->projectPayload())
+ ->json('id');
+
+ $this->transitionProjectStatus($projectId, 'SOW Approval', $token)
+ ->assertStatus(200)
+ ->assertJsonPath('status.name', 'SOW Approval');
+
+ $this->assertDatabaseHas('projects', [
+ 'id' => $projectId,
+ 'status_id' => $this->statusId('SOW Approval'),
+ ]);
}
// 3.1.19 API test: PUT /api/projects/{id}/estimate sets approved
public function test_put_projects_estimate_sets_approved()
{
- $this->markTestIncomplete('3.1.19: Implement PUT /api/projects/{id}/estimate endpoint');
+ $token = $this->loginAsManager();
+ $projectId = $this->withToken($token)
+ ->postJson('/api/projects', $this->projectPayload())
+ ->json('id');
+
+ $this->withToken($token)
+ ->putJson("/api/projects/{$projectId}/estimate", ['approved_estimate' => 275])
+ ->assertStatus(200)
+ ->assertJsonPath('approved_estimate', '275.00');
+
+ $this->assertSame('275.00', (string) Project::find($projectId)->approved_estimate);
}
// 3.1.20 API test: PUT /api/projects/{id}/forecast updates effort
public function test_put_projects_forecast_updates_effort()
{
- $this->markTestIncomplete('3.1.20: Implement PUT /api/projects/{id}/forecast endpoint');
+ $token = $this->loginAsManager();
+ $projectId = $this->withToken($token)
+ ->postJson('/api/projects', $this->projectPayload(['approved_estimate' => 100]))
+ ->json('id');
+
+ $forecast = ['2025-01' => 33, '2025-02' => 33, '2025-03' => 34];
+
+ $this->withToken($token)
+ ->putJson("/api/projects/{$projectId}/forecast", ['forecasted_effort' => $forecast])
+ ->assertStatus(200)
+ ->assertJsonFragment(['forecasted_effort' => $forecast]);
+
+ $this->assertSame($forecast, Project::find($projectId)->forecasted_effort);
}
// 3.1.21 API test: Validate forecasted sum equals approved
public function test_validate_forecasted_sum_equals_approved()
{
- $this->markTestIncomplete('3.1.21: Implement forecasted effort validation');
+ $token = $this->loginAsManager();
+ $projectId = $this->withToken($token)
+ ->postJson('/api/projects', $this->projectPayload(['approved_estimate' => 100]))
+ ->json('id');
+
+ $forecast = ['2025-01' => 50, '2025-02' => 50, '2025-03' => 50];
+
+ $this->withToken($token)
+ ->putJson("/api/projects/{$projectId}/forecast", ['forecasted_effort' => $forecast])
+ ->assertStatus(422)
+ ->assertJsonFragment([
+ 'message' => 'Forecasted effort (150.00 h) exceeds approved estimate (100.00 h) by 50.00 hours (50.00%). Forecasted effort must be between 95.00 and 105.00 hours for a 100.00 hour estimate.',
+ ]);
+
+ $this->assertNull(Project::find($projectId)->forecasted_effort);
}
}
diff --git a/backend/tests/Unit/Models/ProjectForecastTest.php b/backend/tests/Unit/Models/ProjectForecastTest.php
index f8060685..00ecebab 100644
--- a/backend/tests/Unit/Models/ProjectForecastTest.php
+++ b/backend/tests/Unit/Models/ProjectForecastTest.php
@@ -2,10 +2,13 @@
namespace Tests\Unit\Models;
-use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
use App\Models\Project;
use App\Models\ProjectStatus;
+use App\Services\ProjectService;
+use Database\Seeders\ProjectStatusSeeder;
+use Database\Seeders\ProjectTypeSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
class ProjectForecastTest extends TestCase
{
@@ -14,16 +17,62 @@ class ProjectForecastTest extends TestCase
// 3.1.24 Unit test: Forecasted effort validation
public function test_forecasted_effort_validation()
{
- $this->markTestIncomplete('3.1.24: Implement forecasted effort validation tests');
+ $this->seed([ProjectStatusSeeder::class, ProjectTypeSeeder::class]);
+
+ $service = app(ProjectService::class);
+
+ $status = ProjectStatus::firstOrFail();
+ $project = Project::factory()->create([
+ 'status_id' => $status->id,
+ ]);
+
+ $forecast = ['2026-01' => 20, '2026-02' => 30];
+
+ $updated = $service->setForecastedEffort($project, $forecast);
+
+ $this->assertSame($forecast, $updated->forecasted_effort);
}
public function test_forecasted_sum_must_equal_approved_estimate()
{
- $this->markTestIncomplete('3.1.24: Test forecasted sum equals approved');
+ $this->seed([ProjectStatusSeeder::class, ProjectTypeSeeder::class]);
+
+ $service = app(ProjectService::class);
+
+ $status = ProjectStatus::firstOrFail();
+ $project = Project::factory()->create([
+ 'status_id' => $status->id,
+ ]);
+
+ $service->setApprovedEstimate($project, 100);
+
+ $forecast = ['2026-01' => 40, '2026-02' => 60];
+ $updated = $service->setForecastedEffort($project, $forecast);
+
+ $this->assertEquals(100, array_sum($updated->forecasted_effort));
}
public function test_forecasted_effort_tolerance()
{
- $this->markTestIncomplete('3.1.24: Test 5% tolerance for forecasted effort');
+ $this->seed([ProjectStatusSeeder::class, ProjectTypeSeeder::class]);
+
+ $service = app(ProjectService::class);
+
+ $status = ProjectStatus::firstOrFail();
+ $project = Project::factory()->create([
+ 'status_id' => $status->id,
+ ]);
+
+ $service->setApprovedEstimate($project, 100);
+
+ $forecastWithinTolerance = ['2026-01' => 50, '2026-02' => 55];
+ $service->setForecastedEffort($project, $forecastWithinTolerance);
+
+ $this->assertEquals(105, array_sum($project->refresh()->forecasted_effort));
+
+ $forecastTooHigh = ['2026-01' => 60, '2026-02' => 50];
+
+ $this->expectException(\RuntimeException::class);
+ $service->setForecastedEffort($project, $forecastTooHigh);
}
}
diff --git a/backend/tests/Unit/Models/ProjectModelTest.php b/backend/tests/Unit/Models/ProjectModelTest.php
index 0cfb46c9..2fb771aa 100644
--- a/backend/tests/Unit/Models/ProjectModelTest.php
+++ b/backend/tests/Unit/Models/ProjectModelTest.php
@@ -2,11 +2,15 @@
namespace Tests\Unit\Models;
-use Illuminate\Foundation\Testing\RefreshDatabase;
-use Tests\TestCase;
use App\Models\Project;
use App\Models\ProjectStatus;
use App\Models\ProjectType;
+use App\Services\ProjectService;
+use App\Services\ProjectStatusService;
+use Database\Seeders\ProjectStatusSeeder;
+use Database\Seeders\ProjectTypeSeeder;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Tests\TestCase;
class ProjectModelTest extends TestCase
{
@@ -15,16 +19,48 @@ class ProjectModelTest extends TestCase
// 3.1.22 Unit test: Project status state machine
public function test_project_status_state_machine()
{
- $this->markTestIncomplete('3.1.22: Implement project status state machine tests');
+ $statusService = app(ProjectStatusService::class);
+
+ $this->assertContains('SOW Approval', $statusService->getValidTransitions('Pre-sales'));
}
public function test_project_can_transition_to_valid_status()
{
- $this->markTestIncomplete('3.1.22: Test valid status transitions');
+ $this->seed([ProjectStatusSeeder::class, ProjectTypeSeeder::class]);
+
+ $service = app(ProjectService::class);
+
+ $preSales = ProjectStatus::where('name', 'Pre-sales')->firstOrFail();
+ $sowApproval = ProjectStatus::where('name', 'SOW Approval')->firstOrFail();
+ $type = ProjectType::firstOrFail();
+
+ $project = Project::factory()->create([
+ 'status_id' => $preSales->id,
+ 'type_id' => $type->id,
+ ]);
+
+ $updated = $service->transitionStatus($project, $sowApproval->id);
+
+ $this->assertSame($sowApproval->id, $updated->status_id);
+ $this->assertSame('SOW Approval', $updated->status->name);
}
public function test_project_cannot_transition_to_invalid_status()
{
- $this->markTestIncomplete('3.1.22: Test invalid status transitions are rejected');
+ $this->seed([ProjectStatusSeeder::class, ProjectTypeSeeder::class]);
+
+ $service = app(ProjectService::class);
+
+ $preSales = ProjectStatus::where('name', 'Pre-sales')->firstOrFail();
+ $inProgress = ProjectStatus::where('name', 'In Progress')->firstOrFail();
+ $type = ProjectType::firstOrFail();
+
+ $project = Project::factory()->create([
+ 'status_id' => $preSales->id,
+ 'type_id' => $type->id,
+ ]);
+
+ $this->expectException(\RuntimeException::class);
+ $service->transitionStatus($project, $inProgress->id);
}
}
diff --git a/backend/tests/Unit/Policies/ProjectPolicyTest.php b/backend/tests/Unit/Policies/ProjectPolicyTest.php
index 66f29a81..5c183aa8 100644
--- a/backend/tests/Unit/Policies/ProjectPolicyTest.php
+++ b/backend/tests/Unit/Policies/ProjectPolicyTest.php
@@ -2,10 +2,13 @@
namespace Tests\Unit\Policies;
+use App\Models\Project;
+use App\Models\User;
+use App\Policies\ProjectPolicy;
+use Database\Seeders\ProjectStatusSeeder;
+use Database\Seeders\ProjectTypeSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
-use App\Models\User;
-use App\Models\Project;
class ProjectPolicyTest extends TestCase
{
@@ -14,16 +17,43 @@ class ProjectPolicyTest extends TestCase
// 3.1.23 Unit test: ProjectPolicy ownership checks
public function test_project_policy_authorization()
{
- $this->markTestIncomplete('3.1.23: Implement ProjectPolicy authorization tests');
+ $this->seed([ProjectStatusSeeder::class, ProjectTypeSeeder::class]);
+
+ $policy = new ProjectPolicy;
+ $roles = ['developer', 'manager', 'superuser'];
+
+ foreach ($roles as $role) {
+ $user = User::factory()->create(['role' => $role]);
+ $project = Project::factory()->create();
+
+ $this->assertTrue($policy->viewAny($user));
+ $this->assertTrue($policy->view($user, $project));
+ }
}
public function test_superuser_can_manage_all_projects()
{
- $this->markTestIncomplete('3.1.23: Test superuser full access');
+ $this->seed([ProjectStatusSeeder::class, ProjectTypeSeeder::class]);
+
+ $policy = new ProjectPolicy;
+ $user = User::factory()->create(['role' => 'superuser']);
+ $project = Project::factory()->create();
+
+ $this->assertTrue($policy->create($user));
+ $this->assertTrue($policy->update($user, $project));
+ $this->assertTrue($policy->delete($user, $project));
}
public function test_manager_can_edit_own_projects()
{
- $this->markTestIncomplete('3.1.23: Test manager project ownership');
+ $this->seed([ProjectStatusSeeder::class, ProjectTypeSeeder::class]);
+
+ $policy = new ProjectPolicy;
+ $user = User::factory()->create(['role' => 'manager']);
+ $project = Project::factory()->create();
+
+ $this->assertTrue($policy->create($user));
+ $this->assertTrue($policy->update($user, $project));
+ $this->assertTrue($policy->delete($user, $project));
}
}
diff --git a/frontend/src/lib/services/projectService.ts b/frontend/src/lib/services/projectService.ts
new file mode 100644
index 00000000..4d20dd9b
--- /dev/null
+++ b/frontend/src/lib/services/projectService.ts
@@ -0,0 +1,102 @@
+import { api } from './api';
+
+export interface ProjectType {
+ id: number;
+ name: string;
+}
+
+export interface ProjectStatus {
+ id: number;
+ name: string;
+ order?: number;
+}
+
+export interface Project {
+ id: string;
+ code: string;
+ title: string;
+ type_id: number;
+ status_id: number;
+ type?: ProjectType;
+ status?: ProjectStatus;
+ approved_estimate?: string | number | null;
+ forecasted_effort?: Record