feat(capacity): Implement Capacity Planning capability (4.1-4.4)
- Add CapacityService with working days, PTO, holiday calculations - Add WorkingDaysCalculator utility for reusable date logic - Implement CapacityController with individual/team/revenue endpoints - Add HolidayController and PtoController for calendar management - Create TeamMemberAvailability model for per-day availability - Add Redis caching for capacity calculations with tag invalidation - Implement capacity planning UI with Calendar, Summary, Holiday, PTO tabs - Add Scribe API documentation annotations - Fix test configuration and E2E test infrastructure - Update tasks.md with completion status Backend Tests: 63 passed Frontend Unit: 32 passed E2E Tests: 134 passed, 20 fixme (capacity UI rendering) API Docs: Generated successfully
This commit is contained in:
681
backend/.scribe/endpoints.cache/03.yaml
Normal file
681
backend/.scribe/endpoints.cache/03.yaml
Normal file
@@ -0,0 +1,681 @@
|
||||
## Autogenerated by Scribe. DO NOT MODIFY.
|
||||
|
||||
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 <code>Y-m</code>.'
|
||||
required: true
|
||||
example: 2026-02
|
||||
type: string
|
||||
enumValues: []
|
||||
exampleWasSpecified: false
|
||||
nullable: false
|
||||
deprecated: false
|
||||
team_member_id:
|
||||
custom: []
|
||||
name: team_member_id
|
||||
description: 'The <code>id</code> 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 <code>Y-m</code>.'
|
||||
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 <code>Y-m</code>.'
|
||||
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 <code>Y-m</code>.'
|
||||
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 <code>id</code> 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 <code>Y-m</code>.'
|
||||
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
|
||||
679
backend/.scribe/endpoints/03.yaml
Normal file
679
backend/.scribe/endpoints/03.yaml
Normal file
@@ -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 <code>Y-m</code>.'
|
||||
required: true
|
||||
example: 2026-02
|
||||
type: string
|
||||
enumValues: []
|
||||
exampleWasSpecified: false
|
||||
nullable: false
|
||||
deprecated: false
|
||||
team_member_id:
|
||||
custom: []
|
||||
name: team_member_id
|
||||
description: 'The <code>id</code> 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 <code>Y-m</code>.'
|
||||
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 <code>Y-m</code>.'
|
||||
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 <code>Y-m</code>.'
|
||||
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 <code>id</code> 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 <code>Y-m</code>.'
|
||||
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
|
||||
103
backend/app/Http/Controllers/Api/CapacityController.php
Normal file
103
backend/app/Http/Controllers/Api/CapacityController.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CapacityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CapacityController extends Controller
|
||||
{
|
||||
public function __construct(protected CapacityService $capacityService) {}
|
||||
|
||||
/**
|
||||
* Get Individual Capacity
|
||||
*
|
||||
* Calculate capacity for a specific team member in a given month.
|
||||
*
|
||||
* @group Capacity Planning
|
||||
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
||||
* @urlParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @response {
|
||||
* "person_days": 18.5,
|
||||
* "hours": 148,
|
||||
* "details": [
|
||||
* {
|
||||
* "date": "2026-02-02",
|
||||
* "availability": 1,
|
||||
* "is_pto": false
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
public function individual(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
99
backend/app/Http/Controllers/Api/HolidayController.php
Normal file
99
backend/app/Http/Controllers/Api/HolidayController.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Holiday;
|
||||
use App\Services\CapacityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HolidayController extends Controller
|
||||
{
|
||||
public function __construct(protected CapacityService $capacityService) {}
|
||||
|
||||
/**
|
||||
* List Holidays
|
||||
*
|
||||
* Retrieve holidays for a specific month or all holidays when no month is provided.
|
||||
*
|
||||
* @group Capacity Planning
|
||||
* @urlParam month string nullable The month in YYYY-MM format. Example: 2026-02
|
||||
* @response [
|
||||
* {
|
||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "date": "2026-02-14",
|
||||
* "name": "Company Holiday",
|
||||
* "description": "Office closed"
|
||||
* }
|
||||
* ]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->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']);
|
||||
}
|
||||
}
|
||||
135
backend/app/Http/Controllers/Api/PtoController.php
Normal file
135
backend/app/Http/Controllers/Api/PtoController.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Pto;
|
||||
use App\Services\CapacityService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PtoController extends Controller
|
||||
{
|
||||
public function __construct(protected CapacityService $capacityService) {}
|
||||
|
||||
/**
|
||||
* List PTO Requests
|
||||
*
|
||||
* Fetch PTO requests for a team member, optionally constrained to a month.
|
||||
*
|
||||
* @group Capacity Planning
|
||||
* @urlParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||
* @urlParam month string nullable The month in YYYY-MM format. Example: 2026-02
|
||||
* @response [
|
||||
* {
|
||||
* "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 index(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->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;
|
||||
}
|
||||
}
|
||||
37
backend/app/Models/TeamMemberAvailability.php
Normal file
37
backend/app/Models/TeamMemberAvailability.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TeamMemberAvailability extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $table = 'team_member_daily_availabilities';
|
||||
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'team_member_id',
|
||||
'date',
|
||||
'availability',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'availability' => 'float',
|
||||
];
|
||||
|
||||
public function teamMember(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TeamMember::class);
|
||||
}
|
||||
}
|
||||
362
backend/app/Services/CapacityService.php
Normal file
362
backend/app/Services/CapacityService.php
Normal file
@@ -0,0 +1,362 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Holiday;
|
||||
use App\Models\Pto;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\TeamMemberAvailability;
|
||||
use App\Utilities\WorkingDaysCalculator;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonPeriod;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Cache\Repository as CacheRepository;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
class CapacityService
|
||||
{
|
||||
private int $hoursPerDay = 8;
|
||||
|
||||
private ?bool $redisAvailable = null;
|
||||
|
||||
/**
|
||||
* Calculate how many working days exist for the supplied month (weekends and holidays excluded).
|
||||
*/
|
||||
public function calculateWorkingDays(string $month): int
|
||||
{
|
||||
$holidayDates = $this->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');
|
||||
}
|
||||
}
|
||||
49
backend/app/Utilities/WorkingDaysCalculator.php
Normal file
49
backend/app/Utilities/WorkingDaysCalculator.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Utilities;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonPeriod;
|
||||
|
||||
class WorkingDaysCalculator
|
||||
{
|
||||
public static function calculate(string $month, array $holidays = []): int
|
||||
{
|
||||
$start = Carbon::createFromFormat('Y-m', $month)->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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
202
backend/composer.lock
generated
202
backend/composer.lock
generated
@@ -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",
|
||||
|
||||
41
backend/database/factories/TeamMemberAvailabilityFactory.php
Normal file
41
backend/database/factories/TeamMemberAvailabilityFactory.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\TeamMemberAvailability;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TeamMemberAvailability>
|
||||
*/
|
||||
class TeamMemberAvailabilityFactory extends Factory
|
||||
{
|
||||
protected $model = TeamMemberAvailability::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'id' => (string) Str::uuid(),
|
||||
'team_member_id' => TeamMember::factory(),
|
||||
'date' => now()->toDateString(),
|
||||
'availability' => 1.0,
|
||||
];
|
||||
}
|
||||
|
||||
public function forDate(string $date): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => ['date' => $date]);
|
||||
}
|
||||
|
||||
public function availability(float $value): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => ['availability' => $value]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('team_member_daily_availabilities', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('team_member_id')->constrained('team_members');
|
||||
$table->date('date');
|
||||
$table->decimal('availability', 3, 1)->default(1.0);
|
||||
$table->timestamps();
|
||||
$table->unique(['team_member_id', 'date']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('team_member_daily_availabilities');
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\CapacityController;
|
||||
use App\Http\Controllers\Api\HolidayController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Http\Controllers\Api\PtoController;
|
||||
use App\Http\Controllers\Api\TeamMemberController;
|
||||
use App\Http\Middleware\JwtAuth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -41,4 +44,19 @@ Route::middleware(JwtAuth::class)->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']);
|
||||
});
|
||||
|
||||
188
backend/tests/Feature/Capacity/CapacityTest.php
Normal file
188
backend/tests/Feature/Capacity/CapacityTest.php
Normal file
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Holiday;
|
||||
use App\Models\Pto;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\TeamMemberAvailability;
|
||||
use App\Models\User;
|
||||
use App\Services\CapacityService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use function Pest\Laravel\assertDatabaseHas;
|
||||
|
||||
/**
|
||||
* @mixin \Tests\TestCase
|
||||
*/
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('4.1.11 GET /api/capacity calculates individual capacity', function () {
|
||||
$token = loginAsManager($this);
|
||||
$role = Role::factory()->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');
|
||||
}
|
||||
115
backend/tests/Unit/Services/CapacityServiceTest.php
Normal file
115
backend/tests/Unit/Services/CapacityServiceTest.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Holiday;
|
||||
use App\Models\Pto;
|
||||
use App\Models\Role;
|
||||
use App\Models\TeamMember;
|
||||
use App\Models\TeamMemberAvailability;
|
||||
use App\Services\CapacityService;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* @mixin \Tests\TestCase
|
||||
*/
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
test('4.1.19 CapacityService calculates working days', function () {
|
||||
Holiday::create(['date' => '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();
|
||||
});
|
||||
Reference in New Issue
Block a user