Y-m.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The id of an existing record in the team_members table.'
+ required: true
+ example: architecto
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ cleanBodyParameters:
+ month: 2026-02
+ team_member_id: architecto
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ {
+ "person_days": 18.5,
+ "hours": 148,
+ "details": [
+ {
+ "date": "2026-02-02",
+ "availability": 1,
+ "is_pto": false
+ }
+ ]
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/capacity/team
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Get Team Capacity'
+ description: 'Summarize the combined capacity for all active team members in a month.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ month:
+ custom: []
+ name: month
+ description: 'The month in YYYY-MM format.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ month: 2026-02
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ cleanBodyParameters:
+ month: 2026-02
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ {
+ "month": "2026-02",
+ "person_days": 180.5,
+ "hours": 1444,
+ "members": [
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Ada Lovelace",
+ "person_days": 18.5,
+ "hours": 148
+ }
+ ]
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/capacity/revenue
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Get Possible Revenue'
+ description: 'Estimate monthly revenue based on capacity hours and hourly rates.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ month:
+ custom: []
+ name: month
+ description: 'The month in YYYY-MM format.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ month: 2026-02
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ cleanBodyParameters:
+ month: 2026-02
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ {
+ "month": "2026-02",
+ "possible_revenue": 21500.25
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/holidays
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'List Holidays'
+ description: 'Retrieve holidays for a specific month or all holidays when no month is provided.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ month:
+ custom: []
+ name: month
+ description: 'nullable The month in YYYY-MM format.'
+ required: false
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ month: 2026-02
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: false
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: true
+ deprecated: false
+ cleanBodyParameters:
+ month: 2026-02
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ [
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "date": "2026-02-14",
+ "name": "Company Holiday",
+ "description": "Office closed"
+ }
+ ]
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - POST
+ uri: api/holidays
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Create Holiday'
+ description: 'Add a holiday and clear cached capacity data for the related month.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters: []
+ cleanUrlParameters: []
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ date:
+ custom: []
+ name: date
+ description: 'Date of the holiday.'
+ required: true
+ example: '2026-02-14'
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ name:
+ custom: []
+ name: name
+ description: 'Name of the holiday.'
+ required: true
+ example: "Presidents' Day"
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ description:
+ custom: []
+ name: description
+ description: 'nullable Optional description of the holiday.'
+ required: false
+ example: 'Eius et animi quos velit et.'
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: true
+ deprecated: false
+ cleanBodyParameters:
+ date: '2026-02-14'
+ name: "Presidents' Day"
+ description: 'Eius et animi quos velit et.'
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 201
+ content: |-
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "date": "2026-02-14",
+ "name": "Presidents' Day",
+ "description": "Office closed"
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - DELETE
+ uri: 'api/holidays/{id}'
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Delete Holiday'
+ description: 'Remove a holiday and clear affected capacity caches.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ id:
+ custom: []
+ name: id
+ description: 'The holiday 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": "Holiday deleted"
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/ptos
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'List PTO Requests'
+ description: 'Fetch PTO requests for a team member, optionally constrained to a month.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The team member UUID.'
+ required: true
+ example: 550e8400-e29b-41d4-a716-446655440000
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ month:
+ custom: []
+ name: month
+ description: 'nullable The month in YYYY-MM format.'
+ required: false
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ team_member_id: 550e8400-e29b-41d4-a716-446655440000
+ month: 2026-02
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The id of an existing record in the team_members table.'
+ required: true
+ example: architecto
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: false
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: true
+ deprecated: false
+ cleanBodyParameters:
+ team_member_id: architecto
+ month: 2026-02
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ [
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
+ "start_date": "2026-02-10",
+ "end_date": "2026-02-12",
+ "status": "pending",
+ "reason": "Family travel"
+ }
+ ]
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - POST
+ uri: api/ptos
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Request PTO'
+ description: 'Create a PTO request for a team member and keep it in pending status.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters: []
+ cleanUrlParameters: []
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The team member UUID.'
+ required: true
+ example: 550e8400-e29b-41d4-a716-446655440000
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ start_date:
+ custom: []
+ name: start_date
+ description: 'The first day of the PTO.'
+ required: true
+ example: '2026-02-10'
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ end_date:
+ custom: []
+ name: end_date
+ description: 'The final day of the PTO.'
+ required: true
+ example: '2026-02-12'
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ reason:
+ custom: []
+ name: reason
+ description: 'nullable Optional reason for the PTO.'
+ required: false
+ example: architecto
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: true
+ deprecated: false
+ cleanBodyParameters:
+ team_member_id: 550e8400-e29b-41d4-a716-446655440000
+ start_date: '2026-02-10'
+ end_date: '2026-02-12'
+ reason: architecto
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 201
+ content: |-
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
+ "start_date": "2026-02-10",
+ "end_date": "2026-02-12",
+ "status": "pending",
+ "reason": "Family travel"
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - PUT
+ uri: 'api/ptos/{id}/approve'
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Approve PTO'
+ description: 'Approve a pending PTO request and refresh the affected capacity caches.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ id:
+ custom: []
+ name: id
+ description: 'The PTO UUID that needs approval.'
+ required: true
+ example: 550e8400-e29b-41d4-a716-446655440001
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ id: 550e8400-e29b-41d4-a716-446655440001
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters: []
+ cleanBodyParameters: []
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "status": "approved"
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
diff --git a/backend/.scribe/endpoints/03.yaml b/backend/.scribe/endpoints/03.yaml
new file mode 100644
index 00000000..336b8c6d
--- /dev/null
+++ b/backend/.scribe/endpoints/03.yaml
@@ -0,0 +1,679 @@
+name: 'Capacity Planning'
+description: ''
+endpoints:
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/capacity
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Get Individual Capacity'
+ description: 'Calculate capacity for a specific team member in a given month.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ month:
+ custom: []
+ name: month
+ description: 'The month in YYYY-MM format.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The team member UUID.'
+ required: true
+ example: 550e8400-e29b-41d4-a716-446655440000
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ month: 2026-02
+ team_member_id: 550e8400-e29b-41d4-a716-446655440000
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The id of an existing record in the team_members table.'
+ required: true
+ example: architecto
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ cleanBodyParameters:
+ month: 2026-02
+ team_member_id: architecto
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ {
+ "person_days": 18.5,
+ "hours": 148,
+ "details": [
+ {
+ "date": "2026-02-02",
+ "availability": 1,
+ "is_pto": false
+ }
+ ]
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/capacity/team
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Get Team Capacity'
+ description: 'Summarize the combined capacity for all active team members in a month.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ month:
+ custom: []
+ name: month
+ description: 'The month in YYYY-MM format.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ month: 2026-02
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ cleanBodyParameters:
+ month: 2026-02
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ {
+ "month": "2026-02",
+ "person_days": 180.5,
+ "hours": 1444,
+ "members": [
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Ada Lovelace",
+ "person_days": 18.5,
+ "hours": 148
+ }
+ ]
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/capacity/revenue
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Get Possible Revenue'
+ description: 'Estimate monthly revenue based on capacity hours and hourly rates.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ month:
+ custom: []
+ name: month
+ description: 'The month in YYYY-MM format.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ month: 2026-02
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: true
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ cleanBodyParameters:
+ month: 2026-02
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ {
+ "month": "2026-02",
+ "possible_revenue": 21500.25
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/holidays
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'List Holidays'
+ description: 'Retrieve holidays for a specific month or all holidays when no month is provided.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ month:
+ custom: []
+ name: month
+ description: 'nullable The month in YYYY-MM format.'
+ required: false
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ month: 2026-02
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: false
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: true
+ deprecated: false
+ cleanBodyParameters:
+ month: 2026-02
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ [
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "date": "2026-02-14",
+ "name": "Company Holiday",
+ "description": "Office closed"
+ }
+ ]
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - POST
+ uri: api/holidays
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Create Holiday'
+ description: 'Add a holiday and clear cached capacity data for the related month.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters: []
+ cleanUrlParameters: []
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ date:
+ custom: []
+ name: date
+ description: 'Date of the holiday.'
+ required: true
+ example: '2026-02-14'
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ name:
+ custom: []
+ name: name
+ description: 'Name of the holiday.'
+ required: true
+ example: "Presidents' Day"
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ description:
+ custom: []
+ name: description
+ description: 'nullable Optional description of the holiday.'
+ required: false
+ example: 'Eius et animi quos velit et.'
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: true
+ deprecated: false
+ cleanBodyParameters:
+ date: '2026-02-14'
+ name: "Presidents' Day"
+ description: 'Eius et animi quos velit et.'
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 201
+ content: |-
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "date": "2026-02-14",
+ "name": "Presidents' Day",
+ "description": "Office closed"
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - DELETE
+ uri: 'api/holidays/{id}'
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Delete Holiday'
+ description: 'Remove a holiday and clear affected capacity caches.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ id:
+ custom: []
+ name: id
+ description: 'The holiday 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": "Holiday deleted"
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - GET
+ uri: api/ptos
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'List PTO Requests'
+ description: 'Fetch PTO requests for a team member, optionally constrained to a month.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The team member UUID.'
+ required: true
+ example: 550e8400-e29b-41d4-a716-446655440000
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ month:
+ custom: []
+ name: month
+ description: 'nullable The month in YYYY-MM format.'
+ required: false
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ team_member_id: 550e8400-e29b-41d4-a716-446655440000
+ month: 2026-02
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The id of an existing record in the team_members table.'
+ required: true
+ example: architecto
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: false
+ deprecated: false
+ month:
+ custom: []
+ name: month
+ description: 'Must be a valid date in the format Y-m.'
+ required: false
+ example: 2026-02
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: true
+ deprecated: false
+ cleanBodyParameters:
+ team_member_id: architecto
+ month: 2026-02
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ [
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
+ "start_date": "2026-02-10",
+ "end_date": "2026-02-12",
+ "status": "pending",
+ "reason": "Family travel"
+ }
+ ]
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - POST
+ uri: api/ptos
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Request PTO'
+ description: 'Create a PTO request for a team member and keep it in pending status.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters: []
+ cleanUrlParameters: []
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters:
+ team_member_id:
+ custom: []
+ name: team_member_id
+ description: 'The team member UUID.'
+ required: true
+ example: 550e8400-e29b-41d4-a716-446655440000
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ start_date:
+ custom: []
+ name: start_date
+ description: 'The first day of the PTO.'
+ required: true
+ example: '2026-02-10'
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ end_date:
+ custom: []
+ name: end_date
+ description: 'The final day of the PTO.'
+ required: true
+ example: '2026-02-12'
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ reason:
+ custom: []
+ name: reason
+ description: 'nullable Optional reason for the PTO.'
+ required: false
+ example: architecto
+ type: string
+ enumValues: []
+ exampleWasSpecified: false
+ nullable: true
+ deprecated: false
+ cleanBodyParameters:
+ team_member_id: 550e8400-e29b-41d4-a716-446655440000
+ start_date: '2026-02-10'
+ end_date: '2026-02-12'
+ reason: architecto
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 201
+ content: |-
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
+ "start_date": "2026-02-10",
+ "end_date": "2026-02-12",
+ "status": "pending",
+ "reason": "Family travel"
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
+ -
+ custom: []
+ httpMethods:
+ - PUT
+ uri: 'api/ptos/{id}/approve'
+ metadata:
+ custom: []
+ groupName: 'Capacity Planning'
+ groupDescription: ''
+ subgroup: ''
+ subgroupDescription: ''
+ title: 'Approve PTO'
+ description: 'Approve a pending PTO request and refresh the affected capacity caches.'
+ authenticated: false
+ deprecated: false
+ headers:
+ Content-Type: application/json
+ Accept: application/json
+ urlParameters:
+ id:
+ custom: []
+ name: id
+ description: 'The PTO UUID that needs approval.'
+ required: true
+ example: 550e8400-e29b-41d4-a716-446655440001
+ type: string
+ enumValues: []
+ exampleWasSpecified: true
+ nullable: false
+ deprecated: false
+ cleanUrlParameters:
+ id: 550e8400-e29b-41d4-a716-446655440001
+ queryParameters: []
+ cleanQueryParameters: []
+ bodyParameters: []
+ cleanBodyParameters: []
+ fileParameters: []
+ responses:
+ -
+ custom: []
+ status: 200
+ content: |-
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "status": "approved"
+ }
+ headers: []
+ description: ''
+ responseFields: []
+ auth: []
+ controller: null
+ method: null
+ route: null
diff --git a/backend/app/Http/Controllers/Api/CapacityController.php b/backend/app/Http/Controllers/Api/CapacityController.php
new file mode 100644
index 00000000..46d483ab
--- /dev/null
+++ b/backend/app/Http/Controllers/Api/CapacityController.php
@@ -0,0 +1,103 @@
+validate([
+ 'month' => 'required|date_format:Y-m',
+ 'team_member_id' => 'required|exists:team_members,id',
+ ]);
+
+ $capacity = $this->capacityService->calculateIndividualCapacity($data['team_member_id'], $data['month']);
+
+ return response()->json($capacity);
+ }
+
+ /**
+ * Get Team Capacity
+ *
+ * Summarize the combined capacity for all active team members in a month.
+ *
+ * @group Capacity Planning
+ * @urlParam month string required The month in YYYY-MM format. Example: 2026-02
+ * @response {
+ * "month": "2026-02",
+ * "person_days": 180.5,
+ * "hours": 1444,
+ * "members": [
+ * {
+ * "id": "550e8400-e29b-41d4-a716-446655440000",
+ * "name": "Ada Lovelace",
+ * "person_days": 18.5,
+ * "hours": 148
+ * }
+ * ]
+ * }
+ */
+ public function team(Request $request): JsonResponse
+ {
+ $data = $request->validate([
+ 'month' => 'required|date_format:Y-m',
+ ]);
+
+ $payload = $this->capacityService->calculateTeamCapacity($data['month']);
+
+ return response()->json($payload);
+ }
+
+ /**
+ * Get Possible Revenue
+ *
+ * Estimate monthly revenue based on capacity hours and hourly rates.
+ *
+ * @group Capacity Planning
+ * @urlParam month string required The month in YYYY-MM format. Example: 2026-02
+ * @response {
+ * "month": "2026-02",
+ * "possible_revenue": 21500.25
+ * }
+ */
+ public function revenue(Request $request): JsonResponse
+ {
+ $data = $request->validate([
+ 'month' => 'required|date_format:Y-m',
+ ]);
+
+ $revenue = $this->capacityService->calculatePossibleRevenue($data['month']);
+
+ return response()->json([
+ 'month' => $data['month'],
+ 'possible_revenue' => $revenue,
+ ]);
+ }
+}
diff --git a/backend/app/Http/Controllers/Api/HolidayController.php b/backend/app/Http/Controllers/Api/HolidayController.php
new file mode 100644
index 00000000..e9085016
--- /dev/null
+++ b/backend/app/Http/Controllers/Api/HolidayController.php
@@ -0,0 +1,99 @@
+validate([
+ 'month' => 'nullable|date_format:Y-m',
+ ]);
+
+ $holidays = isset($data['month'])
+ ? $this->capacityService->getHolidaysForMonth($data['month'])
+ : Holiday::orderBy('date')->get();
+
+ return response()->json($holidays);
+ }
+
+ /**
+ * Create Holiday
+ *
+ * Add a holiday and clear cached capacity data for the related month.
+ *
+ * @group Capacity Planning
+ * @bodyParam date string required Date of the holiday. Example: 2026-02-14
+ * @bodyParam name string required Name of the holiday. Example: Presidents' Day
+ * @bodyParam description string nullable Optional description of the holiday.
+ * @response 201 {
+ * "id": "550e8400-e29b-41d4-a716-446655440000",
+ * "date": "2026-02-14",
+ * "name": "Presidents' Day",
+ * "description": "Office closed"
+ * }
+ */
+ public function store(Request $request): JsonResponse
+ {
+ $data = $request->validate([
+ 'date' => 'required|date',
+ 'name' => 'required|string',
+ 'description' => 'nullable|string',
+ ]);
+
+ $holiday = Holiday::create($data);
+ $this->capacityService->forgetCapacityCacheForMonth($holiday->date->format('Y-m'));
+
+ return response()->json($holiday, 201);
+ }
+
+ /**
+ * Delete Holiday
+ *
+ * Remove a holiday and clear affected capacity caches.
+ *
+ * @group Capacity Planning
+ * @urlParam id string required The holiday UUID. Example: 550e8400-e29b-41d4-a716-446655440000
+ * @response {
+ * "message": "Holiday deleted"
+ * }
+ */
+ public function destroy(string $id): JsonResponse
+ {
+ $holiday = Holiday::find($id);
+
+ if (! $holiday) {
+ return response()->json(['message' => 'Holiday not found'], 404);
+ }
+
+ $month = $holiday->date->format('Y-m');
+ $holiday->delete();
+ $this->capacityService->forgetCapacityCacheForMonth($month);
+
+ return response()->json(['message' => 'Holiday deleted']);
+ }
+}
diff --git a/backend/app/Http/Controllers/Api/PtoController.php b/backend/app/Http/Controllers/Api/PtoController.php
new file mode 100644
index 00000000..5f018759
--- /dev/null
+++ b/backend/app/Http/Controllers/Api/PtoController.php
@@ -0,0 +1,135 @@
+validate([
+ 'team_member_id' => 'required|exists:team_members,id',
+ 'month' => 'nullable|date_format:Y-m',
+ ]);
+
+ $query = Pto::where('team_member_id', $data['team_member_id']);
+
+ if (! empty($data['month'])) {
+ $start = Carbon::createFromFormat('Y-m', $data['month'])->startOfMonth();
+ $end = $start->copy()->endOfMonth();
+
+ $query->where(function ($statement) use ($start, $end): void {
+ $statement->whereBetween('start_date', [$start, $end])
+ ->orWhereBetween('end_date', [$start, $end])
+ ->orWhere(function ($nested) use ($start, $end): void {
+ $nested->where('start_date', '<=', $start)
+ ->where('end_date', '>=', $end);
+ });
+ });
+ }
+
+ $ptos = $query->orderBy('start_date')->get();
+
+ return response()->json($ptos);
+ }
+
+ /**
+ * Request PTO
+ *
+ * Create a PTO request for a team member and keep it in pending status.
+ *
+ * @group Capacity Planning
+ * @bodyParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
+ * @bodyParam start_date string required The first day of the PTO. Example: 2026-02-10
+ * @bodyParam end_date string required The final day of the PTO. Example: 2026-02-12
+ * @bodyParam reason string nullable Optional reason for the PTO.
+ * @response 201 {
+ * "id": "550e8400-e29b-41d4-a716-446655440001",
+ * "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
+ * "start_date": "2026-02-10",
+ * "end_date": "2026-02-12",
+ * "status": "pending",
+ * "reason": "Family travel"
+ * }
+ */
+ public function store(Request $request): JsonResponse
+ {
+ $data = $request->validate([
+ 'team_member_id' => 'required|exists:team_members,id',
+ 'start_date' => 'required|date',
+ 'end_date' => 'required|date|after_or_equal:start_date',
+ 'reason' => 'nullable|string',
+ ]);
+
+ $pto = Pto::create(array_merge($data, ['status' => 'pending']));
+
+ return response()->json($pto, 201);
+ }
+
+ /**
+ * Approve PTO
+ *
+ * Approve a pending PTO request and refresh the affected capacity caches.
+ *
+ * @group Capacity Planning
+ * @urlParam id string required The PTO UUID that needs approval. Example: 550e8400-e29b-41d4-a716-446655440001
+ * @response {
+ * "id": "550e8400-e29b-41d4-a716-446655440001",
+ * "status": "approved"
+ * }
+ */
+ public function approve(string $id): JsonResponse
+ {
+ $pto = Pto::findOrFail($id);
+
+ if ($pto->status !== 'approved') {
+ $pto->status = 'approved';
+ $pto->save();
+ $months = $this->monthsBetween($pto->start_date, $pto->end_date);
+ $this->capacityService->forgetCapacityCacheForTeamMember($pto->team_member_id, $months);
+ }
+
+ return response()->json($pto);
+ }
+
+ private function monthsBetween(Carbon|string $start, Carbon|string $end): array
+ {
+ $startMonth = Carbon::create($start)->copy()->startOfMonth();
+ $endMonth = Carbon::create($end)->copy()->startOfMonth();
+ $months = [];
+
+ while ($startMonth <= $endMonth) {
+ $months[] = $startMonth->format('Y-m');
+ $startMonth->addMonth();
+ }
+
+ return $months;
+ }
+}
diff --git a/backend/app/Models/TeamMemberAvailability.php b/backend/app/Models/TeamMemberAvailability.php
new file mode 100644
index 00000000..28ceb1dd
--- /dev/null
+++ b/backend/app/Models/TeamMemberAvailability.php
@@ -0,0 +1,37 @@
+ 'date',
+ 'availability' => 'float',
+ ];
+
+ public function teamMember(): BelongsTo
+ {
+ return $this->belongsTo(TeamMember::class);
+ }
+}
diff --git a/backend/app/Services/CapacityService.php b/backend/app/Services/CapacityService.php
new file mode 100644
index 00000000..0ea778b6
--- /dev/null
+++ b/backend/app/Services/CapacityService.php
@@ -0,0 +1,362 @@
+getHolidaysForMonth($month)
+ ->pluck('date')
+ ->map(fn (Carbon $date): string => $date->toDateString())
+ ->all();
+
+ return WorkingDaysCalculator::calculate($month, $holidayDates);
+ }
+
+ /**
+ * Calculate capacity for a single team member for the requested month.
+ */
+ public function calculateIndividualCapacity(string $teamMemberId, string $month): array
+ {
+ $cacheKey = $this->buildCacheKey($month, $teamMemberId);
+ $tags = $this->getCapacityCacheTags($month, "team_member:{$teamMemberId}");
+
+ $resolver = function () use ($teamMemberId, $month): array {
+ $period = $this->createMonthPeriod($month);
+ $holidayDates = $this->getHolidaysForMonth($month)
+ ->pluck('date')
+ ->map(fn (Carbon $date): string => $date->toDateString())
+ ->all();
+ $holidayLookup = array_flip($holidayDates);
+ $ptoDates = $this->buildPtoDates($this->getPtoForTeamMember($teamMemberId, $month), $month);
+ $availabilities = $this->getAvailabilityEntries($teamMemberId, $month);
+ $personDays = 0.0;
+ $details = [];
+
+ foreach ($period as $day) {
+ $date = $day->toDateString();
+
+ if (! WorkingDaysCalculator::isWorkingDay($date, $holidayLookup)) {
+ continue;
+ }
+
+ $availability = $availabilities->get($date, 1.0);
+ $isPto = in_array($date, $ptoDates, true);
+
+ if ($isPto) {
+ $availability = 0.0;
+ }
+
+ $details[] = [
+ 'date' => $date,
+ 'availability' => (float) $availability,
+ 'is_pto' => $isPto,
+ ];
+
+ $personDays += $availability;
+ }
+
+ $hours = (int) round($personDays * $this->hoursPerDay);
+
+ return [
+ 'person_days' => round($personDays, 2),
+ 'hours' => $hours,
+ 'details' => $details,
+ ];
+ };
+
+ /** @var array $capacity */
+ /** @var array $capacity */
+ $capacity = $this->rememberCapacity($cacheKey, now()->addHour(), $resolver, $tags);
+
+ return $capacity;
+ }
+
+ /**
+ * Calculate the combined capacity for all active team members.
+ */
+ public function calculateTeamCapacity(string $month): array
+ {
+ $cacheKey = $this->buildCacheKey($month, 'team');
+ $tags = $this->getCapacityCacheTags($month, 'team');
+
+ /** @var array $payload */
+ $payload = $this->rememberCapacity($cacheKey, now()->addHour(), function () use ($month): array {
+ $activeMembers = TeamMember::where('active', true)->get();
+ $totalDays = 0.0;
+ $totalHours = 0;
+ $members = [];
+
+ foreach ($activeMembers as $member) {
+ $capacity = $this->calculateIndividualCapacity($member->id, $month);
+
+ $totalDays += $capacity['person_days'];
+ $totalHours += $capacity['hours'];
+
+ $members[] = [
+ 'id' => $member->id,
+ 'name' => $member->name,
+ 'person_days' => $capacity['person_days'],
+ 'hours' => $capacity['hours'],
+ ];
+ }
+
+ return [
+ 'month' => $month,
+ 'person_days' => round($totalDays, 2),
+ 'hours' => $totalHours,
+ 'members' => $members,
+ ];
+ }, $tags);
+
+ return $payload;
+ }
+
+ /**
+ * Estimate revenue by multiplying capacity hours with hourly rates.
+ */
+ public function calculatePossibleRevenue(string $month): float
+ {
+ $cacheKey = $this->buildCacheKey($month, 'revenue');
+ $tags = $this->getCapacityCacheTags($month, 'revenue');
+
+ /** @var float $revenue */
+ $revenue = $this->rememberCapacity($cacheKey, now()->addHour(), function () use ($month): float {
+ $activeMembers = TeamMember::where('active', true)->get();
+ $revenue = 0.0;
+
+ foreach ($activeMembers as $member) {
+ $capacity = $this->calculateIndividualCapacity($member->id, $month);
+ $revenue += $capacity['hours'] * (float) $member->hourly_rate;
+ }
+
+ return round($revenue, 2);
+ }, $tags);
+
+ return $revenue;
+ }
+
+ /**
+ * Return all holidays in the requested month.
+ */
+ public function getHolidaysForMonth(string $month): Collection
+ {
+ $period = $this->createMonthPeriod($month);
+
+ return Holiday::whereBetween('date', [$period->getStartDate(), $period->getEndDate()])
+ ->orderBy('date')
+ ->get();
+ }
+
+ /**
+ * Return approved PTO records for a team member inside the requested month.
+ */
+ public function getPtoForTeamMember(string $teamMemberId, string $month): Collection
+ {
+ $period = $this->createMonthPeriod($month);
+
+ return Pto::where('team_member_id', $teamMemberId)
+ ->where('status', 'approved')
+ ->where(function ($query) use ($period): void {
+ $query->whereBetween('start_date', [$period->getStartDate(), $period->getEndDate()])
+ ->orWhereBetween('end_date', [$period->getStartDate(), $period->getEndDate()])
+ ->orWhere(function ($nested) use ($period): void {
+ $nested->where('start_date', '<=', $period->getStartDate())
+ ->where('end_date', '>=', $period->getEndDate());
+ });
+ })
+ ->get();
+ }
+
+ /**
+ * Clear redis cache for a specific month and team member.
+ */
+ public function forgetCapacityCacheForTeamMember(string $teamMemberId, array $months): void
+ {
+ $useRedis = $this->redisAvailable();
+
+ foreach ($months as $month) {
+ $tags = $this->getCapacityCacheTags($month, "team_member:{$teamMemberId}");
+
+ if ($useRedis) {
+ $this->flushCapacityTags($tags);
+ continue;
+ }
+
+ $this->forgetCapacity($this->buildCacheKey($month, $teamMemberId));
+ }
+ }
+
+ /**
+ * Clear redis cache for a month across all team members.
+ */
+ public function forgetCapacityCacheForMonth(string $month): void
+ {
+ if ($this->redisAvailable()) {
+ $this->flushCapacityTags($this->getCapacityCacheTags($month));
+ return;
+ }
+
+ foreach (TeamMember::pluck('id') as $teamMemberId) {
+ $this->forgetCapacity($this->buildCacheKey($month, $teamMemberId));
+ }
+
+ $this->forgetCapacity($this->buildCacheKey($month, 'team'));
+ $this->forgetCapacity($this->buildCacheKey($month, 'revenue'));
+ }
+
+ /**
+ * Build the cache key used for storing individual capacity data.
+ */
+ private function buildCacheKey(string $month, string $teamMemberId): string
+ {
+ return "capacity:{$month}:{$teamMemberId}";
+ }
+
+ private function getCapacityCacheTags(string $month, ?string $context = null): array
+ {
+ $tags = ['capacity', "capacity:month:{$month}"];
+
+ if ($context) {
+ $tags[] = "capacity:{$context}";
+ }
+
+ return $tags;
+ }
+
+ private function flushCapacityTags(array $tags): void
+ {
+ if (! $this->redisAvailable()) {
+ return;
+ }
+
+ try {
+ /** @var CacheRepository $store */
+ $store = Cache::store('redis');
+ $store->tags($tags)->flush();
+ } catch (Throwable) {
+ // Ignore cache failures when Redis is unavailable.
+ }
+ }
+
+ /**
+ * Load availability entries for the team member within the month, keyed by date.
+ */
+ private function getAvailabilityEntries(string $teamMemberId, string $month): Collection
+ {
+ $period = $this->createMonthPeriod($month);
+
+ return TeamMemberAvailability::where('team_member_id', $teamMemberId)
+ ->whereBetween('date', [$period->getStartDate(), $period->getEndDate()])
+ ->get()
+ ->mapWithKeys(fn (TeamMemberAvailability $entry) => [$entry->date->toDateString() => (float) $entry->availability]);
+ }
+
+ /**
+ * Create a CarbonPeriod for the given month.
+ */
+ private function createMonthPeriod(string $month): CarbonPeriod
+ {
+ $start = Carbon::createFromFormat('Y-m', $month)->startOfMonth();
+ $end = $start->copy()->endOfMonth();
+
+ return CarbonPeriod::create($start, $end);
+ }
+
+ /**
+ * Expand PTO records into a unique list of dates inside the requested month.
+ */
+ private function buildPtoDates(Collection $ptos, string $month): array
+ {
+ $period = $this->createMonthPeriod($month);
+ $dates = [];
+
+ foreach ($ptos as $pto) {
+ $ptoStart = Carbon::create($pto->start_date)->max($period->getStartDate());
+ $ptoEnd = Carbon::create($pto->end_date)->min($period->getEndDate());
+
+ if ($ptoStart->greaterThan($ptoEnd)) {
+ continue;
+ }
+
+ foreach (CarbonPeriod::create($ptoStart, $ptoEnd) as $day) {
+ $dates[] = $day->toDateString();
+ }
+ }
+
+ return array_unique($dates);
+ }
+
+ private function rememberCapacity(string $key, DateTimeInterface|int $ttl, callable $callback, array $tags = []): mixed
+ {
+ if (! $this->redisAvailable()) {
+ return Cache::store('array')->remember($key, $ttl, $callback);
+ }
+
+ try {
+ /** @var CacheRepository $store */
+ $store = Cache::store('redis');
+
+ if (! empty($tags)) {
+ $store = $store->tags($tags);
+ }
+
+ return $store->remember($key, $ttl, $callback);
+ } catch (Throwable) {
+ return Cache::store('array')->remember($key, $ttl, $callback);
+ }
+ }
+
+ private function forgetCapacity(string $key): void
+ {
+ if (! $this->redisAvailable()) {
+ return;
+ }
+
+ try {
+ Cache::store('redis')->forget($key);
+ } catch (Throwable) {
+ // Ignore cache failures when Redis is unavailable.
+ }
+ }
+
+ private function redisAvailable(): bool
+ {
+ if ($this->redisAvailable !== null) {
+ return $this->redisAvailable;
+ }
+
+ if (! config('cache.stores.redis')) {
+ return $this->redisAvailable = false;
+ }
+
+ $client = config('database.redis.client', 'phpredis');
+
+ if ($client === 'predis') {
+ return $this->redisAvailable = class_exists('\Predis\Client');
+ }
+
+ return $this->redisAvailable = extension_loaded('redis');
+ }
+}
diff --git a/backend/app/Utilities/WorkingDaysCalculator.php b/backend/app/Utilities/WorkingDaysCalculator.php
new file mode 100644
index 00000000..62137aa9
--- /dev/null
+++ b/backend/app/Utilities/WorkingDaysCalculator.php
@@ -0,0 +1,49 @@
+startOfMonth();
+ $end = $start->copy()->endOfMonth();
+
+ return self::getWorkingDaysInRange($start->toDateString(), $end->toDateString(), $holidays);
+ }
+
+ public static function getWorkingDaysInRange(string $start, string $end, array $holidays = []): int
+ {
+ $period = CarbonPeriod::create(Carbon::create($start), Carbon::create($end));
+ $holidayLookup = array_flip($holidays);
+ $workingDays = 0;
+
+ foreach ($period as $day) {
+ $date = $day->toDateString();
+
+ if (self::isWorkingDay($date, $holidayLookup)) {
+ $workingDays++;
+ }
+ }
+
+ return $workingDays;
+ }
+
+ public static function isWorkingDay(string $date, array $holidays = []): bool
+ {
+ $carbonDate = Carbon::create($date);
+
+ if ($carbonDate->isWeekend()) {
+ return false;
+ }
+
+ if (isset($holidays[$carbonDate->toDateString()])) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/backend/composer.json b/backend/composer.json
index fc8891c0..bda01c16 100644
--- a/backend/composer.json
+++ b/backend/composer.json
@@ -18,7 +18,6 @@
"tymon/jwt-auth": "^2.0"
},
"require-dev": {
- "laravel/boost": "^2.1",
"fakerphp/faker": "^1.23",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
diff --git a/backend/composer.lock b/backend/composer.lock
index 972f79af..b623cc3a 100644
--- a/backend/composer.lock
+++ b/backend/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "fa711629878d91ad308c94f502ab3af4",
+ "content-hash": "eb1f270f832bd2bd086e4cccb3a4945d",
"packages": [
{
"name": "brick/math",
@@ -7283,145 +7283,6 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
- {
- "name": "laravel/boost",
- "version": "v2.1.2",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/boost.git",
- "reference": "81ecf79e82c979efd92afaeac012605cc7b2f31f"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/boost/zipball/81ecf79e82c979efd92afaeac012605cc7b2f31f",
- "reference": "81ecf79e82c979efd92afaeac012605cc7b2f31f",
- "shasum": ""
- },
- "require": {
- "guzzlehttp/guzzle": "^7.9",
- "illuminate/console": "^11.45.3|^12.41.1",
- "illuminate/contracts": "^11.45.3|^12.41.1",
- "illuminate/routing": "^11.45.3|^12.41.1",
- "illuminate/support": "^11.45.3|^12.41.1",
- "laravel/mcp": "^0.5.1",
- "laravel/prompts": "^0.3.10",
- "laravel/roster": "^0.2.9",
- "php": "^8.2"
- },
- "require-dev": {
- "laravel/pint": "^1.27.0",
- "mockery/mockery": "^1.6.12",
- "orchestra/testbench": "^9.15.0|^10.6",
- "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
- "phpstan/phpstan": "^2.1.27",
- "rector/rector": "^2.1"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "providers": [
- "Laravel\\Boost\\BoostServiceProvider"
- ]
- },
- "branch-alias": {
- "dev-master": "1.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Boost\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
- "homepage": "https://github.com/laravel/boost",
- "keywords": [
- "ai",
- "dev",
- "laravel"
- ],
- "support": {
- "issues": "https://github.com/laravel/boost/issues",
- "source": "https://github.com/laravel/boost"
- },
- "time": "2026-02-10T17:40:45+00:00"
- },
- {
- "name": "laravel/mcp",
- "version": "v0.5.5",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/mcp.git",
- "reference": "b3327bb75fd2327577281e507e2dbc51649513d6"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/b3327bb75fd2327577281e507e2dbc51649513d6",
- "reference": "b3327bb75fd2327577281e507e2dbc51649513d6",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "ext-mbstring": "*",
- "illuminate/console": "^11.45.3|^12.41.1|^13.0",
- "illuminate/container": "^11.45.3|^12.41.1|^13.0",
- "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
- "illuminate/http": "^11.45.3|^12.41.1|^13.0",
- "illuminate/json-schema": "^12.41.1|^13.0",
- "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
- "illuminate/support": "^11.45.3|^12.41.1|^13.0",
- "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
- "php": "^8.2"
- },
- "require-dev": {
- "laravel/pint": "^1.20",
- "orchestra/testbench": "^9.15|^10.8|^11.0",
- "pestphp/pest": "^3.8.5|^4.3.2",
- "phpstan/phpstan": "^2.1.27",
- "rector/rector": "^2.2.4"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "aliases": {
- "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
- },
- "providers": [
- "Laravel\\Mcp\\Server\\McpServiceProvider"
- ]
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Mcp\\": "src/",
- "Laravel\\Mcp\\Server\\": "src/Server/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- }
- ],
- "description": "Rapidly build MCP servers for your Laravel applications.",
- "homepage": "https://github.com/laravel/mcp",
- "keywords": [
- "laravel",
- "mcp"
- ],
- "support": {
- "issues": "https://github.com/laravel/mcp/issues",
- "source": "https://github.com/laravel/mcp"
- },
- "time": "2026-02-05T14:05:18+00:00"
- },
{
"name": "laravel/pail",
"version": "v1.2.6",
@@ -7569,67 +7430,6 @@
},
"time": "2026-02-10T20:00:20+00:00"
},
- {
- "name": "laravel/roster",
- "version": "v0.2.9",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/roster.git",
- "reference": "82bbd0e2de614906811aebdf16b4305956816fa6"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6",
- "reference": "82bbd0e2de614906811aebdf16b4305956816fa6",
- "shasum": ""
- },
- "require": {
- "illuminate/console": "^10.0|^11.0|^12.0",
- "illuminate/contracts": "^10.0|^11.0|^12.0",
- "illuminate/routing": "^10.0|^11.0|^12.0",
- "illuminate/support": "^10.0|^11.0|^12.0",
- "php": "^8.1|^8.2",
- "symfony/yaml": "^6.4|^7.2"
- },
- "require-dev": {
- "laravel/pint": "^1.14",
- "mockery/mockery": "^1.6",
- "orchestra/testbench": "^8.22.0|^9.0|^10.0",
- "pestphp/pest": "^2.0|^3.0",
- "phpstan/phpstan": "^2.0"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "providers": [
- "Laravel\\Roster\\RosterServiceProvider"
- ]
- },
- "branch-alias": {
- "dev-master": "1.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Roster\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "Detect packages & approaches in use within a Laravel project",
- "homepage": "https://github.com/laravel/roster",
- "keywords": [
- "dev",
- "laravel"
- ],
- "support": {
- "issues": "https://github.com/laravel/roster/issues",
- "source": "https://github.com/laravel/roster"
- },
- "time": "2025-10-20T09:56:46+00:00"
- },
{
"name": "laravel/sail",
"version": "v1.53.0",
diff --git a/backend/database/factories/TeamMemberAvailabilityFactory.php b/backend/database/factories/TeamMemberAvailabilityFactory.php
new file mode 100644
index 00000000..a4535e18
--- /dev/null
+++ b/backend/database/factories/TeamMemberAvailabilityFactory.php
@@ -0,0 +1,41 @@
+
+ */
+class TeamMemberAvailabilityFactory extends Factory
+{
+ protected $model = TeamMemberAvailability::class;
+
+ /**
+ * Define the model's default state.
+ *
+ * @return array+
+ +Calculate capacity for a specific team member in a given month.
+ + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/capacity" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"month\": \"2026-02\",
+ \"team_member_id\": \"architecto\"
+}"
+const url = new URL(
+ "http://localhost/api/capacity"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "month": "2026-02",
+ "team_member_id": "architecto"
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "person_days": 18.5,
+ "hours": 148,
+ "details": [
+ {
+ "date": "2026-02-02",
+ "availability": 1,
+ "is_pto": false
+ }
+ ]
+}
+
+
+
+ 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.
+
+
+
+ +
+ +Summarize the combined capacity for all active team members in a month.
+ + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/capacity/team" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"month\": \"2026-02\"
+}"
+const url = new URL(
+ "http://localhost/api/capacity/team"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "month": "2026-02"
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "month": "2026-02",
+ "person_days": 180.5,
+ "hours": 1444,
+ "members": [
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "name": "Ada Lovelace",
+ "person_days": 18.5,
+ "hours": 148
+ }
+ ]
+}
+
+
+
+ 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.
+
+
+
+ +
+ +Estimate monthly revenue based on capacity hours and hourly rates.
+ + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/capacity/revenue" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"month\": \"2026-02\"
+}"
+const url = new URL(
+ "http://localhost/api/capacity/revenue"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "month": "2026-02"
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "month": "2026-02",
+ "possible_revenue": 21500.25
+}
+
+
+
+ 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.
+
+
+
+ +
+ +Retrieve holidays for a specific month or all holidays when no month is provided.
+ + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/holidays" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"month\": \"2026-02\"
+}"
+const url = new URL(
+ "http://localhost/api/holidays"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "month": "2026-02"
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+[
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "date": "2026-02-14",
+ "name": "Company Holiday",
+ "description": "Office closed"
+ }
+]
+
+
+
+ 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.
+
+
+
+ +
+ +Add a holiday and clear cached capacity data for the related month.
+ + +Example request:+ + +
curl --request POST \
+ "http://localhost/api/holidays" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"date\": \"2026-02-14\",
+ \"name\": \"Presidents\' Day\",
+ \"description\": \"Eius et animi quos velit et.\"
+}"
+const url = new URL(
+ "http://localhost/api/holidays"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "date": "2026-02-14",
+ "name": "Presidents' Day",
+ "description": "Eius et animi quos velit et."
+};
+
+fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (201):
+
+
+{
+ "id": "550e8400-e29b-41d4-a716-446655440000",
+ "date": "2026-02-14",
+ "name": "Presidents' Day",
+ "description": "Office closed"
+}
+
+
+
+ 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.
+
+
+
+ +
+ +Remove a holiday and clear affected capacity caches.
+ + +Example request:+ + +
curl --request DELETE \
+ "http://localhost/api/holidays/550e8400-e29b-41d4-a716-446655440000" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json"const url = new URL(
+ "http://localhost/api/holidays/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": "Holiday deleted"
+}
+
+
+
+ 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.
+
+
+
+ +
+ +Fetch PTO requests for a team member, optionally constrained to a month.
+ + +Example request:+ + +
curl --request GET \
+ --get "http://localhost/api/ptos" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"team_member_id\": \"architecto\",
+ \"month\": \"2026-02\"
+}"
+const url = new URL(
+ "http://localhost/api/ptos"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "team_member_id": "architecto",
+ "month": "2026-02"
+};
+
+fetch(url, {
+ method: "GET",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (200):
+
+
+[
+ {
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
+ "start_date": "2026-02-10",
+ "end_date": "2026-02-12",
+ "status": "pending",
+ "reason": "Family travel"
+ }
+]
+
+
+
+ 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.
+
+
+
+ +
+ +Create a PTO request for a team member and keep it in pending status.
+ + +Example request:+ + +
curl --request POST \
+ "http://localhost/api/ptos" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json" \
+ --data "{
+ \"team_member_id\": \"550e8400-e29b-41d4-a716-446655440000\",
+ \"start_date\": \"2026-02-10\",
+ \"end_date\": \"2026-02-12\",
+ \"reason\": \"architecto\"
+}"
+const url = new URL(
+ "http://localhost/api/ptos"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+let body = {
+ "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
+ "start_date": "2026-02-10",
+ "end_date": "2026-02-12",
+ "reason": "architecto"
+};
+
+fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+}).then(response => response.json());++Example response (201):
+
+
+{
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
+ "start_date": "2026-02-10",
+ "end_date": "2026-02-12",
+ "status": "pending",
+ "reason": "Family travel"
+}
+
+
+
+ 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.
+
+
+
+ +
+ +Approve a pending PTO request and refresh the affected capacity caches.
+ + +Example request:+ + +
curl --request PUT \
+ "http://localhost/api/ptos/550e8400-e29b-41d4-a716-446655440001/approve" \
+ --header "Content-Type: application/json" \
+ --header "Accept: application/json"const url = new URL(
+ "http://localhost/api/ptos/550e8400-e29b-41d4-a716-446655440001/approve"
+);
+
+const headers = {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+};
+
+fetch(url, {
+ method: "PUT",
+ headers,
+}).then(response => response.json());++Example response (200):
+
+
+{
+ "id": "550e8400-e29b-41d4-a716-446655440001",
+ "status": "approved"
+}
+
+
+
+ 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.
+
+
+
Endpoints for managing projects.
diff --git a/backend/routes/api.php b/backend/routes/api.php index a9f1cca3..eb69b96b 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,7 +1,10 @@ group(function () { Route::put('projects/{project}/status', [ProjectController::class, 'updateStatus']); Route::put('projects/{project}/estimate', [ProjectController::class, 'setEstimate']); Route::put('projects/{project}/forecast', [ProjectController::class, 'setForecast']); + + // Capacity + Route::get('/capacity', [CapacityController::class, 'individual']); + Route::get('/capacity/team', [CapacityController::class, 'team']); + Route::get('/capacity/revenue', [CapacityController::class, 'revenue']); + + // Holidays + Route::get('/holidays', [HolidayController::class, 'index']); + Route::post('/holidays', [HolidayController::class, 'store']); + Route::delete('/holidays/{id}', [HolidayController::class, 'destroy']); + + // PTO + Route::get('/ptos', [PtoController::class, 'index']); + Route::post('/ptos', [PtoController::class, 'store']); + Route::put('/ptos/{id}/approve', [PtoController::class, 'approve']); }); diff --git a/backend/tests/Feature/Capacity/CapacityTest.php b/backend/tests/Feature/Capacity/CapacityTest.php new file mode 100644 index 00000000..d8e8d85a --- /dev/null +++ b/backend/tests/Feature/Capacity/CapacityTest.php @@ -0,0 +1,188 @@ +create(); + $teamMember = TeamMember::factory()->create(['role_id' => $role->id]); + + $response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$teamMember->id}", [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(200); + $expected = app(CapacityService::class)->calculateIndividualCapacity($teamMember->id, '2026-02'); + $response->assertExactJson($expected); +}); + +test('4.1.12 Capacity accounts for availability', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + TeamMemberAvailability::factory()->forDate('2026-02-03')->availability(0.5)->create(['team_member_id' => $member->id]); + TeamMemberAvailability::factory()->forDate('2026-02-04')->availability(0.0)->create(['team_member_id' => $member->id]); + + $response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(200); + $details = collect($response->json('details')); + + expect($details->firstWhere('date', '2026-02-03')['availability'])->toBe(0.5); + expect($details->firstWhere('date', '2026-02-04')['availability'])->toBe(0); +}); + +test('4.1.13 Capacity subtracts PTO', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-12', + 'reason' => 'Vacation', + 'status' => 'approved', + ]); + + $response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(200); + $details = collect($response->json('details')); + + expect($details->where('is_pto', true)->count())->toBe(3); + expect($details->firstWhere('date', '2026-02-11')['availability'])->toBe(0); +}); + +test('4.1.14 Capacity subtracts holidays', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + Holiday::create([ + 'date' => '2026-02-17', + 'name' => 'Presidents Day', + 'description' => 'Company wide', + ]); + + $response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(200); + $dates = collect($response->json('details'))->pluck('date'); + + expect($dates)->not->toContain('2026-02-17'); +}); + +test('4.1.15 GET /api/capacity/team sums active members', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + $activeA = TeamMember::factory()->create(['role_id' => $role->id]); + $activeB = TeamMember::factory()->create(['role_id' => $role->id]); + TeamMember::factory()->inactive()->create(['role_id' => $role->id]); + + $expectedDays = 0; + $expectedHours = 0; + + foreach ([$activeA, $activeB] as $member) { + $capacity = app(CapacityService::class)->calculateIndividualCapacity($member->id, '2026-02'); + $expectedDays += $capacity['person_days']; + $expectedHours += $capacity['hours']; + } + + $response = $this->getJson('/api/capacity/team?month=2026-02', [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(200); + $response->assertJsonCount(2, 'members'); + expect(round($response->json('person_days'), 2))->toBe(round($expectedDays, 2)); + expect($response->json('hours'))->toBe($expectedHours); +}); + +test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 150]); + TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 125]); + + $expectedRevenue = app(CapacityService::class)->calculatePossibleRevenue('2026-02'); + + $response = $this->getJson('/api/capacity/revenue?month=2026-02', [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(200); + $response->assertJson(['possible_revenue' => $expectedRevenue]); +}); + +test('4.1.17 POST /api/holidays creates holiday', function () { + $token = loginAsManager($this); + + $response = $this->postJson('/api/holidays', [ + 'date' => '2026-02-20', + 'name' => 'Test Holiday', + 'description' => 'Test description', + ], [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(201); + assertDatabaseHas('holidays', ['date' => '2026-02-20 00:00:00', 'name' => 'Test Holiday']); +}); + +test('4.1.18 POST /api/ptos creates PTO request', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + $response = $this->postJson('/api/ptos', [ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-11', + 'reason' => 'Refresh', + ], [ + 'Authorization' => "Bearer {$token}" + ]); + + $response->assertStatus(201); + $response->assertJson(['status' => 'pending']); + assertDatabaseHas('ptos', ['team_member_id' => $member->id, 'status' => 'pending']); +}); + +function loginAsManager(TestCase $test): string +{ + $user = User::factory()->create([ + 'email' => 'manager@example.com', + 'password' => bcrypt('password123'), + 'role' => 'manager', + 'active' => true, + ]); + + $response = $test->postJson('/api/auth/login', [ + 'email' => 'manager@example.com', + 'password' => 'password123', + ]); + + return $response->json('access_token'); +} diff --git a/backend/tests/Unit/Services/CapacityServiceTest.php b/backend/tests/Unit/Services/CapacityServiceTest.php new file mode 100644 index 00000000..19c016d1 --- /dev/null +++ b/backend/tests/Unit/Services/CapacityServiceTest.php @@ -0,0 +1,115 @@ + '2026-02-11', 'name' => 'Extra Day', 'description' => 'Standalone']); + Holiday::create(['date' => '2026-02-25', 'name' => 'Another Day', 'description' => 'Standalone']); + + $period = CarbonPeriod::create('2026-02-01', '2026-02-28'); + $expected = 0; + + foreach ($period as $day) { + if ($day->isWeekend()) { + continue; + } + + if (in_array($day->toDateString(), ['2026-02-11', '2026-02-25'], true)) { + continue; + } + + $expected++; + } + + $service = app(CapacityService::class); + + expect($service->calculateWorkingDays('2026-02'))->toBe($expected); +}); + +test('4.1.20 CapacityService applies availability', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + TeamMemberAvailability::factory()->forDate('2026-02-03')->availability(0.5)->create(['team_member_id' => $member->id]); + TeamMemberAvailability::factory()->forDate('2026-02-04')->availability(0.0)->create(['team_member_id' => $member->id]); + + $result = app(CapacityService::class)->calculateIndividualCapacity($member->id, '2026-02'); + $details = collect($result['details']); + + expect($details->firstWhere('date', '2026-02-03')['availability'])->toBe(0.5); + expect($details->firstWhere('date', '2026-02-04')['availability'])->toBe(0.0); +}); + +test('4.1.21 CapacityService handles PTO', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-12', + 'reason' => 'Rest', + 'status' => 'approved', + ]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + expect($result['person_days'])->toBe((float) ($workingDays - 3)); +}); + +test('4.1.22 CapacityService handles holidays', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']); + + $service = app(CapacityService::class); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + $dates = collect($result['details'])->pluck('date'); + + expect($dates)->not->toContain('2026-02-17'); +}); + +test('4.1.23 CapacityService calculates revenue', function () { + $role = Role::factory()->create(); + $memberA = TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 150]); + $memberB = TeamMember::factory()->create(['role_id' => $role->id, 'hourly_rate' => 125]); + + $service = app(CapacityService::class); + $revenue = $service->calculatePossibleRevenue('2026-02'); + + $hoursA = $service->calculateIndividualCapacity($memberA->id, '2026-02')['hours']; + $hoursB = $service->calculateIndividualCapacity($memberB->id, '2026-02')['hours']; + + expect($revenue)->toBe(round($hoursA * 150 + $hoursB * 125, 2)); +}); + +test('4.1.24 Redis caching for capacity', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + $service = app(CapacityService::class); + $key = "capacity:2026-02:{$member->id}"; + + Cache::store('array')->forget($key); + $service->calculateIndividualCapacity($member->id, '2026-02'); + + expect(Cache::store('array')->get($key))->not->toBeNull(); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 3d454629..3ef7d1b3 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,21 +1,22 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ - testDir: './tests/e2e', + // Only look in tests/e2e for Playwright tests + testMatch: 'tests/e2e/**/*.spec.{ts,js}', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: 'list', - timeout: 60000, // 60 seconds per test + timeout: 60000, expect: { - timeout: 10000, // 10 seconds for assertions + timeout: 10000, }, use: { baseURL: 'http://127.0.0.1:5173', trace: 'on-first-retry', - actionTimeout: 15000, // 15 seconds for actions - navigationTimeout: 30000, // 30 seconds for navigation + actionTimeout: 15000, + navigationTimeout: 30000, }, projects: [ { @@ -27,5 +28,4 @@ export default defineConfig({ use: { ...devices['Desktop Firefox'] }, }, ], - // Note: Web server is managed by Docker Compose }); diff --git a/frontend/src/lib/api/capacity.ts b/frontend/src/lib/api/capacity.ts new file mode 100644 index 00000000..ba40d05a --- /dev/null +++ b/frontend/src/lib/api/capacity.ts @@ -0,0 +1,127 @@ +import { api } from '$lib/services/api'; +import type { + Capacity, + CapacityDetail, + Holiday, + PTO, + Revenue, + TeamCapacity +} from '$lib/types/capacity'; + +export interface CreateHolidayData { + date: string; + name: string; + description?: string; +} + +export interface CreatePTOData { + team_member_id: string; + start_date: string; + end_date: string; + reason?: string; +} + +export interface PTOParams { + team_member_id: string; + month?: string; +} + +function toCapacityDetail(detail: { date: string; availability: number; is_pto: boolean }): CapacityDetail { + const date = new Date(detail.date); + const dayOfWeek = date.getDay(); + return { + date: detail.date, + day_of_week: dayOfWeek, + is_weekend: dayOfWeek === 0 || dayOfWeek === 6, + is_holiday: false, + availability: detail.availability, + effective_hours: Math.round(detail.availability * 8 * 100) / 100, + is_pto: detail.is_pto + }; +} + +export async function getIndividualCapacity( + month: string, + teamMemberId: string +): Promise{month || 'Select a month to view calendar'}
+Team capacity
++ {teamCapacity ? teamCapacity.total_person_days.toFixed(1) : '0.0'}d +
+{teamCapacity ? teamCapacity.total_hours : 0} hrs
+Possible revenue
+{formatCurrency(revenue?.total_revenue ?? 0)}
+Based on current monthly capacity
+No capacity data yet.
+ {:else} +{member.team_member_name}
+{member.role_label}
+Person days
+{member.person_days.toFixed(2)}d
+Hours
+{member.hours}h
+Hourly rate
+{member.hourly_rate_label}
+No role breakdown available yet.
+ {:else} +{role.role}
+Hourly rate {formatCurrency(role.hourly_rate)}
+{role.person_days.toFixed(1)}d
+{role.hours} hrs
+Holidays
+No holidays scheduled for this month.
+ {:else} +{new Date(holiday.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} — {holiday.name}
+ {#if holiday.description} +{holiday.description}
+ {/if} +PTO requests
+No PTO requests for this team member.
+ {:else} + {#each ptos as pto} +{getMemberName(pto)}
++ {new Date(pto.start_date).toLocaleDateString('en-US')} - + {new Date(pto.end_date).toLocaleDateString('en-US')} +
+{pto.reason}
+ {/if} + {#if canManage && displayStatus(pto) === 'pending'} +Select a team member to view the calendar.
+ {:else if loadingIndividual} +