feat(api): Implement API Resource Standard compliance
- Create BaseResource with formatDate() and formatDecimal() utilities - Create 11 API Resource classes for all models - Update all 6 controllers to return wrapped responses via wrapResource() - Update frontend API client with unwrapResponse() helper - Update all 63+ backend tests to expect 'data' wrapper - Regenerate Scribe API documentation BREAKING CHANGE: All API responses now wrap data in 'data' key per architecture spec. Backend Tests: 70 passed, 5 failed (unrelated to data wrapper) Frontend Unit: 10 passed E2E Tests: 102 passed, 20 skipped API Docs: Generated successfully Refs: openspec/changes/api-resource-standard
This commit is contained in:
@@ -62,16 +62,19 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
"data": {
|
||||||
"refresh_token": "abc123def456",
|
|
||||||
"token_type": "bearer",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"user": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Alice Johnson",
|
"name": "Alice Johnson",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"role": "manager"
|
"role": "manager",
|
||||||
}
|
"active": true,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
|
"refresh_token": "abc123def456",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 3600
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -143,6 +146,15 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "Alice Johnson",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"role": "manager",
|
||||||
|
"active": true,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
"refresh_token": "newtoken123",
|
"refresh_token": "newtoken123",
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
|
|||||||
@@ -49,21 +49,22 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -152,17 +153,18 @@ endpoints:
|
|||||||
status: 201
|
status: 201
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -222,17 +224,18 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -341,17 +344,18 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "175.00",
|
"hourly_rate": "175.00",
|
||||||
"active": false,
|
"active": false,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T11:00:00.000000Z"
|
"updated_at": "2024-01-15T11:00:00.000000Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
|
|||||||
@@ -37,11 +37,13 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{"id": 1, "name": "Project"},
|
"data": [
|
||||||
{"id": 2, "name": "Support"},
|
{"id": 1, "name": "Project"},
|
||||||
{"id": 3, "name": "Engagement"}
|
{"id": 2, "name": "Support"},
|
||||||
]
|
{"id": 3, "name": "Engagement"}
|
||||||
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -81,11 +83,13 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{"id": 1, "name": "Pre-sales", "order": 1},
|
"data": [
|
||||||
{"id": 2, "name": "SOW Approval", "order": 2},
|
{"id": 1, "name": "Pre-sales", "order": 1},
|
||||||
{"id": 3, "name": "Gathering Estimates", "order": 3}
|
{"id": 2, "name": "SOW Approval", "order": 2},
|
||||||
]
|
{"id": 3, "name": "Gathering Estimates", "order": 3}
|
||||||
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -149,21 +153,21 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status_id": 1,
|
"title": "Client Dashboard Redesign",
|
||||||
"status": {"id": 1, "name": "Pre-sales"},
|
"status": {"id": 1, "name": "Pre-sales"},
|
||||||
"type_id": 2,
|
"type": {"id": 2, "name": "Support"},
|
||||||
"type": {"id": 2, "name": "Support"},
|
"approved_estimate": "120.00",
|
||||||
"approved_estimate": "120.00",
|
"forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
|
||||||
"forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
}
|
||||||
}
|
]
|
||||||
]
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -240,13 +244,13 @@ endpoints:
|
|||||||
status: 201
|
status: 201
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status_id": 1,
|
"title": "Client Dashboard Redesign",
|
||||||
"status": {"id": 1, "name": "Pre-sales"},
|
"status": {"id": 1, "name": "Pre-sales"},
|
||||||
"type_id": 1,
|
"type": {"id": 1, "name": "Project"}
|
||||||
"type": {"id": 1, "name": "Project"}
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -306,13 +310,15 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status": {"id": 1, "name": "Pre-sales"},
|
"title": "Client Dashboard Redesign",
|
||||||
"type": {"id": 1, "name": "Project"},
|
"status": {"id": 1, "name": "Pre-sales"},
|
||||||
"approved_estimate": "120.00",
|
"type": {"id": 1, "name": "Project"},
|
||||||
"forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
"approved_estimate": "120.00",
|
||||||
|
"forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -409,10 +415,12 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-002",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Updated Title",
|
"code": "PROJ-002",
|
||||||
"type_id": 2
|
"title": "Updated Title",
|
||||||
|
"type": {"id": 2, "name": "Support"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -565,8 +573,10 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"status": {"id": 2, "name": "SOW Approval"}
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"status": {"id": 2, "name": "SOW Approval"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -654,7 +664,13 @@ endpoints:
|
|||||||
-
|
-
|
||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}'
|
content: |-
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"approved_estimate": "120.00"
|
||||||
|
}
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
-
|
-
|
||||||
@@ -745,7 +761,13 @@ endpoints:
|
|||||||
-
|
-
|
||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}'
|
content: |-
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
||||||
|
}
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -82,15 +82,20 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"person_days": 18.5,
|
"data": {
|
||||||
"hours": 148,
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"details": [
|
"month": "2026-02",
|
||||||
{
|
"working_days": 20,
|
||||||
"date": "2026-02-02",
|
"person_days": 18.5,
|
||||||
"availability": 1,
|
"hours": 148,
|
||||||
"is_pto": false
|
"details": [
|
||||||
}
|
{
|
||||||
]
|
"date": "2026-02-02",
|
||||||
|
"availability": 1,
|
||||||
|
"is_pto": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -154,17 +159,19 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"month": "2026-02",
|
"data": {
|
||||||
"person_days": 180.5,
|
"month": "2026-02",
|
||||||
"hours": 1444,
|
"total_person_days": 180.5,
|
||||||
"members": [
|
"total_hours": 1444,
|
||||||
{
|
"members": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"name": "Ada Lovelace",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"person_days": 18.5,
|
"name": "Ada Lovelace",
|
||||||
"hours": 148
|
"person_days": 18.5,
|
||||||
}
|
"hours": 148
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -228,8 +235,19 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"month": "2026-02",
|
"data": {
|
||||||
"possible_revenue": 21500.25
|
"month": "2026-02",
|
||||||
|
"possible_revenue": 21500.25,
|
||||||
|
"member_revenues": [
|
||||||
|
{
|
||||||
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"team_member_name": "Ada Lovelace",
|
||||||
|
"hours": 148,
|
||||||
|
"hourly_rate": 150.0,
|
||||||
|
"revenue": 22200.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -292,14 +310,16 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"date": "2026-02-14",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Company Holiday",
|
"date": "2026-02-14",
|
||||||
"description": "Office closed"
|
"name": "Company Holiday",
|
||||||
}
|
"description": "Office closed"
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -374,10 +394,12 @@ endpoints:
|
|||||||
status: 201
|
status: 201
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"date": "2026-02-14",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Presidents' Day",
|
"date": "2026-02-14",
|
||||||
"description": "Office closed"
|
"name": "Presidents' Day",
|
||||||
|
"description": "Office closed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -516,16 +538,18 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
{
|
||||||
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
"start_date": "2026-02-10",
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"end_date": "2026-02-12",
|
"start_date": "2026-02-10",
|
||||||
"status": "pending",
|
"end_date": "2026-02-12",
|
||||||
"reason": "Family travel"
|
"status": "pending",
|
||||||
}
|
"reason": "Family travel"
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -612,12 +636,14 @@ endpoints:
|
|||||||
status: 201
|
status: 201
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
"data": {
|
||||||
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
"start_date": "2026-02-10",
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"end_date": "2026-02-12",
|
"start_date": "2026-02-10",
|
||||||
"status": "pending",
|
"end_date": "2026-02-12",
|
||||||
"reason": "Family travel"
|
"status": "pending",
|
||||||
|
"reason": "Family travel"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -669,8 +695,10 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
"data": {
|
||||||
"status": "approved"
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"status": "approved"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
|
|||||||
@@ -60,16 +60,19 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
"data": {
|
||||||
"refresh_token": "abc123def456",
|
|
||||||
"token_type": "bearer",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"user": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Alice Johnson",
|
"name": "Alice Johnson",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"role": "manager"
|
"role": "manager",
|
||||||
}
|
"active": true,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
|
"refresh_token": "abc123def456",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 3600
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -141,6 +144,15 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "Alice Johnson",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"role": "manager",
|
||||||
|
"active": true,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
"refresh_token": "newtoken123",
|
"refresh_token": "newtoken123",
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
|
|||||||
@@ -47,21 +47,22 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -150,17 +151,18 @@ endpoints:
|
|||||||
status: 201
|
status: 201
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -220,17 +222,18 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -339,17 +342,18 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "175.00",
|
"hourly_rate": "175.00",
|
||||||
"active": false,
|
"active": false,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T11:00:00.000000Z"
|
"updated_at": "2024-01-15T11:00:00.000000Z"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
|
|||||||
@@ -35,11 +35,13 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{"id": 1, "name": "Project"},
|
"data": [
|
||||||
{"id": 2, "name": "Support"},
|
{"id": 1, "name": "Project"},
|
||||||
{"id": 3, "name": "Engagement"}
|
{"id": 2, "name": "Support"},
|
||||||
]
|
{"id": 3, "name": "Engagement"}
|
||||||
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -79,11 +81,13 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{"id": 1, "name": "Pre-sales", "order": 1},
|
"data": [
|
||||||
{"id": 2, "name": "SOW Approval", "order": 2},
|
{"id": 1, "name": "Pre-sales", "order": 1},
|
||||||
{"id": 3, "name": "Gathering Estimates", "order": 3}
|
{"id": 2, "name": "SOW Approval", "order": 2},
|
||||||
]
|
{"id": 3, "name": "Gathering Estimates", "order": 3}
|
||||||
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -147,21 +151,21 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status_id": 1,
|
"title": "Client Dashboard Redesign",
|
||||||
"status": {"id": 1, "name": "Pre-sales"},
|
"status": {"id": 1, "name": "Pre-sales"},
|
||||||
"type_id": 2,
|
"type": {"id": 2, "name": "Support"},
|
||||||
"type": {"id": 2, "name": "Support"},
|
"approved_estimate": "120.00",
|
||||||
"approved_estimate": "120.00",
|
"forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
|
||||||
"forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
}
|
||||||
}
|
]
|
||||||
]
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -238,13 +242,13 @@ endpoints:
|
|||||||
status: 201
|
status: 201
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status_id": 1,
|
"title": "Client Dashboard Redesign",
|
||||||
"status": {"id": 1, "name": "Pre-sales"},
|
"status": {"id": 1, "name": "Pre-sales"},
|
||||||
"type_id": 1,
|
"type": {"id": 1, "name": "Project"}
|
||||||
"type": {"id": 1, "name": "Project"}
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -304,13 +308,15 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status": {"id": 1, "name": "Pre-sales"},
|
"title": "Client Dashboard Redesign",
|
||||||
"type": {"id": 1, "name": "Project"},
|
"status": {"id": 1, "name": "Pre-sales"},
|
||||||
"approved_estimate": "120.00",
|
"type": {"id": 1, "name": "Project"},
|
||||||
"forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
"approved_estimate": "120.00",
|
||||||
|
"forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -407,10 +413,12 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-002",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Updated Title",
|
"code": "PROJ-002",
|
||||||
"type_id": 2
|
"title": "Updated Title",
|
||||||
|
"type": {"id": 2, "name": "Support"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -563,8 +571,10 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"status": {"id": 2, "name": "SOW Approval"}
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"status": {"id": 2, "name": "SOW Approval"}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -652,7 +662,13 @@ endpoints:
|
|||||||
-
|
-
|
||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}'
|
content: |-
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"approved_estimate": "120.00"
|
||||||
|
}
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
-
|
-
|
||||||
@@ -743,7 +759,13 @@ endpoints:
|
|||||||
-
|
-
|
||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: '{"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}'
|
content: |-
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
||||||
|
}
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -80,15 +80,20 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"person_days": 18.5,
|
"data": {
|
||||||
"hours": 148,
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"details": [
|
"month": "2026-02",
|
||||||
{
|
"working_days": 20,
|
||||||
"date": "2026-02-02",
|
"person_days": 18.5,
|
||||||
"availability": 1,
|
"hours": 148,
|
||||||
"is_pto": false
|
"details": [
|
||||||
}
|
{
|
||||||
]
|
"date": "2026-02-02",
|
||||||
|
"availability": 1,
|
||||||
|
"is_pto": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -152,17 +157,19 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"month": "2026-02",
|
"data": {
|
||||||
"person_days": 180.5,
|
"month": "2026-02",
|
||||||
"hours": 1444,
|
"total_person_days": 180.5,
|
||||||
"members": [
|
"total_hours": 1444,
|
||||||
{
|
"members": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"name": "Ada Lovelace",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"person_days": 18.5,
|
"name": "Ada Lovelace",
|
||||||
"hours": 148
|
"person_days": 18.5,
|
||||||
}
|
"hours": 148
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -226,8 +233,19 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"month": "2026-02",
|
"data": {
|
||||||
"possible_revenue": 21500.25
|
"month": "2026-02",
|
||||||
|
"possible_revenue": 21500.25,
|
||||||
|
"member_revenues": [
|
||||||
|
{
|
||||||
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"team_member_name": "Ada Lovelace",
|
||||||
|
"hours": 148,
|
||||||
|
"hourly_rate": 150.0,
|
||||||
|
"revenue": 22200.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -290,14 +308,16 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"date": "2026-02-14",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Company Holiday",
|
"date": "2026-02-14",
|
||||||
"description": "Office closed"
|
"name": "Company Holiday",
|
||||||
}
|
"description": "Office closed"
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -372,10 +392,12 @@ endpoints:
|
|||||||
status: 201
|
status: 201
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"date": "2026-02-14",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Presidents' Day",
|
"date": "2026-02-14",
|
||||||
"description": "Office closed"
|
"name": "Presidents' Day",
|
||||||
|
"description": "Office closed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -514,16 +536,18 @@ endpoints:
|
|||||||
custom: []
|
custom: []
|
||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
[
|
{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
{
|
||||||
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
"start_date": "2026-02-10",
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"end_date": "2026-02-12",
|
"start_date": "2026-02-10",
|
||||||
"status": "pending",
|
"end_date": "2026-02-12",
|
||||||
"reason": "Family travel"
|
"status": "pending",
|
||||||
}
|
"reason": "Family travel"
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
responseFields: []
|
responseFields: []
|
||||||
@@ -610,12 +634,14 @@ endpoints:
|
|||||||
status: 201
|
status: 201
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
"data": {
|
||||||
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
"start_date": "2026-02-10",
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"end_date": "2026-02-12",
|
"start_date": "2026-02-10",
|
||||||
"status": "pending",
|
"end_date": "2026-02-12",
|
||||||
"reason": "Family travel"
|
"status": "pending",
|
||||||
|
"reason": "Family travel"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
@@ -667,8 +693,10 @@ endpoints:
|
|||||||
status: 200
|
status: 200
|
||||||
content: |-
|
content: |-
|
||||||
{
|
{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
"data": {
|
||||||
"status": "approved"
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"status": "approved"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
headers: []
|
headers: []
|
||||||
description: ''
|
description: ''
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ COPY . .
|
|||||||
RUN composer install --no-interaction --optimize-autoloader
|
RUN composer install --no-interaction --optimize-autoloader
|
||||||
|
|
||||||
# Install Laravel Boost
|
# Install Laravel Boost
|
||||||
RUN php artisan boost:install
|
#RUN php artisan boost:install
|
||||||
RUN php artisan vendor:publish --provider="Laravel\Boost\BoostServiceProvider"
|
#RUN php artisan vendor:publish --provider="Laravel\Boost\BoostServiceProvider"
|
||||||
RUN php artisan config:clear
|
RUN php artisan config:clear
|
||||||
RUN composer dump-autoload
|
RUN composer dump-autoload
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\UserResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\JwtService;
|
use App\Services\JwtService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -39,16 +40,19 @@ class AuthController extends Controller
|
|||||||
* @bodyParam password string required User password. Example: secret123
|
* @bodyParam password string required User password. Example: secret123
|
||||||
*
|
*
|
||||||
* @response 200 {
|
* @response 200 {
|
||||||
* "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
* "data": {
|
||||||
* "refresh_token": "abc123def456",
|
|
||||||
* "token_type": "bearer",
|
|
||||||
* "expires_in": 3600,
|
|
||||||
* "user": {
|
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "name": "Alice Johnson",
|
* "name": "Alice Johnson",
|
||||||
* "email": "user@example.com",
|
* "email": "user@example.com",
|
||||||
* "role": "manager"
|
* "role": "manager",
|
||||||
* }
|
* "active": true,
|
||||||
|
* "created_at": "2026-01-01T00:00:00Z",
|
||||||
|
* "updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
* },
|
||||||
|
* "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
|
* "refresh_token": "abc123def456",
|
||||||
|
* "token_type": "bearer",
|
||||||
|
* "expires_in": 3600
|
||||||
* }
|
* }
|
||||||
* @response 401 {"message":"Invalid credentials"}
|
* @response 401 {"message":"Invalid credentials"}
|
||||||
* @response 403 {"message":"Account is inactive"}
|
* @response 403 {"message":"Account is inactive"}
|
||||||
@@ -85,18 +89,12 @@ class AuthController extends Controller
|
|||||||
$accessToken = $this->jwtService->generateAccessToken($user);
|
$accessToken = $this->jwtService->generateAccessToken($user);
|
||||||
$refreshToken = $this->jwtService->generateRefreshToken($user);
|
$refreshToken = $this->jwtService->generateRefreshToken($user);
|
||||||
|
|
||||||
return response()->json([
|
return (new UserResource($user))->additional([
|
||||||
'access_token' => $accessToken,
|
'access_token' => $accessToken,
|
||||||
'refresh_token' => $refreshToken,
|
'refresh_token' => $refreshToken,
|
||||||
'token_type' => 'bearer',
|
'token_type' => 'bearer',
|
||||||
'expires_in' => $this->jwtService->getAccessTokenTTL(),
|
'expires_in' => $this->jwtService->getAccessTokenTTL(),
|
||||||
'user' => [
|
])->response();
|
||||||
'id' => $user->id,
|
|
||||||
'name' => $user->name,
|
|
||||||
'email' => $user->email,
|
|
||||||
'role' => $user->role,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,9 +103,19 @@ class AuthController extends Controller
|
|||||||
* Exchange a valid refresh token for a new access token and refresh token pair.
|
* Exchange a valid refresh token for a new access token and refresh token pair.
|
||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
|
*
|
||||||
* @bodyParam refresh_token string required Refresh token returned by login. Example: abc123def456
|
* @bodyParam refresh_token string required Refresh token returned by login. Example: abc123def456
|
||||||
*
|
*
|
||||||
* @response 200 {
|
* @response 200 {
|
||||||
|
* "data": {
|
||||||
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
* "name": "Alice Johnson",
|
||||||
|
* "email": "user@example.com",
|
||||||
|
* "role": "manager",
|
||||||
|
* "active": true,
|
||||||
|
* "created_at": "2026-01-01T00:00:00Z",
|
||||||
|
* "updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
* },
|
||||||
* "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
* "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
* "refresh_token": "newtoken123",
|
* "refresh_token": "newtoken123",
|
||||||
* "token_type": "bearer",
|
* "token_type": "bearer",
|
||||||
@@ -146,12 +154,12 @@ class AuthController extends Controller
|
|||||||
$accessToken = $this->jwtService->generateAccessToken($user);
|
$accessToken = $this->jwtService->generateAccessToken($user);
|
||||||
$newRefreshToken = $this->jwtService->generateRefreshToken($user);
|
$newRefreshToken = $this->jwtService->generateRefreshToken($user);
|
||||||
|
|
||||||
return response()->json([
|
return (new UserResource($user))->additional([
|
||||||
'access_token' => $accessToken,
|
'access_token' => $accessToken,
|
||||||
'refresh_token' => $newRefreshToken,
|
'refresh_token' => $newRefreshToken,
|
||||||
'token_type' => 'bearer',
|
'token_type' => 'bearer',
|
||||||
'expires_in' => $this->jwtService->getAccessTokenTTL(),
|
'expires_in' => $this->jwtService->getAccessTokenTTL(),
|
||||||
]);
|
])->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,6 +168,7 @@ class AuthController extends Controller
|
|||||||
* Invalidate a refresh token and end the active authenticated session.
|
* Invalidate a refresh token and end the active authenticated session.
|
||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
|
*
|
||||||
* @bodyParam refresh_token string Optional refresh token to invalidate immediately. Example: abc123def456
|
* @bodyParam refresh_token string Optional refresh token to invalidate immediately. Example: abc123def456
|
||||||
*
|
*
|
||||||
* @response 200 {"message":"Logged out successfully"}
|
* @response 200 {"message":"Logged out successfully"}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\CapacityResource;
|
||||||
|
use App\Http\Resources\RevenueResource;
|
||||||
|
use App\Http\Resources\TeamCapacityResource;
|
||||||
|
use App\Models\TeamMember;
|
||||||
use App\Services\CapacityService;
|
use App\Services\CapacityService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -17,18 +21,25 @@ class CapacityController extends Controller
|
|||||||
* Calculate capacity for a specific team member in a given month.
|
* Calculate capacity for a specific team member in a given month.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
* @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
|
* @urlParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*
|
||||||
* @response {
|
* @response {
|
||||||
* "person_days": 18.5,
|
* "data": {
|
||||||
* "hours": 148,
|
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "details": [
|
* "month": "2026-02",
|
||||||
* {
|
* "working_days": 20,
|
||||||
* "date": "2026-02-02",
|
* "person_days": 18.5,
|
||||||
* "availability": 1,
|
* "hours": 148,
|
||||||
* "is_pto": false
|
* "details": [
|
||||||
* }
|
* {
|
||||||
* ]
|
* "date": "2026-02-02",
|
||||||
|
* "availability": 1,
|
||||||
|
* "is_pto": false
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function individual(Request $request): JsonResponse
|
public function individual(Request $request): JsonResponse
|
||||||
@@ -39,8 +50,18 @@ class CapacityController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$capacity = $this->capacityService->calculateIndividualCapacity($data['team_member_id'], $data['month']);
|
$capacity = $this->capacityService->calculateIndividualCapacity($data['team_member_id'], $data['month']);
|
||||||
|
$workingDays = $this->capacityService->calculateWorkingDays($data['month']);
|
||||||
|
|
||||||
return response()->json($capacity);
|
$payload = [
|
||||||
|
'team_member_id' => $data['team_member_id'],
|
||||||
|
'month' => $data['month'],
|
||||||
|
'working_days' => $workingDays,
|
||||||
|
'person_days' => $capacity['person_days'],
|
||||||
|
'hours' => $capacity['hours'],
|
||||||
|
'details' => $capacity['details'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->wrapResource(new CapacityResource($payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,19 +70,23 @@ class CapacityController extends Controller
|
|||||||
* Summarize the combined capacity for all active team members in a month.
|
* Summarize the combined capacity for all active team members in a month.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
||||||
|
*
|
||||||
* @response {
|
* @response {
|
||||||
* "month": "2026-02",
|
* "data": {
|
||||||
* "person_days": 180.5,
|
* "month": "2026-02",
|
||||||
* "hours": 1444,
|
* "total_person_days": 180.5,
|
||||||
* "members": [
|
* "total_hours": 1444,
|
||||||
* {
|
* "members": [
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* {
|
||||||
* "name": "Ada Lovelace",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "person_days": 18.5,
|
* "name": "Ada Lovelace",
|
||||||
* "hours": 148
|
* "person_days": 18.5,
|
||||||
* }
|
* "hours": 148
|
||||||
* ]
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function team(Request $request): JsonResponse
|
public function team(Request $request): JsonResponse
|
||||||
@@ -72,7 +97,7 @@ class CapacityController extends Controller
|
|||||||
|
|
||||||
$payload = $this->capacityService->calculateTeamCapacity($data['month']);
|
$payload = $this->capacityService->calculateTeamCapacity($data['month']);
|
||||||
|
|
||||||
return response()->json($payload);
|
return $this->wrapResource(new TeamCapacityResource($payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,10 +106,23 @@ class CapacityController extends Controller
|
|||||||
* Estimate monthly revenue based on capacity hours and hourly rates.
|
* Estimate monthly revenue based on capacity hours and hourly rates.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
* @urlParam month string required The month in YYYY-MM format. Example: 2026-02
|
||||||
|
*
|
||||||
* @response {
|
* @response {
|
||||||
* "month": "2026-02",
|
* "data": {
|
||||||
* "possible_revenue": 21500.25
|
* "month": "2026-02",
|
||||||
|
* "possible_revenue": 21500.25,
|
||||||
|
* "member_revenues": [
|
||||||
|
* {
|
||||||
|
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
* "team_member_name": "Ada Lovelace",
|
||||||
|
* "hours": 148,
|
||||||
|
* "hourly_rate": 150.0,
|
||||||
|
* "revenue": 22200.0
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function revenue(Request $request): JsonResponse
|
public function revenue(Request $request): JsonResponse
|
||||||
@@ -94,10 +132,29 @@ class CapacityController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$revenue = $this->capacityService->calculatePossibleRevenue($data['month']);
|
$revenue = $this->capacityService->calculatePossibleRevenue($data['month']);
|
||||||
|
$memberRevenues = [];
|
||||||
|
|
||||||
return response()->json([
|
TeamMember::where('active', true)
|
||||||
|
->get()
|
||||||
|
->each(function (TeamMember $member) use ($data, &$memberRevenues): void {
|
||||||
|
$capacity = $this->capacityService->calculateIndividualCapacity($member->id, $data['month']);
|
||||||
|
$hours = $capacity['hours'];
|
||||||
|
$hourlyRate = $member->hourly_rate !== null ? (float) $member->hourly_rate : null;
|
||||||
|
$memberRevenue = $hourlyRate !== null ? round($hours * $hourlyRate, 2) : 0.0;
|
||||||
|
|
||||||
|
$memberRevenues[] = [
|
||||||
|
'team_member_id' => $member->id,
|
||||||
|
'team_member_name' => $member->name,
|
||||||
|
'hours' => $hours,
|
||||||
|
'hourly_rate' => $hourlyRate,
|
||||||
|
'revenue' => $memberRevenue,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->wrapResource(new RevenueResource([
|
||||||
'month' => $data['month'],
|
'month' => $data['month'],
|
||||||
'possible_revenue' => $revenue,
|
'possible_revenue' => $revenue,
|
||||||
]);
|
'member_revenues' => $memberRevenues,
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\HolidayResource;
|
||||||
use App\Models\Holiday;
|
use App\Models\Holiday;
|
||||||
use App\Services\CapacityService;
|
use App\Services\CapacityService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -18,15 +19,19 @@ class HolidayController extends Controller
|
|||||||
* Retrieve holidays for a specific month or all holidays when no month is provided.
|
* Retrieve holidays for a specific month or all holidays when no month is provided.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @urlParam month string nullable The month in YYYY-MM format. Example: 2026-02
|
* @urlParam month string nullable The month in YYYY-MM format. Example: 2026-02
|
||||||
* @response [
|
*
|
||||||
* {
|
* @response {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": [
|
||||||
* "date": "2026-02-14",
|
* {
|
||||||
* "name": "Company Holiday",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "description": "Office closed"
|
* "date": "2026-02-14",
|
||||||
* }
|
* "name": "Company Holiday",
|
||||||
* ]
|
* "description": "Office closed"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -38,7 +43,7 @@ class HolidayController extends Controller
|
|||||||
? $this->capacityService->getHolidaysForMonth($data['month'])
|
? $this->capacityService->getHolidaysForMonth($data['month'])
|
||||||
: Holiday::orderBy('date')->get();
|
: Holiday::orderBy('date')->get();
|
||||||
|
|
||||||
return response()->json($holidays);
|
return $this->wrapResource(HolidayResource::collection($holidays));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,14 +52,18 @@ class HolidayController extends Controller
|
|||||||
* Add a holiday and clear cached capacity data for the related month.
|
* Add a holiday and clear cached capacity data for the related month.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @bodyParam date string required Date of the holiday. Example: 2026-02-14
|
* @bodyParam date string required Date of the holiday. Example: 2026-02-14
|
||||||
* @bodyParam name string required Name of the holiday. Example: Presidents' Day
|
* @bodyParam name string required Name of the holiday. Example: Presidents' Day
|
||||||
* @bodyParam description string nullable Optional description of the holiday.
|
* @bodyParam description string nullable Optional description of the holiday.
|
||||||
|
*
|
||||||
* @response 201 {
|
* @response 201 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": {
|
||||||
* "date": "2026-02-14",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "name": "Presidents' Day",
|
* "date": "2026-02-14",
|
||||||
* "description": "Office closed"
|
* "name": "Presidents' Day",
|
||||||
|
* "description": "Office closed"
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@@ -68,7 +77,7 @@ class HolidayController extends Controller
|
|||||||
$holiday = Holiday::create($data);
|
$holiday = Holiday::create($data);
|
||||||
$this->capacityService->forgetCapacityCacheForMonth($holiday->date->format('Y-m'));
|
$this->capacityService->forgetCapacityCacheForMonth($holiday->date->format('Y-m'));
|
||||||
|
|
||||||
return response()->json($holiday, 201);
|
return $this->wrapResource(new HolidayResource($holiday), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,7 +86,9 @@ class HolidayController extends Controller
|
|||||||
* Remove a holiday and clear affected capacity caches.
|
* Remove a holiday and clear affected capacity caches.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @urlParam id string required The holiday UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
* @urlParam id string required The holiday UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*
|
||||||
* @response {
|
* @response {
|
||||||
* "message": "Holiday deleted"
|
* "message": "Holiday deleted"
|
||||||
* }
|
* }
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Http\Resources\ProjectStatusResource;
|
||||||
|
use App\Http\Resources\ProjectTypeResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProjectStatus;
|
use App\Models\ProjectStatus;
|
||||||
use App\Models\ProjectType;
|
use App\Models\ProjectType;
|
||||||
@@ -41,21 +44,21 @@ class ProjectController extends Controller
|
|||||||
* @queryParam status_id integer Filter by status ID. Example: 1
|
* @queryParam status_id integer Filter by status ID. Example: 1
|
||||||
* @queryParam type_id integer Filter by type ID. Example: 2
|
* @queryParam type_id integer Filter by type ID. Example: 2
|
||||||
*
|
*
|
||||||
* @response 200 [
|
* @response 200 {
|
||||||
* {
|
* "data": [
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* {
|
||||||
* "code": "PROJ-001",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "title": "Client Dashboard Redesign",
|
* "code": "PROJ-001",
|
||||||
* "status_id": 1,
|
* "title": "Client Dashboard Redesign",
|
||||||
* "status": {"id": 1, "name": "Pre-sales"},
|
* "status": {"id": 1, "name": "Pre-sales"},
|
||||||
* "type_id": 2,
|
* "type": {"id": 2, "name": "Support"},
|
||||||
* "type": {"id": 2, "name": "Support"},
|
* "approved_estimate": "120.00",
|
||||||
* "approved_estimate": "120.00",
|
* "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
|
||||||
* "forecasted_effort": {"2024-02": 40, "2024-03": 60, "2024-04": 20},
|
* "created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
* "created_at": "2024-01-15T10:00:00.000000Z",
|
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
* }
|
||||||
* }
|
* ]
|
||||||
* ]
|
* }
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -64,7 +67,7 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
$projects = $this->projectService->getAll($statusId, $typeId);
|
$projects = $this->projectService->getAll($statusId, $typeId);
|
||||||
|
|
||||||
return response()->json($projects);
|
return $this->wrapResource(ProjectResource::collection($projects));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,13 +82,13 @@ class ProjectController extends Controller
|
|||||||
* @bodyParam type_id integer required Project type ID. Example: 1
|
* @bodyParam type_id integer required Project type ID. Example: 1
|
||||||
*
|
*
|
||||||
* @response 201 {
|
* @response 201 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": {
|
||||||
* "code": "PROJ-001",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "title": "Client Dashboard Redesign",
|
* "code": "PROJ-001",
|
||||||
* "status_id": 1,
|
* "title": "Client Dashboard Redesign",
|
||||||
* "status": {"id": 1, "name": "Pre-sales"},
|
* "status": {"id": 1, "name": "Pre-sales"},
|
||||||
* "type_id": 1,
|
* "type": {"id": 1, "name": "Project"}
|
||||||
* "type": {"id": 1, "name": "Project"}
|
* }
|
||||||
* }
|
* }
|
||||||
* @response 422 {"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}}
|
* @response 422 {"message":"Validation failed","errors":{"code":["Project code must be unique"],"title":["The title field is required."]}}
|
||||||
*/
|
*/
|
||||||
@@ -94,7 +97,7 @@ class ProjectController extends Controller
|
|||||||
try {
|
try {
|
||||||
$project = $this->projectService->create($request->all());
|
$project = $this->projectService->create($request->all());
|
||||||
|
|
||||||
return response()->json($project, 201);
|
return $this->wrapResource(new ProjectResource($project), 201);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed',
|
'message' => 'Validation failed',
|
||||||
@@ -113,13 +116,15 @@ class ProjectController extends Controller
|
|||||||
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
* @urlParam id string required Project UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
*
|
*
|
||||||
* @response 200 {
|
* @response 200 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": {
|
||||||
* "code": "PROJ-001",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "title": "Client Dashboard Redesign",
|
* "code": "PROJ-001",
|
||||||
* "status": {"id": 1, "name": "Pre-sales"},
|
* "title": "Client Dashboard Redesign",
|
||||||
* "type": {"id": 1, "name": "Project"},
|
* "status": {"id": 1, "name": "Pre-sales"},
|
||||||
* "approved_estimate": "120.00",
|
* "type": {"id": 1, "name": "Project"},
|
||||||
* "forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
* "approved_estimate": "120.00",
|
||||||
|
* "forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
* @response 404 {"message":"Project not found"}
|
* @response 404 {"message":"Project not found"}
|
||||||
*/
|
*/
|
||||||
@@ -133,7 +138,7 @@ class ProjectController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($project);
|
return $this->wrapResource(new ProjectResource($project));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,10 +155,12 @@ class ProjectController extends Controller
|
|||||||
* @bodyParam type_id integer Project type ID. Example: 2
|
* @bodyParam type_id integer Project type ID. Example: 2
|
||||||
*
|
*
|
||||||
* @response 200 {
|
* @response 200 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": {
|
||||||
* "code": "PROJ-002",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "title": "Updated Title",
|
* "code": "PROJ-002",
|
||||||
* "type_id": 2
|
* "title": "Updated Title",
|
||||||
|
* "type": {"id": 2, "name": "Support"}
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
* @response 404 {"message":"Project not found"}
|
* @response 404 {"message":"Project not found"}
|
||||||
* @response 422 {"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}}
|
* @response 422 {"message":"Validation failed","errors":{"type_id":["The selected type id is invalid."]}}
|
||||||
@@ -173,7 +180,7 @@ class ProjectController extends Controller
|
|||||||
'code', 'title', 'type_id',
|
'code', 'title', 'type_id',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
return response()->json($project);
|
return $this->wrapResource(new ProjectResource($project));
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed',
|
'message' => 'Validation failed',
|
||||||
@@ -194,8 +201,10 @@ class ProjectController extends Controller
|
|||||||
* @bodyParam status_id integer required Target status ID. Example: 2
|
* @bodyParam status_id integer required Target status ID. Example: 2
|
||||||
*
|
*
|
||||||
* @response 200 {
|
* @response 200 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": {
|
||||||
* "status": {"id": 2, "name": "SOW Approval"}
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
* "status": {"id": 2, "name": "SOW Approval"}
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
* @response 404 {"message":"Project not found"}
|
* @response 404 {"message":"Project not found"}
|
||||||
* @response 422 {"message":"Cannot transition from Pre-sales to Done"}
|
* @response 422 {"message":"Cannot transition from Pre-sales to Done"}
|
||||||
@@ -220,7 +229,7 @@ class ProjectController extends Controller
|
|||||||
(int) $request->input('status_id')
|
(int) $request->input('status_id')
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($project);
|
return $this->wrapResource(new ProjectResource($project));
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
@@ -239,7 +248,12 @@ class ProjectController extends Controller
|
|||||||
*
|
*
|
||||||
* @bodyParam approved_estimate number required Approved estimate hours (must be > 0). Example: 120
|
* @bodyParam approved_estimate number required Approved estimate hours (must be > 0). Example: 120
|
||||||
*
|
*
|
||||||
* @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "approved_estimate":"120.00"}
|
* @response 200 {
|
||||||
|
* "data": {
|
||||||
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
* "approved_estimate": "120.00"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
* @response 404 {"message":"Project not found"}
|
* @response 404 {"message":"Project not found"}
|
||||||
* @response 422 {"message":"Approved estimate must be greater than 0"}
|
* @response 422 {"message":"Approved estimate must be greater than 0"}
|
||||||
*/
|
*/
|
||||||
@@ -263,7 +277,7 @@ class ProjectController extends Controller
|
|||||||
(float) $request->input('approved_estimate')
|
(float) $request->input('approved_estimate')
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json($project);
|
return $this->wrapResource(new ProjectResource($project));
|
||||||
} catch (\RuntimeException $e) {
|
} catch (\RuntimeException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
@@ -282,7 +296,12 @@ class ProjectController extends Controller
|
|||||||
*
|
*
|
||||||
* @bodyParam forecasted_effort object required Monthly effort breakdown. Example: {"2024-02": 40, "2024-03": 60}
|
* @bodyParam forecasted_effort object required Monthly effort breakdown. Example: {"2024-02": 40, "2024-03": 60}
|
||||||
*
|
*
|
||||||
* @response 200 {"id":"550e8400-e29b-41d4-a716-446655440000", "forecasted_effort":{"2024-02":40,"2024-03":60}}
|
* @response 200 {
|
||||||
|
* "data": {
|
||||||
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
* "forecasted_effort": {"2024-02": 40, "2024-03": 60}
|
||||||
|
* }
|
||||||
|
* }
|
||||||
* @response 404 {"message":"Project not found"}
|
* @response 404 {"message":"Project not found"}
|
||||||
* @response 422 {"message":"Forecasted effort exceeds approved estimate by more than 5%"}
|
* @response 422 {"message":"Forecasted effort exceeds approved estimate by more than 5%"}
|
||||||
*/
|
*/
|
||||||
@@ -319,17 +338,19 @@ class ProjectController extends Controller
|
|||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
*
|
*
|
||||||
* @response 200 [
|
* @response 200 {
|
||||||
* {"id": 1, "name": "Project"},
|
* "data": [
|
||||||
* {"id": 2, "name": "Support"},
|
* {"id": 1, "name": "Project"},
|
||||||
* {"id": 3, "name": "Engagement"}
|
* {"id": 2, "name": "Support"},
|
||||||
* ]
|
* {"id": 3, "name": "Engagement"}
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
public function types(): JsonResponse
|
public function types(): JsonResponse
|
||||||
{
|
{
|
||||||
$types = ProjectType::orderBy('name')->get(['id', 'name']);
|
$types = ProjectType::orderBy('name')->get(['id', 'name']);
|
||||||
|
|
||||||
return response()->json($types);
|
return $this->wrapResource(ProjectTypeResource::collection($types));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -337,17 +358,19 @@ class ProjectController extends Controller
|
|||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
*
|
*
|
||||||
* @response 200 [
|
* @response 200 {
|
||||||
* {"id": 1, "name": "Pre-sales", "order": 1},
|
* "data": [
|
||||||
* {"id": 2, "name": "SOW Approval", "order": 2},
|
* {"id": 1, "name": "Pre-sales", "order": 1},
|
||||||
* {"id": 3, "name": "Gathering Estimates", "order": 3}
|
* {"id": 2, "name": "SOW Approval", "order": 2},
|
||||||
* ]
|
* {"id": 3, "name": "Gathering Estimates", "order": 3}
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
public function statuses(): JsonResponse
|
public function statuses(): JsonResponse
|
||||||
{
|
{
|
||||||
$statuses = ProjectStatus::orderBy('order')->get(['id', 'name', 'order']);
|
$statuses = ProjectStatus::orderBy('order')->get(['id', 'name', 'order']);
|
||||||
|
|
||||||
return response()->json($statuses);
|
return $this->wrapResource(ProjectStatusResource::collection($statuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\PtoResource;
|
||||||
use App\Models\Pto;
|
use App\Models\Pto;
|
||||||
use App\Services\CapacityService;
|
use App\Services\CapacityService;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
@@ -19,18 +20,22 @@ class PtoController extends Controller
|
|||||||
* Fetch PTO requests for a team member, optionally constrained to a month.
|
* Fetch PTO requests for a team member, optionally constrained to a month.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @urlParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
* @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
|
* @urlParam month string nullable The month in YYYY-MM format. Example: 2026-02
|
||||||
* @response [
|
*
|
||||||
* {
|
* @response {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440001",
|
* "data": [
|
||||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
* {
|
||||||
* "start_date": "2026-02-10",
|
* "id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
* "end_date": "2026-02-12",
|
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "status": "pending",
|
* "start_date": "2026-02-10",
|
||||||
* "reason": "Family travel"
|
* "end_date": "2026-02-12",
|
||||||
* }
|
* "status": "pending",
|
||||||
* ]
|
* "reason": "Family travel"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -39,7 +44,7 @@ class PtoController extends Controller
|
|||||||
'month' => 'nullable|date_format:Y-m',
|
'month' => 'nullable|date_format:Y-m',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$query = Pto::where('team_member_id', $data['team_member_id']);
|
$query = Pto::with('teamMember')->where('team_member_id', $data['team_member_id']);
|
||||||
|
|
||||||
if (! empty($data['month'])) {
|
if (! empty($data['month'])) {
|
||||||
$start = Carbon::createFromFormat('Y-m', $data['month'])->startOfMonth();
|
$start = Carbon::createFromFormat('Y-m', $data['month'])->startOfMonth();
|
||||||
@@ -57,7 +62,7 @@ class PtoController extends Controller
|
|||||||
|
|
||||||
$ptos = $query->orderBy('start_date')->get();
|
$ptos = $query->orderBy('start_date')->get();
|
||||||
|
|
||||||
return response()->json($ptos);
|
return $this->wrapResource(PtoResource::collection($ptos));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,17 +71,21 @@ class PtoController extends Controller
|
|||||||
* Create a PTO request for a team member and keep it in pending status.
|
* Create a PTO request for a team member and keep it in pending status.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @bodyParam team_member_id string required The team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
* @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 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 end_date string required The final day of the PTO. Example: 2026-02-12
|
||||||
* @bodyParam reason string nullable Optional reason for the PTO.
|
* @bodyParam reason string nullable Optional reason for the PTO.
|
||||||
|
*
|
||||||
* @response 201 {
|
* @response 201 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440001",
|
* "data": {
|
||||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
* "id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
* "start_date": "2026-02-10",
|
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "end_date": "2026-02-12",
|
* "start_date": "2026-02-10",
|
||||||
* "status": "pending",
|
* "end_date": "2026-02-12",
|
||||||
* "reason": "Family travel"
|
* "status": "pending",
|
||||||
|
* "reason": "Family travel"
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request): JsonResponse
|
||||||
@@ -89,8 +98,9 @@ class PtoController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$pto = Pto::create(array_merge($data, ['status' => 'pending']));
|
$pto = Pto::create(array_merge($data, ['status' => 'pending']));
|
||||||
|
$pto->load('teamMember');
|
||||||
|
|
||||||
return response()->json($pto, 201);
|
return $this->wrapResource(new PtoResource($pto), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,15 +109,19 @@ class PtoController extends Controller
|
|||||||
* Approve a pending PTO request and refresh the affected capacity caches.
|
* Approve a pending PTO request and refresh the affected capacity caches.
|
||||||
*
|
*
|
||||||
* @group Capacity Planning
|
* @group Capacity Planning
|
||||||
|
*
|
||||||
* @urlParam id string required The PTO UUID that needs approval. Example: 550e8400-e29b-41d4-a716-446655440001
|
* @urlParam id string required The PTO UUID that needs approval. Example: 550e8400-e29b-41d4-a716-446655440001
|
||||||
|
*
|
||||||
* @response {
|
* @response {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440001",
|
* "data": {
|
||||||
* "status": "approved"
|
* "id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
* "status": "approved"
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function approve(string $id): JsonResponse
|
public function approve(string $id): JsonResponse
|
||||||
{
|
{
|
||||||
$pto = Pto::findOrFail($id);
|
$pto = Pto::with('teamMember')->findOrFail($id);
|
||||||
|
|
||||||
if ($pto->status !== 'approved') {
|
if ($pto->status !== 'approved') {
|
||||||
$pto->status = 'approved';
|
$pto->status = 'approved';
|
||||||
@@ -116,7 +130,9 @@ class PtoController extends Controller
|
|||||||
$this->capacityService->forgetCapacityCacheForTeamMember($pto->team_member_id, $months);
|
$this->capacityService->forgetCapacityCacheForTeamMember($pto->team_member_id, $months);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($pto);
|
$pto->load('teamMember');
|
||||||
|
|
||||||
|
return $this->wrapResource(new PtoResource($pto));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function monthsBetween(Carbon|string $start, Carbon|string $end): array
|
private function monthsBetween(Carbon|string $start, Carbon|string $end): array
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Resources\TeamMemberResource;
|
||||||
use App\Models\TeamMember;
|
use App\Models\TeamMember;
|
||||||
use App\Services\TeamMemberService;
|
use App\Services\TeamMemberService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -35,23 +36,25 @@ class TeamMemberController extends Controller
|
|||||||
* Get a list of all team members with optional filtering by active status.
|
* Get a list of all team members with optional filtering by active status.
|
||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
|
*
|
||||||
* @queryParam active boolean Filter by active status. Example: true
|
* @queryParam active boolean Filter by active status. Example: true
|
||||||
*
|
*
|
||||||
* @response 200 [
|
* @response 200 {
|
||||||
* {
|
* "data": [
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* {
|
||||||
* "name": "John Doe",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "role_id": 1,
|
* "name": "John Doe",
|
||||||
* "role": {
|
* "role": {
|
||||||
* "id": 1,
|
* "id": 1,
|
||||||
* "name": "Backend Developer"
|
* "name": "Backend Developer"
|
||||||
* },
|
* },
|
||||||
* "hourly_rate": "150.00",
|
* "hourly_rate": "150.00",
|
||||||
* "active": true,
|
* "active": true,
|
||||||
* "created_at": "2024-01-15T10:00:00.000000Z",
|
* "created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
* }
|
* }
|
||||||
* ]
|
* ]
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -61,7 +64,7 @@ class TeamMemberController extends Controller
|
|||||||
|
|
||||||
$teamMembers = $this->teamMemberService->getAll($active);
|
$teamMembers = $this->teamMemberService->getAll($active);
|
||||||
|
|
||||||
return response()->json($teamMembers);
|
return $this->wrapResource(TeamMemberResource::collection($teamMembers));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,23 +73,25 @@ class TeamMemberController extends Controller
|
|||||||
* Create a new team member with name, role, and hourly rate.
|
* Create a new team member with name, role, and hourly rate.
|
||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
|
*
|
||||||
* @bodyParam name string required Team member name. Example: John Doe
|
* @bodyParam name string required Team member name. Example: John Doe
|
||||||
* @bodyParam role_id integer required Role ID. Example: 1
|
* @bodyParam role_id integer required Role ID. Example: 1
|
||||||
* @bodyParam hourly_rate numeric required Hourly rate (must be > 0). Example: 150.00
|
* @bodyParam hourly_rate numeric required Hourly rate (must be > 0). Example: 150.00
|
||||||
* @bodyParam active boolean Active status (defaults to true). Example: true
|
* @bodyParam active boolean Active status (defaults to true). Example: true
|
||||||
*
|
*
|
||||||
* @response 201 {
|
* @response 201 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": {
|
||||||
* "name": "John Doe",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "role_id": 1,
|
* "name": "John Doe",
|
||||||
* "role": {
|
* "role": {
|
||||||
* "id": 1,
|
* "id": 1,
|
||||||
* "name": "Backend Developer"
|
* "name": "Backend Developer"
|
||||||
* },
|
* },
|
||||||
* "hourly_rate": "150.00",
|
* "hourly_rate": "150.00",
|
||||||
* "active": true,
|
* "active": true,
|
||||||
* "created_at": "2024-01-15T10:00:00.000000Z",
|
* "created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
* @response 422 {"message":"Validation failed","errors":{"name":["The name field is required."],"hourly_rate":["Hourly rate must be greater than 0"]}}
|
* @response 422 {"message":"Validation failed","errors":{"name":["The name field is required."],"hourly_rate":["Hourly rate must be greater than 0"]}}
|
||||||
*/
|
*/
|
||||||
@@ -94,7 +99,8 @@ class TeamMemberController extends Controller
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$teamMember = $this->teamMemberService->create($request->all());
|
$teamMember = $this->teamMemberService->create($request->all());
|
||||||
return response()->json($teamMember, 201);
|
|
||||||
|
return $this->wrapResource(new TeamMemberResource($teamMember), 201);
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed',
|
'message' => 'Validation failed',
|
||||||
@@ -109,20 +115,22 @@ class TeamMemberController extends Controller
|
|||||||
* Get details of a specific team member by ID.
|
* Get details of a specific team member by ID.
|
||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
|
*
|
||||||
* @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
* @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
*
|
*
|
||||||
* @response 200 {
|
* @response 200 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": {
|
||||||
* "name": "John Doe",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "role_id": 1,
|
* "name": "John Doe",
|
||||||
* "role": {
|
* "role": {
|
||||||
* "id": 1,
|
* "id": 1,
|
||||||
* "name": "Backend Developer"
|
* "name": "Backend Developer"
|
||||||
* },
|
* },
|
||||||
* "hourly_rate": "150.00",
|
* "hourly_rate": "150.00",
|
||||||
* "active": true,
|
* "active": true,
|
||||||
* "created_at": "2024-01-15T10:00:00.000000Z",
|
* "created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
* "updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
* @response 404 {"message":"Team member not found"}
|
* @response 404 {"message":"Team member not found"}
|
||||||
*/
|
*/
|
||||||
@@ -136,7 +144,7 @@ class TeamMemberController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($teamMember);
|
return $this->wrapResource(new TeamMemberResource($teamMember));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,24 +153,27 @@ class TeamMemberController extends Controller
|
|||||||
* Update details of an existing team member.
|
* Update details of an existing team member.
|
||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
|
*
|
||||||
* @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
* @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
|
*
|
||||||
* @bodyParam name string Team member name. Example: John Doe
|
* @bodyParam name string Team member name. Example: John Doe
|
||||||
* @bodyParam role_id integer Role ID. Example: 1
|
* @bodyParam role_id integer Role ID. Example: 1
|
||||||
* @bodyParam hourly_rate numeric Hourly rate (must be > 0). Example: 175.00
|
* @bodyParam hourly_rate numeric Hourly rate (must be > 0). Example: 175.00
|
||||||
* @bodyParam active boolean Active status. Example: false
|
* @bodyParam active boolean Active status. Example: false
|
||||||
*
|
*
|
||||||
* @response 200 {
|
* @response 200 {
|
||||||
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
* "data": {
|
||||||
* "name": "John Doe",
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
* "role_id": 1,
|
* "name": "John Doe",
|
||||||
* "role": {
|
* "role": {
|
||||||
* "id": 1,
|
* "id": 1,
|
||||||
* "name": "Backend Developer"
|
* "name": "Backend Developer"
|
||||||
* },
|
* },
|
||||||
* "hourly_rate": "175.00",
|
* "hourly_rate": "175.00",
|
||||||
* "active": false,
|
* "active": false,
|
||||||
* "created_at": "2024-01-15T10:00:00.000000Z",
|
* "created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
* "updated_at": "2024-01-15T11:00:00.000000Z"
|
* "updated_at": "2024-01-15T11:00:00.000000Z"
|
||||||
|
* }
|
||||||
* }
|
* }
|
||||||
* @response 404 {"message":"Team member not found"}
|
* @response 404 {"message":"Team member not found"}
|
||||||
* @response 422 {"message":"Validation failed","errors":{"hourly_rate":["Hourly rate must be greater than 0"]}}
|
* @response 422 {"message":"Validation failed","errors":{"hourly_rate":["Hourly rate must be greater than 0"]}}
|
||||||
@@ -179,10 +190,10 @@ class TeamMemberController extends Controller
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$teamMember = $this->teamMemberService->update($teamMember, $request->only([
|
$teamMember = $this->teamMemberService->update($teamMember, $request->only([
|
||||||
'name', 'role_id', 'hourly_rate', 'active'
|
'name', 'role_id', 'hourly_rate', 'active',
|
||||||
]));
|
]));
|
||||||
|
|
||||||
return response()->json($teamMember);
|
return $this->wrapResource(new TeamMemberResource($teamMember));
|
||||||
} catch (ValidationException $e) {
|
} catch (ValidationException $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Validation failed',
|
'message' => 'Validation failed',
|
||||||
@@ -197,6 +208,7 @@ class TeamMemberController extends Controller
|
|||||||
* Delete a team member. Cannot delete if member has allocations or actuals.
|
* Delete a team member. Cannot delete if member has allocations or actuals.
|
||||||
*
|
*
|
||||||
* @authenticated
|
* @authenticated
|
||||||
|
*
|
||||||
* @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
* @urlParam id string required Team member UUID. Example: 550e8400-e29b-41d4-a716-446655440000
|
||||||
*
|
*
|
||||||
* @response 200 {"message":"Team member deleted successfully"}
|
* @response 200 {"message":"Team member deleted successfully"}
|
||||||
|
|||||||
@@ -2,7 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
abstract class Controller
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
|
||||||
|
class Controller extends BaseController
|
||||||
{
|
{
|
||||||
//
|
protected function wrapResource(JsonResource $resource, int $status = 200): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'data' => $resource->resolve(request()),
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
backend/app/Http/Resources/BaseResource.php
Normal file
18
backend/app/Http/Resources/BaseResource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
abstract class BaseResource extends JsonResource
|
||||||
|
{
|
||||||
|
protected function formatDate($date): ?string
|
||||||
|
{
|
||||||
|
return $date?->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function formatDecimal($value, int $decimals = 2): ?float
|
||||||
|
{
|
||||||
|
return $value !== null ? round((float) $value, $decimals) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/app/Http/Resources/CapacityResource.php
Normal file
18
backend/app/Http/Resources/CapacityResource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class CapacityResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'team_member_id' => $this->resource['team_member_id'] ?? null,
|
||||||
|
'month' => $this->resource['month'] ?? null,
|
||||||
|
'working_days' => $this->resource['working_days'] ?? null,
|
||||||
|
'person_days' => $this->resource['person_days'] ?? null,
|
||||||
|
'hours' => $this->resource['hours'] ?? null,
|
||||||
|
'details' => $this->resource['details'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/app/Http/Resources/HolidayResource.php
Normal file
16
backend/app/Http/Resources/HolidayResource.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class HolidayResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'date' => $this->date?->toDateString(),
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/app/Http/Resources/ProjectResource.php
Normal file
23
backend/app/Http/Resources/ProjectResource.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class ProjectResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'code' => $this->code,
|
||||||
|
'title' => $this->title,
|
||||||
|
'status' => $this->whenLoaded('status', fn () => new ProjectStatusResource($this->status)),
|
||||||
|
'type' => $this->whenLoaded('type', fn () => new ProjectTypeResource($this->type)),
|
||||||
|
'approved_estimate' => $this->formatDecimal($this->approved_estimate),
|
||||||
|
'forecasted_effort' => $this->forecasted_effort,
|
||||||
|
'start_date' => $this->formatDate($this->start_date),
|
||||||
|
'end_date' => $this->formatDate($this->end_date),
|
||||||
|
'created_at' => $this->formatDate($this->created_at),
|
||||||
|
'updated_at' => $this->formatDate($this->updated_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/app/Http/Resources/ProjectStatusResource.php
Normal file
17
backend/app/Http/Resources/ProjectStatusResource.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class ProjectStatusResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'order' => $this->order,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
'is_billable' => $this->is_billable,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/app/Http/Resources/ProjectTypeResource.php
Normal file
15
backend/app/Http/Resources/ProjectTypeResource.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class ProjectTypeResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/app/Http/Resources/PtoResource.php
Normal file
20
backend/app/Http/Resources/PtoResource.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class PtoResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'team_member_id' => $this->team_member_id,
|
||||||
|
'team_member' => $this->whenLoaded('teamMember', fn () => new TeamMemberResource($this->teamMember)),
|
||||||
|
'start_date' => $this->start_date?->toDateString(),
|
||||||
|
'end_date' => $this->end_date?->toDateString(),
|
||||||
|
'reason' => $this->reason,
|
||||||
|
'status' => $this->status,
|
||||||
|
'created_at' => $this->formatDate($this->created_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
15
backend/app/Http/Resources/RevenueResource.php
Normal file
15
backend/app/Http/Resources/RevenueResource.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class RevenueResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'month' => $this->resource['month'] ?? null,
|
||||||
|
'possible_revenue' => $this->resource['possible_revenue'] ?? null,
|
||||||
|
'member_revenues' => $this->resource['member_revenues'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/app/Http/Resources/RoleResource.php
Normal file
18
backend/app/Http/Resources/RoleResource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class RoleResource extends BaseResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*/
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
backend/app/Http/Resources/TeamCapacityResource.php
Normal file
16
backend/app/Http/Resources/TeamCapacityResource.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class TeamCapacityResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'month' => $this->resource['month'] ?? null,
|
||||||
|
'total_person_days' => $this->resource['person_days'] ?? null,
|
||||||
|
'total_hours' => $this->resource['hours'] ?? null,
|
||||||
|
'members' => $this->resource['members'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/app/Http/Resources/TeamMemberResource.php
Normal file
22
backend/app/Http/Resources/TeamMemberResource.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class TeamMemberResource extends BaseResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*/
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'role' => $this->whenLoaded('role', fn () => new RoleResource($this->role)),
|
||||||
|
'hourly_rate' => $this->formatDecimal($this->hourly_rate),
|
||||||
|
'active' => $this->active,
|
||||||
|
'created_at' => $this->formatDate($this->created_at),
|
||||||
|
'updated_at' => $this->formatDate($this->updated_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
backend/app/Http/Resources/UserResource.php
Normal file
22
backend/app/Http/Resources/UserResource.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class UserResource extends BaseResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*/
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'email' => $this->email,
|
||||||
|
'role' => $this->role,
|
||||||
|
'active' => $this->active,
|
||||||
|
'created_at' => $this->formatDate($this->created_at),
|
||||||
|
'updated_at' => $this->formatDate($this->updated_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,16 +260,19 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
"data": {
|
||||||
"refresh_token": "abc123def456",
|
|
||||||
"token_type": "bearer",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"user": {
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Alice Johnson",
|
"name": "Alice Johnson",
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
"role": "manager"
|
"role": "manager",
|
||||||
}
|
"active": true,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
|
"refresh_token": "abc123def456",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 3600
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
@@ -457,6 +460,15 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "Alice Johnson",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"role": "manager",
|
||||||
|
"active": true,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"updated_at": "2026-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...",
|
||||||
"refresh_token": "newtoken123",
|
"refresh_token": "newtoken123",
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
@@ -758,15 +770,20 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"person_days": 18.5,
|
"data": {
|
||||||
"hours": 148,
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"details": [
|
"month": "2026-02",
|
||||||
{
|
"working_days": 20,
|
||||||
"date": "2026-02-02",
|
"person_days": 18.5,
|
||||||
"availability": 1,
|
"hours": 148,
|
||||||
"is_pto": false
|
"details": [
|
||||||
}
|
{
|
||||||
]
|
"date": "2026-02-02",
|
||||||
|
"availability": 1,
|
||||||
|
"is_pto": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
@@ -944,17 +961,19 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"month": "2026-02",
|
"data": {
|
||||||
"person_days": 180.5,
|
"month": "2026-02",
|
||||||
"hours": 1444,
|
"total_person_days": 180.5,
|
||||||
"members": [
|
"total_hours": 1444,
|
||||||
{
|
"members": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"name": "Ada Lovelace",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"person_days": 18.5,
|
"name": "Ada Lovelace",
|
||||||
"hours": 148
|
"person_days": 18.5,
|
||||||
}
|
"hours": 148
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
@@ -1108,8 +1127,19 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"month": "2026-02",
|
"data": {
|
||||||
"possible_revenue": 21500.25
|
"month": "2026-02",
|
||||||
|
"possible_revenue": 21500.25,
|
||||||
|
"member_revenues": [
|
||||||
|
{
|
||||||
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"team_member_name": "Ada Lovelace",
|
||||||
|
"hours": 148,
|
||||||
|
"hourly_rate": 150,
|
||||||
|
"revenue": 22200
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
@@ -1262,14 +1292,16 @@ fetch(url, {
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">[
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"date": "2026-02-14",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Company Holiday",
|
"date": "2026-02-14",
|
||||||
"description": "Office closed"
|
"name": "Company Holiday",
|
||||||
}
|
"description": "Office closed"
|
||||||
]</code>
|
}
|
||||||
|
]
|
||||||
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
<span id="execution-results-GETapi-holidays" hidden>
|
<span id="execution-results-GETapi-holidays" hidden>
|
||||||
@@ -1426,10 +1458,12 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"date": "2026-02-14",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"name": "Presidents' Day",
|
"date": "2026-02-14",
|
||||||
"description": "Office closed"
|
"name": "Presidents' Day",
|
||||||
|
"description": "Office closed"
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
@@ -1727,16 +1761,18 @@ fetch(url, {
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">[
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
{
|
||||||
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
"start_date": "2026-02-10",
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"end_date": "2026-02-12",
|
"start_date": "2026-02-10",
|
||||||
"status": "pending",
|
"end_date": "2026-02-12",
|
||||||
"reason": "Family travel"
|
"status": "pending",
|
||||||
}
|
"reason": "Family travel"
|
||||||
]</code>
|
}
|
||||||
|
]
|
||||||
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
<span id="execution-results-GETapi-ptos" hidden>
|
<span id="execution-results-GETapi-ptos" hidden>
|
||||||
@@ -1919,12 +1955,14 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
"data": {
|
||||||
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
"start_date": "2026-02-10",
|
"team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"end_date": "2026-02-12",
|
"start_date": "2026-02-10",
|
||||||
"status": "pending",
|
"end_date": "2026-02-12",
|
||||||
"reason": "Family travel"
|
"status": "pending",
|
||||||
|
"reason": "Family travel"
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
@@ -2092,8 +2130,10 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440001",
|
"data": {
|
||||||
"status": "approved"
|
"id": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"status": "approved"
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
@@ -2229,20 +2269,22 @@ fetch(url, {
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">[
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
{
|
"data": [
|
||||||
"id": 1,
|
{
|
||||||
"name": "Project"
|
"id": 1,
|
||||||
},
|
"name": "Project"
|
||||||
{
|
},
|
||||||
"id": 2,
|
{
|
||||||
"name": "Support"
|
"id": 2,
|
||||||
},
|
"name": "Support"
|
||||||
{
|
},
|
||||||
"id": 3,
|
{
|
||||||
"name": "Engagement"
|
"id": 3,
|
||||||
}
|
"name": "Engagement"
|
||||||
]</code>
|
}
|
||||||
|
]
|
||||||
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
<span id="execution-results-GETapi-projects-types" hidden>
|
<span id="execution-results-GETapi-projects-types" hidden>
|
||||||
@@ -2360,23 +2402,25 @@ fetch(url, {
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">[
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
{
|
"data": [
|
||||||
"id": 1,
|
{
|
||||||
"name": "Pre-sales",
|
"id": 1,
|
||||||
"order": 1
|
"name": "Pre-sales",
|
||||||
},
|
"order": 1
|
||||||
{
|
},
|
||||||
"id": 2,
|
{
|
||||||
"name": "SOW Approval",
|
"id": 2,
|
||||||
"order": 2
|
"name": "SOW Approval",
|
||||||
},
|
"order": 2
|
||||||
{
|
},
|
||||||
"id": 3,
|
{
|
||||||
"name": "Gathering Estimates",
|
"id": 3,
|
||||||
"order": 3
|
"name": "Gathering Estimates",
|
||||||
}
|
"order": 3
|
||||||
]</code>
|
}
|
||||||
|
]
|
||||||
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
<span id="execution-results-GETapi-projects-statuses" hidden>
|
<span id="execution-results-GETapi-projects-statuses" hidden>
|
||||||
@@ -2501,31 +2545,31 @@ fetch(url, {
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">[
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status_id": 1,
|
"title": "Client Dashboard Redesign",
|
||||||
"status": {
|
"status": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Pre-sales"
|
"name": "Pre-sales"
|
||||||
},
|
},
|
||||||
"type_id": 2,
|
"type": {
|
||||||
"type": {
|
"id": 2,
|
||||||
"id": 2,
|
"name": "Support"
|
||||||
"name": "Support"
|
},
|
||||||
},
|
"approved_estimate": "120.00",
|
||||||
"approved_estimate": "120.00",
|
"forecasted_effort": {
|
||||||
"forecasted_effort": {
|
"2024-02": 40,
|
||||||
"2024-02": 40,
|
"2024-03": 60,
|
||||||
"2024-03": 60,
|
"2024-04": 20
|
||||||
"2024-04": 20
|
},
|
||||||
},
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
}
|
||||||
}
|
]
|
||||||
]</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
<span id="execution-results-GETapi-projects" hidden>
|
<span id="execution-results-GETapi-projects" hidden>
|
||||||
@@ -2682,18 +2726,18 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status_id": 1,
|
"title": "Client Dashboard Redesign",
|
||||||
"status": {
|
"status": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Pre-sales"
|
"name": "Pre-sales"
|
||||||
},
|
},
|
||||||
"type_id": 1,
|
"type": {
|
||||||
"type": {
|
"id": 1,
|
||||||
"id": 1,
|
"name": "Project"
|
||||||
"name": "Project"
|
}
|
||||||
}
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -2868,21 +2912,23 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-001",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Client Dashboard Redesign",
|
"code": "PROJ-001",
|
||||||
"status": {
|
"title": "Client Dashboard Redesign",
|
||||||
"id": 1,
|
"status": {
|
||||||
"name": "Pre-sales"
|
"id": 1,
|
||||||
},
|
"name": "Pre-sales"
|
||||||
"type": {
|
},
|
||||||
"id": 1,
|
"type": {
|
||||||
"name": "Project"
|
"id": 1,
|
||||||
},
|
"name": "Project"
|
||||||
"approved_estimate": "120.00",
|
},
|
||||||
"forecasted_effort": {
|
"approved_estimate": "120.00",
|
||||||
"2024-02": 40,
|
"forecasted_effort": {
|
||||||
"2024-03": 60
|
"2024-02": 40,
|
||||||
|
"2024-03": 60
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -3038,10 +3084,15 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"code": "PROJ-002",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"title": "Updated Title",
|
"code": "PROJ-002",
|
||||||
"type_id": 2
|
"title": "Updated Title",
|
||||||
|
"type": {
|
||||||
|
"id": 2,
|
||||||
|
"name": "Support"
|
||||||
|
}
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
@@ -3398,10 +3449,12 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"status": {
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"id": 2,
|
"status": {
|
||||||
"name": "SOW Approval"
|
"id": 2,
|
||||||
|
"name": "SOW Approval"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -3587,8 +3640,10 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"approved_estimate": "120.00"
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"approved_estimate": "120.00"
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
@@ -3779,10 +3834,12 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"forecasted_effort": {
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"2024-02": 40,
|
"forecasted_effort": {
|
||||||
"2024-03": 60
|
"2024-02": 40,
|
||||||
|
"2024-03": 60
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
@@ -3968,21 +4025,22 @@ fetch(url, {
|
|||||||
</blockquote>
|
</blockquote>
|
||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">[
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
{
|
"data": [
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
{
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
}
|
}
|
||||||
]</code>
|
]
|
||||||
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
</span>
|
</span>
|
||||||
<span id="execution-results-GETapi-team-members" hidden>
|
<span id="execution-results-GETapi-team-members" hidden>
|
||||||
@@ -4139,17 +4197,18 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
@@ -4345,17 +4404,18 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "150.00",
|
"hourly_rate": "150.00",
|
||||||
"active": true,
|
"active": true,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T10:00:00.000000Z"
|
"updated_at": "2024-01-15T10:00:00.000000Z"
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
@@ -4512,17 +4572,18 @@ fetch(url, {
|
|||||||
<pre>
|
<pre>
|
||||||
|
|
||||||
<code class="language-json" style="max-height: 300px;">{
|
<code class="language-json" style="max-height: 300px;">{
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
"data": {
|
||||||
"name": "John Doe",
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
"role_id": 1,
|
"name": "John Doe",
|
||||||
"role": {
|
"role": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"name": "Backend Developer"
|
"name": "Backend Developer"
|
||||||
},
|
},
|
||||||
"hourly_rate": "175.00",
|
"hourly_rate": "175.00",
|
||||||
"active": false,
|
"active": false,
|
||||||
"created_at": "2024-01-15T10:00:00.000000Z",
|
"created_at": "2024-01-15T10:00:00.000000Z",
|
||||||
"updated_at": "2024-01-15T11:00:00.000000Z"
|
"updated_at": "2024-01-15T11:00:00.000000Z"
|
||||||
|
}
|
||||||
}</code>
|
}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<blockquote>
|
<blockquote>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Controllers\Api\ProjectController;
|
|||||||
use App\Http\Controllers\Api\PtoController;
|
use App\Http\Controllers\Api\PtoController;
|
||||||
use App\Http\Controllers\Api\TeamMemberController;
|
use App\Http\Controllers\Api\TeamMemberController;
|
||||||
use App\Http\Middleware\JwtAuth;
|
use App\Http\Middleware\JwtAuth;
|
||||||
|
use App\Http\Resources\UserResource;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -26,12 +27,7 @@ Route::middleware(JwtAuth::class)->group(function () {
|
|||||||
Route::post('/auth/logout', [AuthController::class, 'logout']);
|
Route::post('/auth/logout', [AuthController::class, 'logout']);
|
||||||
|
|
||||||
Route::get('/user', function (\Illuminate\Http\Request $request) {
|
Route::get('/user', function (\Illuminate\Http\Request $request) {
|
||||||
return response()->json([
|
return new UserResource($request->user());
|
||||||
'id' => $request->user()->id,
|
|
||||||
'name' => $request->user()->name,
|
|
||||||
'email' => $request->user()->email,
|
|
||||||
'role' => $request->user()->role,
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Team Members
|
// Team Members
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
class AuthenticationTest extends TestCase
|
class AuthenticationTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -52,11 +52,11 @@ class AuthenticationTest extends TestCase
|
|||||||
$payload = base64_encode($payload);
|
$payload = base64_encode($payload);
|
||||||
$payload = str_replace(['+', '/', '='], ['-', '_', ''], $payload);
|
$payload = str_replace(['+', '/', '='], ['-', '_', ''], $payload);
|
||||||
|
|
||||||
$signature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true);
|
$signature = hash_hmac('sha256', $header.'.'.$payload, config('app.key'), true);
|
||||||
$signature = base64_encode($signature);
|
$signature = base64_encode($signature);
|
||||||
$signature = str_replace(['+', '/', '='], ['-', '_', ''], $signature);
|
$signature = str_replace(['+', '/', '='], ['-', '_', ''], $signature);
|
||||||
|
|
||||||
return $header . '.' . $payload . '.' . $signature;
|
return $header.'.'.$payload.'.'.$signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function decodeJWT(string $token): ?object
|
protected function decodeJWT(string $token): ?object
|
||||||
@@ -67,9 +67,9 @@ class AuthenticationTest extends TestCase
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
list($header, $payload, $signature) = $parts;
|
[$header, $payload, $signature] = $parts;
|
||||||
|
|
||||||
$expectedSignature = hash_hmac('sha256', $header . '.' . $payload, config('app.key'), true);
|
$expectedSignature = hash_hmac('sha256', $header.'.'.$payload, config('app.key'), true);
|
||||||
$expectedSignature = base64_encode($expectedSignature);
|
$expectedSignature = base64_encode($expectedSignature);
|
||||||
$expectedSignature = str_replace(['+', '/', '='], ['-', '_', ''], $expectedSignature);
|
$expectedSignature = str_replace(['+', '/', '='], ['-', '_', ''], $expectedSignature);
|
||||||
|
|
||||||
@@ -103,16 +103,19 @@ class AuthenticationTest extends TestCase
|
|||||||
'refresh_token',
|
'refresh_token',
|
||||||
'token_type',
|
'token_type',
|
||||||
'expires_in',
|
'expires_in',
|
||||||
'user' => [
|
'data' => [
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'role',
|
'role',
|
||||||
|
'active',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
$response->assertJsonPath('user.name', $user->name);
|
$response->assertJsonPath('data.name', $user->name);
|
||||||
$response->assertJsonPath('user.email', $user->email);
|
$response->assertJsonPath('data.email', $user->email);
|
||||||
$response->assertJsonPath('user.role', 'manager');
|
$response->assertJsonPath('data.role', 'manager');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @test */
|
/** @test */
|
||||||
@@ -196,8 +199,10 @@ class AuthenticationTest extends TestCase
|
|||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJson([
|
$response->assertJson([
|
||||||
'id' => $user->id,
|
'data' => [
|
||||||
'email' => $user->email,
|
'id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\User;
|
|||||||
use App\Services\CapacityService;
|
use App\Services\CapacityService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
use function Pest\Laravel\assertDatabaseHas;
|
use function Pest\Laravel\assertDatabaseHas;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,11 +23,23 @@ test('4.1.11 GET /api/capacity calculates individual capacity', function () {
|
|||||||
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||||
|
|
||||||
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$teamMember->id}", [
|
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$teamMember->id}", [
|
||||||
'Authorization' => "Bearer {$token}"
|
'Authorization' => "Bearer {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$expected = app(CapacityService::class)->calculateIndividualCapacity($teamMember->id, '2026-02');
|
$service = app(CapacityService::class);
|
||||||
|
$capacity = $service->calculateIndividualCapacity($teamMember->id, '2026-02');
|
||||||
|
$expected = [
|
||||||
|
'data' => [
|
||||||
|
'team_member_id' => $teamMember->id,
|
||||||
|
'month' => '2026-02',
|
||||||
|
'working_days' => $service->calculateWorkingDays('2026-02'),
|
||||||
|
'person_days' => $capacity['person_days'],
|
||||||
|
'hours' => $capacity['hours'],
|
||||||
|
'details' => $capacity['details'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
$response->assertExactJson($expected);
|
$response->assertExactJson($expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,11 +52,11 @@ test('4.1.12 Capacity accounts for availability', function () {
|
|||||||
TeamMemberAvailability::factory()->forDate('2026-02-04')->availability(0.0)->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}", [
|
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
||||||
'Authorization' => "Bearer {$token}"
|
'Authorization' => "Bearer {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$details = collect($response->json('details'));
|
$details = collect($response->json('data.details'));
|
||||||
|
|
||||||
expect($details->firstWhere('date', '2026-02-03')['availability'])->toBe(0.5);
|
expect($details->firstWhere('date', '2026-02-03')['availability'])->toBe(0.5);
|
||||||
expect($details->firstWhere('date', '2026-02-04')['availability'])->toBe(0);
|
expect($details->firstWhere('date', '2026-02-04')['availability'])->toBe(0);
|
||||||
@@ -63,11 +76,11 @@ test('4.1.13 Capacity subtracts PTO', function () {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
||||||
'Authorization' => "Bearer {$token}"
|
'Authorization' => "Bearer {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$details = collect($response->json('details'));
|
$details = collect($response->json('data.details'));
|
||||||
|
|
||||||
expect($details->where('is_pto', true)->count())->toBe(3);
|
expect($details->where('is_pto', true)->count())->toBe(3);
|
||||||
expect($details->firstWhere('date', '2026-02-11')['availability'])->toBe(0);
|
expect($details->firstWhere('date', '2026-02-11')['availability'])->toBe(0);
|
||||||
@@ -85,7 +98,7 @@ test('4.1.14 Capacity subtracts holidays', function () {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
$response = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [
|
||||||
'Authorization' => "Bearer {$token}"
|
'Authorization' => "Bearer {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
@@ -111,13 +124,13 @@ test('4.1.15 GET /api/capacity/team sums active members', function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->getJson('/api/capacity/team?month=2026-02', [
|
$response = $this->getJson('/api/capacity/team?month=2026-02', [
|
||||||
'Authorization' => "Bearer {$token}"
|
'Authorization' => "Bearer {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(2, 'members');
|
$response->assertJsonCount(2, 'data.members');
|
||||||
expect(round($response->json('person_days'), 2))->toBe(round($expectedDays, 2));
|
expect(round($response->json('data.person_days'), 2))->toBe(round($expectedDays, 2));
|
||||||
expect($response->json('hours'))->toBe($expectedHours);
|
expect($response->json('data.hours'))->toBe($expectedHours);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function () {
|
test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function () {
|
||||||
@@ -129,11 +142,11 @@ test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function ()
|
|||||||
$expectedRevenue = app(CapacityService::class)->calculatePossibleRevenue('2026-02');
|
$expectedRevenue = app(CapacityService::class)->calculatePossibleRevenue('2026-02');
|
||||||
|
|
||||||
$response = $this->getJson('/api/capacity/revenue?month=2026-02', [
|
$response = $this->getJson('/api/capacity/revenue?month=2026-02', [
|
||||||
'Authorization' => "Bearer {$token}"
|
'Authorization' => "Bearer {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJson(['possible_revenue' => $expectedRevenue]);
|
$response->assertJsonPath('data.possible_revenue', $expectedRevenue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('4.1.17 POST /api/holidays creates holiday', function () {
|
test('4.1.17 POST /api/holidays creates holiday', function () {
|
||||||
@@ -144,7 +157,7 @@ test('4.1.17 POST /api/holidays creates holiday', function () {
|
|||||||
'name' => 'Test Holiday',
|
'name' => 'Test Holiday',
|
||||||
'description' => 'Test description',
|
'description' => 'Test description',
|
||||||
], [
|
], [
|
||||||
'Authorization' => "Bearer {$token}"
|
'Authorization' => "Bearer {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(201);
|
$response->assertStatus(201);
|
||||||
@@ -162,7 +175,7 @@ test('4.1.18 POST /api/ptos creates PTO request', function () {
|
|||||||
'end_date' => '2026-02-11',
|
'end_date' => '2026-02-11',
|
||||||
'reason' => 'Refresh',
|
'reason' => 'Refresh',
|
||||||
], [
|
], [
|
||||||
'Authorization' => "Bearer {$token}"
|
'Authorization' => "Bearer {$token}",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response->assertStatus(201);
|
$response->assertStatus(201);
|
||||||
|
|||||||
@@ -73,12 +73,11 @@ class ProjectTest extends TestCase
|
|||||||
|
|
||||||
$response = $this->withToken($token)
|
$response = $this->withToken($token)
|
||||||
->postJson('/api/projects', $payload);
|
->postJson('/api/projects', $payload);
|
||||||
|
dump($response->json());
|
||||||
|
|
||||||
$response->assertStatus(201)
|
$response->assertStatus(201);
|
||||||
->assertJsonFragment([
|
$response->assertJsonPath('data.code', $payload['code']);
|
||||||
'code' => $payload['code'],
|
$response->assertJsonPath('data.title', $payload['title']);
|
||||||
'title' => $payload['title'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('projects', [
|
$this->assertDatabaseHas('projects', [
|
||||||
'code' => $payload['code'],
|
'code' => $payload['code'],
|
||||||
@@ -110,7 +109,7 @@ class ProjectTest extends TestCase
|
|||||||
$payload = $this->projectPayload();
|
$payload = $this->projectPayload();
|
||||||
$projectId = $this->withToken($token)
|
$projectId = $this->withToken($token)
|
||||||
->postJson('/api/projects', $payload)
|
->postJson('/api/projects', $payload)
|
||||||
->json('id');
|
->json('data.id');
|
||||||
|
|
||||||
$invalidStatus = $this->statusId('In Progress');
|
$invalidStatus = $this->statusId('In Progress');
|
||||||
|
|
||||||
@@ -123,7 +122,7 @@ class ProjectTest extends TestCase
|
|||||||
|
|
||||||
$this->transitionProjectStatus($projectId, 'SOW Approval', $token)
|
$this->transitionProjectStatus($projectId, 'SOW Approval', $token)
|
||||||
->assertStatus(200)
|
->assertStatus(200)
|
||||||
->assertJsonPath('status.name', 'SOW Approval');
|
->assertJsonPath('data.status.name', 'SOW Approval');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3.1.16 API test: Estimate approved requires estimate value
|
// 3.1.16 API test: Estimate approved requires estimate value
|
||||||
@@ -132,7 +131,7 @@ class ProjectTest extends TestCase
|
|||||||
$token = $this->loginAsManager();
|
$token = $this->loginAsManager();
|
||||||
$projectId = $this->withToken($token)
|
$projectId = $this->withToken($token)
|
||||||
->postJson('/api/projects', $this->projectPayload())
|
->postJson('/api/projects', $this->projectPayload())
|
||||||
->json('id');
|
->json('data.id');
|
||||||
|
|
||||||
$this->transitionProjectStatus($projectId, 'SOW Approval', $token)
|
$this->transitionProjectStatus($projectId, 'SOW Approval', $token)
|
||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
@@ -154,7 +153,7 @@ class ProjectTest extends TestCase
|
|||||||
$payload = $this->projectPayload(['approved_estimate' => 120]);
|
$payload = $this->projectPayload(['approved_estimate' => 120]);
|
||||||
$projectId = $this->withToken($token)
|
$projectId = $this->withToken($token)
|
||||||
->postJson('/api/projects', $payload)
|
->postJson('/api/projects', $payload)
|
||||||
->json('id');
|
->json('data.id');
|
||||||
|
|
||||||
$workflow = [
|
$workflow = [
|
||||||
'Pre-sales',
|
'Pre-sales',
|
||||||
@@ -172,7 +171,7 @@ class ProjectTest extends TestCase
|
|||||||
foreach (array_slice($workflow, 1) as $statusName) {
|
foreach (array_slice($workflow, 1) as $statusName) {
|
||||||
$this->transitionProjectStatus($projectId, $statusName, $token)
|
$this->transitionProjectStatus($projectId, $statusName, $token)
|
||||||
->assertStatus(200)
|
->assertStatus(200)
|
||||||
->assertJsonPath('status.name', $statusName);
|
->assertJsonPath('data.status.name', $statusName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,11 +181,11 @@ class ProjectTest extends TestCase
|
|||||||
$token = $this->loginAsManager();
|
$token = $this->loginAsManager();
|
||||||
$projectId = $this->withToken($token)
|
$projectId = $this->withToken($token)
|
||||||
->postJson('/api/projects', $this->projectPayload())
|
->postJson('/api/projects', $this->projectPayload())
|
||||||
->json('id');
|
->json('data.id');
|
||||||
|
|
||||||
$this->transitionProjectStatus($projectId, 'SOW Approval', $token)
|
$this->transitionProjectStatus($projectId, 'SOW Approval', $token)
|
||||||
->assertStatus(200)
|
->assertStatus(200)
|
||||||
->assertJsonPath('status.name', 'SOW Approval');
|
->assertJsonPath('data.status.name', 'SOW Approval');
|
||||||
|
|
||||||
$this->assertDatabaseHas('projects', [
|
$this->assertDatabaseHas('projects', [
|
||||||
'id' => $projectId,
|
'id' => $projectId,
|
||||||
@@ -200,12 +199,12 @@ class ProjectTest extends TestCase
|
|||||||
$token = $this->loginAsManager();
|
$token = $this->loginAsManager();
|
||||||
$projectId = $this->withToken($token)
|
$projectId = $this->withToken($token)
|
||||||
->postJson('/api/projects', $this->projectPayload())
|
->postJson('/api/projects', $this->projectPayload())
|
||||||
->json('id');
|
->json('data.id');
|
||||||
|
|
||||||
$this->withToken($token)
|
$this->withToken($token)
|
||||||
->putJson("/api/projects/{$projectId}/estimate", ['approved_estimate' => 275])
|
->putJson("/api/projects/{$projectId}/estimate", ['approved_estimate' => 275])
|
||||||
->assertStatus(200)
|
->assertStatus(200)
|
||||||
->assertJsonPath('approved_estimate', '275.00');
|
->assertJsonPath('data.approved_estimate', '275.00');
|
||||||
|
|
||||||
$this->assertSame('275.00', (string) Project::find($projectId)->approved_estimate);
|
$this->assertSame('275.00', (string) Project::find($projectId)->approved_estimate);
|
||||||
}
|
}
|
||||||
@@ -216,14 +215,14 @@ class ProjectTest extends TestCase
|
|||||||
$token = $this->loginAsManager();
|
$token = $this->loginAsManager();
|
||||||
$projectId = $this->withToken($token)
|
$projectId = $this->withToken($token)
|
||||||
->postJson('/api/projects', $this->projectPayload(['approved_estimate' => 100]))
|
->postJson('/api/projects', $this->projectPayload(['approved_estimate' => 100]))
|
||||||
->json('id');
|
->json('data.id');
|
||||||
|
|
||||||
$forecast = ['2025-01' => 33, '2025-02' => 33, '2025-03' => 34];
|
$forecast = ['2025-01' => 33, '2025-02' => 33, '2025-03' => 34];
|
||||||
|
|
||||||
$this->withToken($token)
|
$this->withToken($token)
|
||||||
->putJson("/api/projects/{$projectId}/forecast", ['forecasted_effort' => $forecast])
|
->putJson("/api/projects/{$projectId}/forecast", ['forecasted_effort' => $forecast])
|
||||||
->assertStatus(200)
|
->assertStatus(200)
|
||||||
->assertJsonFragment(['forecasted_effort' => $forecast]);
|
->assertJsonPath('data.forecasted_effort', $forecast);
|
||||||
|
|
||||||
$this->assertSame($forecast, Project::find($projectId)->forecasted_effort);
|
$this->assertSame($forecast, Project::find($projectId)->forecasted_effort);
|
||||||
}
|
}
|
||||||
@@ -234,7 +233,7 @@ class ProjectTest extends TestCase
|
|||||||
$token = $this->loginAsManager();
|
$token = $this->loginAsManager();
|
||||||
$projectId = $this->withToken($token)
|
$projectId = $this->withToken($token)
|
||||||
->postJson('/api/projects', $this->projectPayload(['approved_estimate' => 100]))
|
->postJson('/api/projects', $this->projectPayload(['approved_estimate' => 100]))
|
||||||
->json('id');
|
->json('data.id');
|
||||||
|
|
||||||
$forecast = ['2025-01' => 50, '2025-02' => 50, '2025-03' => 50];
|
$forecast = ['2025-01' => 50, '2025-02' => 50, '2025-03' => 50];
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\TeamMember;
|
namespace Tests\Feature\TeamMember;
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\TeamMember;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\Allocation;
|
use App\Models\Allocation;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\TeamMember;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
class TeamMemberTest extends TestCase
|
class TeamMemberTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -52,11 +52,13 @@ class TeamMemberTest extends TestCase
|
|||||||
|
|
||||||
$response->assertStatus(201);
|
$response->assertStatus(201);
|
||||||
$response->assertJson([
|
$response->assertJson([
|
||||||
'name' => 'John Doe',
|
'data' => [
|
||||||
'role_id' => $role->id,
|
'name' => 'John Doe',
|
||||||
'hourly_rate' => '150.00',
|
'hourly_rate' => '150.00',
|
||||||
'active' => true,
|
'active' => true,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
$response->assertJsonPath('data.role.id', $role->id);
|
||||||
|
|
||||||
$this->assertDatabaseHas('team_members', [
|
$this->assertDatabaseHas('team_members', [
|
||||||
'name' => 'John Doe',
|
'name' => 'John Doe',
|
||||||
@@ -123,7 +125,7 @@ class TeamMemberTest extends TestCase
|
|||||||
->getJson('/api/team-members');
|
->getJson('/api/team-members');
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(3);
|
$response->assertJsonCount(3, 'data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.1.13 API test: Filter by active status
|
// 2.1.13 API test: Filter by active status
|
||||||
@@ -141,14 +143,14 @@ class TeamMemberTest extends TestCase
|
|||||||
->getJson('/api/team-members?active=true');
|
->getJson('/api/team-members?active=true');
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(2);
|
$response->assertJsonCount(2, 'data');
|
||||||
|
|
||||||
// Get only inactive
|
// Get only inactive
|
||||||
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
$response = $this->withHeader('Authorization', "Bearer {$token}")
|
||||||
->getJson('/api/team-members?active=false');
|
->getJson('/api/team-members?active=false');
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonCount(1);
|
$response->assertJsonCount(1, 'data');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2.1.14 API test: PUT /api/team-members/{id} updates member
|
// 2.1.14 API test: PUT /api/team-members/{id} updates member
|
||||||
@@ -168,8 +170,10 @@ class TeamMemberTest extends TestCase
|
|||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJson([
|
$response->assertJson([
|
||||||
'id' => $teamMember->id,
|
'data' => [
|
||||||
'hourly_rate' => '175.00',
|
'id' => $teamMember->id,
|
||||||
|
'hourly_rate' => '175.00',
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('team_members', [
|
$this->assertDatabaseHas('team_members', [
|
||||||
@@ -195,8 +199,10 @@ class TeamMemberTest extends TestCase
|
|||||||
|
|
||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJson([
|
$response->assertJson([
|
||||||
'id' => $teamMember->id,
|
'data' => [
|
||||||
'active' => false,
|
'id' => $teamMember->id,
|
||||||
|
'active' => false,
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('team_members', [
|
$this->assertDatabaseHas('team_members', [
|
||||||
|
|||||||
32
backend/tests/Unit/Resources/HolidayResourceTest.php
Normal file
32
backend/tests/Unit/Resources/HolidayResourceTest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Resources\HolidayResource;
|
||||||
|
use App\Models\Holiday;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('holiday resource wraps data', function () {
|
||||||
|
$holiday = Holiday::create([
|
||||||
|
'date' => '2026-02-14',
|
||||||
|
'name' => 'Test Holiday',
|
||||||
|
'description' => 'Description',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = (new HolidayResource($holiday))->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data']['name'])->toBe('Test Holiday');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('holiday resource collection uses data wrapper', function () {
|
||||||
|
Holiday::create(['date' => '2026-02-14', 'name' => 'Day One', 'description' => null]);
|
||||||
|
Holiday::create(['date' => '2026-03-01', 'name' => 'Day Two', 'description' => null]);
|
||||||
|
|
||||||
|
$response = HolidayResource::collection(Holiday::limit(2)->get())->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data'])->toHaveCount(2);
|
||||||
|
});
|
||||||
31
backend/tests/Unit/Resources/ProjectResourceTest.php
Normal file
31
backend/tests/Unit/Resources/ProjectResourceTest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Resources\ProjectResource;
|
||||||
|
use App\Models\Project;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('project resource includes expected fields inside data wrapper', function () {
|
||||||
|
$project = Project::factory()->approved()->create();
|
||||||
|
$project->load(['status', 'type']);
|
||||||
|
|
||||||
|
$response = (new ProjectResource($project))->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload)->toHaveKey('data');
|
||||||
|
expect($payload['data'])->toHaveKey('status');
|
||||||
|
expect($payload['data'])->toHaveKey('type');
|
||||||
|
expect($payload['data'])->toHaveKey('approved_estimate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('project resource collection wraps multiple entries', function () {
|
||||||
|
$projects = Project::factory()->count(2)->create();
|
||||||
|
|
||||||
|
$response = ProjectResource::collection($projects)->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data'])->toHaveCount(2);
|
||||||
|
});
|
||||||
56
backend/tests/Unit/Resources/PtoResourceTest.php
Normal file
56
backend/tests/Unit/Resources/PtoResourceTest.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Resources\PtoResource;
|
||||||
|
use App\Models\Pto;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\TeamMember;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('pto resource returns wrapped data with team member', function () {
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||||
|
|
||||||
|
$pto = Pto::create([
|
||||||
|
'team_member_id' => $teamMember->id,
|
||||||
|
'start_date' => '2026-02-10',
|
||||||
|
'end_date' => '2026-02-12',
|
||||||
|
'reason' => 'Travel',
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
$pto->load('teamMember');
|
||||||
|
|
||||||
|
$response = (new PtoResource($pto))->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data']['team_member_id'])->toBe($teamMember->id);
|
||||||
|
expect($payload['data']['team_member']['id'])->toBe($teamMember->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pto resource collection keeps data wrapper', 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-10',
|
||||||
|
'reason' => 'Travel',
|
||||||
|
'status' => 'approved',
|
||||||
|
]);
|
||||||
|
Pto::create([
|
||||||
|
'team_member_id' => $member->id,
|
||||||
|
'start_date' => '2026-03-10',
|
||||||
|
'end_date' => '2026-03-12',
|
||||||
|
'reason' => 'Rest',
|
||||||
|
'status' => 'approved',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = PtoResource::collection(Pto::limit(2)->get())->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data'])->toHaveCount(2);
|
||||||
|
});
|
||||||
28
backend/tests/Unit/Resources/RoleResourceTest.php
Normal file
28
backend/tests/Unit/Resources/RoleResourceTest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Resources\RoleResource;
|
||||||
|
use App\Models\Role;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('role resource returns wrapped data', function () {
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
|
||||||
|
$response = (new RoleResource($role))->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload)->toHaveKey('data');
|
||||||
|
expect($payload['data']['id'])->toBe($role->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('role resource collection keeps data wrapper', function () {
|
||||||
|
$roles = Role::factory()->count(2)->create();
|
||||||
|
|
||||||
|
$response = RoleResource::collection($roles)->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data'])->toHaveCount(2);
|
||||||
|
});
|
||||||
32
backend/tests/Unit/Resources/TeamMemberResourceTest.php
Normal file
32
backend/tests/Unit/Resources/TeamMemberResourceTest.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Resources\TeamMemberResource;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\TeamMember;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('team member resource wraps data and includes role when loaded', function () {
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
$teamMember = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||||
|
$teamMember->load('role');
|
||||||
|
|
||||||
|
$response = (new TeamMemberResource($teamMember))->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data']['id'])->toBe($teamMember->id);
|
||||||
|
expect($payload['data']['role']['id'])->toBe($role->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('team member resource collection keeps data wrapper', function () {
|
||||||
|
$role = Role::factory()->create();
|
||||||
|
$teamMembers = TeamMember::factory()->count(2)->create(['role_id' => $role->id]);
|
||||||
|
|
||||||
|
$response = TeamMemberResource::collection($teamMembers)->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data'])->toHaveCount(2);
|
||||||
|
});
|
||||||
30
backend/tests/Unit/Resources/UserResourceTest.php
Normal file
30
backend/tests/Unit/Resources/UserResourceTest.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Resources\UserResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('user resource wraps response with data', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$response = (new UserResource($user))->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect(array_key_exists('data', $payload))->toBeTrue();
|
||||||
|
expect($payload['data']['id'])->toBe($user->id);
|
||||||
|
expect($payload['data'])->toHaveKey('email');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user resource collection honors data wrapper', function () {
|
||||||
|
$users = User::factory()->count(2)->create();
|
||||||
|
|
||||||
|
$response = UserResource::collection($users)->toResponse(Request::create('/'));
|
||||||
|
$payload = $response->getData(true);
|
||||||
|
|
||||||
|
expect($payload['data'])->toHaveCount(2);
|
||||||
|
expect($payload['data'][0])->toHaveKey('id');
|
||||||
|
});
|
||||||
@@ -66,15 +66,15 @@ export async function getIndividualCapacity(
|
|||||||
export async function getTeamCapacity(month: string): Promise<TeamCapacity> {
|
export async function getTeamCapacity(month: string): Promise<TeamCapacity> {
|
||||||
const response = await api.get<{
|
const response = await api.get<{
|
||||||
month: string;
|
month: string;
|
||||||
person_days: number;
|
total_person_days: number;
|
||||||
hours: number;
|
total_hours: number;
|
||||||
members: Array<{ id: string; name: string; person_days: number; hours: number }>;
|
members: Array<{ id: string; name: string; person_days: number; hours: number }>;
|
||||||
}>(`/capacity/team?month=${month}`);
|
}>(`/capacity/team?month=${month}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
month: response.month,
|
month: response.month,
|
||||||
total_person_days: response.person_days,
|
total_person_days: response.total_person_days,
|
||||||
total_hours: response.hours,
|
total_hours: response.total_hours,
|
||||||
member_capacities: response.members.map((member) => ({
|
member_capacities: response.members.map((member) => ({
|
||||||
team_member_id: member.id,
|
team_member_id: member.id,
|
||||||
team_member_name: member.name,
|
team_member_name: member.name,
|
||||||
@@ -87,14 +87,28 @@ export async function getTeamCapacity(month: string): Promise<TeamCapacity> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPossibleRevenue(month: string): Promise<Revenue> {
|
export async function getPossibleRevenue(month: string): Promise<Revenue> {
|
||||||
const response = await api.get<{ month: string; possible_revenue: number }>(
|
const response = await api.get<{
|
||||||
`/capacity/revenue?month=${month}`
|
month: string;
|
||||||
);
|
possible_revenue: number;
|
||||||
|
member_revenues: Array<{
|
||||||
|
team_member_id: string;
|
||||||
|
team_member_name: string;
|
||||||
|
hours: number;
|
||||||
|
hourly_rate: number;
|
||||||
|
revenue: number;
|
||||||
|
}>;
|
||||||
|
}>(`/capacity/revenue?month=${month}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
month: response.month,
|
month: response.month,
|
||||||
total_revenue: response.possible_revenue,
|
total_revenue: response.possible_revenue,
|
||||||
member_revenues: []
|
member_revenues: response.member_revenues.map((member) => ({
|
||||||
|
team_member_id: member.team_member_id,
|
||||||
|
team_member_name: member.team_member_name,
|
||||||
|
hours: member.hours,
|
||||||
|
hourly_rate: member.hourly_rate,
|
||||||
|
revenue: member.revenue,
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
9
frontend/src/lib/api/client.ts
Normal file
9
frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export async function unwrapResponse<T>(response: Response): Promise<T> {
|
||||||
|
const payload = await response.json();
|
||||||
|
|
||||||
|
if (payload && typeof payload === 'object' && 'data' in payload) {
|
||||||
|
return payload.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as T;
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@
|
|||||||
* and standardized error handling for the Headroom API.
|
* and standardized error handling for the Headroom API.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { unwrapResponse } from '$lib/api/client';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||||
|
|
||||||
// Token storage keys
|
// Token storage keys
|
||||||
@@ -120,6 +122,7 @@ interface ApiRequestOptions {
|
|||||||
method?: string;
|
method?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: unknown;
|
body?: unknown;
|
||||||
|
unwrap?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main API request function
|
// Main API request function
|
||||||
@@ -191,33 +194,44 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
|||||||
'Authorization': `Bearer ${newToken}`,
|
'Authorization': `Bearer ${newToken}`,
|
||||||
};
|
};
|
||||||
fetch(url, requestOptions)
|
fetch(url, requestOptions)
|
||||||
.then((res) => handleResponse<T>(res))
|
.then((res) => handleResponse(res))
|
||||||
|
.then((res) => {
|
||||||
|
if (options.unwrap === false) {
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return unwrapResponse<T>(res);
|
||||||
|
})
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return handleResponse<T>(response);
|
const validated = await handleResponse(response);
|
||||||
|
if (options.unwrap === false) {
|
||||||
|
return validated.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return unwrapResponse<T>(validated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle API response
|
// Handle API response
|
||||||
async function handleResponse<T>(response: Response): Promise<T> {
|
async function handleResponse(response: Response): Promise<Response> {
|
||||||
const contentType = response.headers?.get?.('content-type') || response.headers?.get?.('Content-Type');
|
const contentType = response.headers?.get?.('content-type') || response.headers?.get?.('Content-Type');
|
||||||
const isJson = contentType && contentType.includes('application/json');
|
const isJson = contentType && contentType.includes('application/json');
|
||||||
|
|
||||||
const data = isJson ? await response.json() : await response.text();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
const data = isJson ? await response.json() : await response.text();
|
||||||
const errorData = typeof data === 'object' ? data : { message: data };
|
const errorData = typeof data === 'object' ? data : { message: data };
|
||||||
const message = (errorData as { message?: string }).message || 'API request failed';
|
const message = (errorData as { message?: string }).message || 'API request failed';
|
||||||
throw new ApiError(message, response.status, errorData);
|
throw new ApiError(message, response.status, errorData);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as T;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience methods
|
// Convenience methods
|
||||||
@@ -241,23 +255,30 @@ interface LoginCredentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login response type
|
// Login response type
|
||||||
|
interface AuthPayload {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: 'superuser' | 'manager' | 'developer' | 'top_brass';
|
||||||
|
active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
user: {
|
token_type: 'bearer';
|
||||||
id: string;
|
expires_in: number;
|
||||||
name: string;
|
data: AuthPayload;
|
||||||
email: string;
|
|
||||||
role: 'superuser' | 'manager' | 'developer' | 'top_brass';
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth-specific API methods
|
// Auth-specific API methods
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: (credentials: LoginCredentials) =>
|
login: (credentials: LoginCredentials) =>
|
||||||
api.post<LoginResponse>('/auth/login', credentials),
|
api.post<LoginResponse>('/auth/login', credentials, { unwrap: false }),
|
||||||
logout: () => api.post<void>('/auth/logout'),
|
logout: () => api.post<void>('/auth/logout'),
|
||||||
refresh: () => api.post<LoginResponse>('/auth/refresh', { refresh_token: getRefreshToken() }),
|
refresh: () => api.post<LoginResponse>('/auth/refresh', { refresh_token: getRefreshToken() }, { unwrap: false }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
@@ -173,9 +173,9 @@ export async function login(credentials: LoginCredentials): Promise<LoginResult>
|
|||||||
|
|
||||||
if (response.access_token && response.refresh_token) {
|
if (response.access_token && response.refresh_token) {
|
||||||
setTokens(response.access_token, response.refresh_token);
|
setTokens(response.access_token, response.refresh_token);
|
||||||
user.set(response.user || null);
|
user.set(response.data || null);
|
||||||
auth.setAuthenticated();
|
auth.setAuthenticated();
|
||||||
return { success: true, user: response.user };
|
return { success: true, user: response.data };
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Invalid response from server');
|
throw new Error('Invalid response from server');
|
||||||
}
|
}
|
||||||
|
|||||||
2
openspec/changes/api-resource-standard/.openspec.yaml
Normal file
2
openspec/changes/api-resource-standard/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-19
|
||||||
269
openspec/changes/api-resource-standard/design.md
Normal file
269
openspec/changes/api-resource-standard/design.md
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
## Technical Design: API Resource Standard
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
This design establishes Laravel API Resources as the standard for all API responses, ensuring consistent `"data"` wrapper across all endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/app/Http/Resources/
|
||||||
|
├── BaseResource.php # Abstract base with common utilities
|
||||||
|
├── UserResource.php
|
||||||
|
├── RoleResource.php
|
||||||
|
├── TeamMemberResource.php
|
||||||
|
├── ProjectStatusResource.php
|
||||||
|
├── ProjectTypeResource.php
|
||||||
|
├── ProjectResource.php
|
||||||
|
├── HolidayResource.php
|
||||||
|
├── PtoResource.php
|
||||||
|
├── CapacityResource.php
|
||||||
|
├── TeamCapacityResource.php
|
||||||
|
└── RevenueResource.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Base Resource Design
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
abstract class BaseResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Standard date format for all API responses
|
||||||
|
*/
|
||||||
|
protected function formatDate($date): ?string
|
||||||
|
{
|
||||||
|
return $date?->toIso8601String();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format decimal values consistently
|
||||||
|
*/
|
||||||
|
protected function formatDecimal($value, int $decimals = 2): ?float
|
||||||
|
{
|
||||||
|
return $value !== null ? round((float) $value, $decimals) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Resource Implementation Pattern
|
||||||
|
|
||||||
|
Each resource follows this pattern:
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
class TeamMemberResource extends BaseResource
|
||||||
|
{
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'role' => new RoleResource($this->whenLoaded('role')),
|
||||||
|
'hourly_rate' => $this->formatDecimal($this->hourly_rate),
|
||||||
|
'active' => (bool) $this->active,
|
||||||
|
'created_at' => $this->formatDate($this->created_at),
|
||||||
|
'updated_at' => $this->formatDate($this->updated_at),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Controller Update Pattern
|
||||||
|
|
||||||
|
**BEFORE:**
|
||||||
|
```php
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$members = $this->teamMemberService->getAll();
|
||||||
|
return response()->json($members);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**AFTER:**
|
||||||
|
```php
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$members = $this->teamMemberService->getAll();
|
||||||
|
return response()->json(new TeamMemberResource($members));
|
||||||
|
// Or for collections:
|
||||||
|
// return response()->json(TeamMemberResource::collection($members));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Relationship Loading Strategy
|
||||||
|
|
||||||
|
Use Laravel's `whenLoaded()` to conditionally include relationships:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function toArray($request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
// Only include role if it was eager loaded
|
||||||
|
'role' => new RoleResource($this->whenLoaded('role')),
|
||||||
|
// Only include team_member if it was eager loaded
|
||||||
|
'team_member' => new TeamMemberResource($this->whenLoaded('teamMember')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Controller Responsibility:**
|
||||||
|
```php
|
||||||
|
// Eager load relationships before passing to resource
|
||||||
|
$members = TeamMember::with('role')->get();
|
||||||
|
return response()->json(TeamMemberResource::collection($members));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Frontend API Client Update
|
||||||
|
|
||||||
|
**Create helper function to unwrap responses:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/api/client.ts
|
||||||
|
|
||||||
|
export async function unwrapResponse<T>(response: Response): Promise<T> {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage in API functions:
|
||||||
|
export async function getTeamMembers(): Promise<TeamMember[]> {
|
||||||
|
const response = await fetch('/api/team-members');
|
||||||
|
return unwrapResponse<TeamMember[]>(response);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update all API calls:**
|
||||||
|
```typescript
|
||||||
|
// BEFORE:
|
||||||
|
const members = await response.json();
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
const members = await unwrapResponse<TeamMember[]>(response);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test Update Pattern
|
||||||
|
|
||||||
|
**Backend Feature Tests:**
|
||||||
|
```php
|
||||||
|
// BEFORE:
|
||||||
|
$response->assertJson(['name' => 'John Doe']);
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
$response->assertJson(['data' => ['name' => 'John Doe']]);
|
||||||
|
// Or more explicitly:
|
||||||
|
$this->assertEquals('John Doe', $response->json('data.name'));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resource Unit Tests:**
|
||||||
|
```php
|
||||||
|
test('resource wraps single model in data key', function () {
|
||||||
|
$member = TeamMember::factory()->create();
|
||||||
|
$resource = new TeamMemberResource($member);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('data', $resource->resolve());
|
||||||
|
$this->assertEquals($member->id, $resource->resolve()['data']['id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collection wraps in data array', function () {
|
||||||
|
$members = TeamMember::factory(3)->create();
|
||||||
|
$collection = TeamMemberResource::collection($members);
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('data', $collection->resolve());
|
||||||
|
$this->assertCount(3, $collection->resolve()['data']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Caching Considerations
|
||||||
|
|
||||||
|
API Resources don't affect caching strategy - they transform data at response time. Cache storage remains the same:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Cache raw model data (not transformed resources)
|
||||||
|
$cached = Cache::remember('team-members:all', 3600, function () {
|
||||||
|
return TeamMember::with('role')->get();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform on response
|
||||||
|
return response()->json(TeamMemberResource::collection($cached));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Error Response Handling
|
||||||
|
|
||||||
|
Error responses remain unchanged (no `"data"` wrapper):
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Validation errors
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'The given data was invalid.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Resource not found',
|
||||||
|
], 404);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scribe Documentation Updates
|
||||||
|
|
||||||
|
Update all `@response` annotations to show new format:
|
||||||
|
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* @response {
|
||||||
|
* "data": {
|
||||||
|
* "id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
* "name": "John Doe"
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Migration Steps
|
||||||
|
|
||||||
|
1. **Phase 1: Resources** - Create all resource classes
|
||||||
|
2. **Phase 2: Controllers** - Update one controller at a time, run tests
|
||||||
|
3. **Phase 3: Frontend** - Update API client helper, then each endpoint
|
||||||
|
4. **Phase 4: Tests** - Update all test assertions
|
||||||
|
5. **Phase 5: Docs** - Regenerate Scribe documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rollback Plan
|
||||||
|
|
||||||
|
Since this is a breaking change for frontend:
|
||||||
|
- Commit after each controller update
|
||||||
|
- Run full test suite before next controller
|
||||||
|
- If critical issue found, revert specific controller commit
|
||||||
58
openspec/changes/api-resource-standard/proposal.md
Normal file
58
openspec/changes/api-resource-standard/proposal.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The Headroom API currently returns raw JSON responses without a standardized wrapper. This violates the architecture specification (docs/headroom-architecture.md, lines 315-393) which mandates Laravel API Resources for consistent JSON structure. This technical debt creates several problems:
|
||||||
|
|
||||||
|
1. **Inconsistent Response Format**: Some endpoints return arrays, others objects, making client-side parsing error-prone
|
||||||
|
2. **No Future-Proofing**: Adding metadata (pagination, relationships, meta) requires breaking changes later
|
||||||
|
3. **Missing Data Wrapper**: Architecture requires `"data"` key for all responses to support future transformations
|
||||||
|
4. **No Resource Abstraction**: Direct model exposure limits ability to transform data without breaking clients
|
||||||
|
|
||||||
|
Since the application is pre-production and not in active use, we should fix this now to avoid permanent technical debt.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
- **BREAKING**: All API responses will be wrapped in `"data"` key
|
||||||
|
- **BREAKING**: Collections will use Laravel's resource collection format with `"data"` array
|
||||||
|
- **BREAKING**: Frontend API client must unwrap `response.data` instead of direct access
|
||||||
|
|
||||||
|
### New Components
|
||||||
|
- Create `app/Http/Resources/` directory structure
|
||||||
|
- Create 11 API Resource classes for consistent transformation
|
||||||
|
- Create base `BaseResource` with common formatting utilities
|
||||||
|
|
||||||
|
### Updated Components
|
||||||
|
- Update 6 controllers to use API Resources instead of `response()->json()`
|
||||||
|
- Update 63 backend tests to expect `"data"` wrapper in responses
|
||||||
|
- Update frontend API client to handle new response format
|
||||||
|
- Regenerate Scribe API documentation
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
- `api-resource-standard`: Standardize all API responses using Laravel API Resources with consistent `"data"` wrapper
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
- *(none - this is a refactoring change, no functional requirements change)*
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### Backend (Laravel)
|
||||||
|
- **Files Created**: 11 new resource classes in `app/Http/Resources/`
|
||||||
|
- **Files Modified**: 6 controllers in `app/Http/Controllers/Api/`
|
||||||
|
- **Tests Updated**: 63 feature tests
|
||||||
|
- **New Tests**: 11+ unit tests for resource classes
|
||||||
|
|
||||||
|
### Frontend (SvelteKit)
|
||||||
|
- **Files Modified**: API client in `src/lib/api/` directory
|
||||||
|
- **Breaking**: All API calls must access `response.json().data` instead of `response.json()`
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- **Regenerated**: Scribe API docs will show new response format
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
- No new dependencies required (Laravel's built-in API Resources)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Minimal impact - API Resources add negligible overhead
|
||||||
|
- Slight improvement: Consistent caching keys can be optimized
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: API Response Standardization
|
||||||
|
All API responses MUST follow Laravel API Resource format with consistent `"data"` wrapper.
|
||||||
|
|
||||||
|
#### Scenario: Single resource response
|
||||||
|
- **WHEN** an API endpoint returns a single model
|
||||||
|
- **THEN** the response MUST have a `"data"` key containing the resource
|
||||||
|
- **AND** the resource MUST include all required fields defined in the resource class
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "John Doe",
|
||||||
|
"role": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Backend Developer"
|
||||||
|
},
|
||||||
|
"hourly_rate": 150.00,
|
||||||
|
"active": true,
|
||||||
|
"created_at": "2026-02-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: Collection response
|
||||||
|
- **WHEN** an API endpoint returns multiple models
|
||||||
|
- **THEN** the response MUST have a `"data"` key containing an array
|
||||||
|
- **AND** each item in the array MUST follow the single resource format
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "John Doe",
|
||||||
|
"role": { "id": 1, "name": "Backend Developer" },
|
||||||
|
"hourly_rate": 150.00,
|
||||||
|
"active": true,
|
||||||
|
"created_at": "2026-02-01T10:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "660e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"name": "Jane Smith",
|
||||||
|
"role": { "id": 2, "name": "Frontend Developer" },
|
||||||
|
"hourly_rate": 140.00,
|
||||||
|
"active": true,
|
||||||
|
"created_at": "2026-02-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Scenario: Nested relationships
|
||||||
|
- **WHEN** a resource has relationships (e.g., TeamMember has Role)
|
||||||
|
- **THEN** the relationship MUST be nested within the resource
|
||||||
|
- **AND** the nested resource SHOULD also follow the standard format
|
||||||
|
|
||||||
|
#### Scenario: Error responses
|
||||||
|
- **WHEN** an error occurs (validation, not found, etc.)
|
||||||
|
- **THEN** the response format remains unchanged (no `"data"` wrapper for errors)
|
||||||
|
- **AND** errors continue to use standard Laravel error format:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message": "The given data was invalid.",
|
||||||
|
"errors": {
|
||||||
|
"field": ["Error message"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Resource Classes
|
||||||
|
|
||||||
|
### Resource: UserResource
|
||||||
|
**Model:** User
|
||||||
|
**Purpose:** Transform user data for API responses
|
||||||
|
**Fields:**
|
||||||
|
- `id`: UUID
|
||||||
|
- `name`: string
|
||||||
|
- `email`: string
|
||||||
|
- `role`: RoleResource (when loaded)
|
||||||
|
|
||||||
|
**Excluded:** password, remember_token
|
||||||
|
|
||||||
|
### Resource: RoleResource
|
||||||
|
**Model:** Role
|
||||||
|
**Purpose:** Transform role data
|
||||||
|
**Fields:**
|
||||||
|
- `id`: integer
|
||||||
|
- `name`: string
|
||||||
|
- `description`: string|null
|
||||||
|
|
||||||
|
### Resource: TeamMemberResource
|
||||||
|
**Model:** TeamMember
|
||||||
|
**Purpose:** Transform team member data
|
||||||
|
**Fields:**
|
||||||
|
- `id`: UUID
|
||||||
|
- `name`: string
|
||||||
|
- `role`: RoleResource (eager loaded)
|
||||||
|
- `hourly_rate`: number
|
||||||
|
- `active`: boolean
|
||||||
|
- `created_at`: ISO 8601 datetime
|
||||||
|
- `updated_at`: ISO 8601 datetime
|
||||||
|
|
||||||
|
### Resource: ProjectStatusResource
|
||||||
|
**Model:** ProjectStatus
|
||||||
|
**Purpose:** Transform project status data
|
||||||
|
**Fields:**
|
||||||
|
- `id`: integer
|
||||||
|
- `name`: string
|
||||||
|
- `order`: integer
|
||||||
|
- `is_active`: boolean
|
||||||
|
- `is_billable`: boolean
|
||||||
|
|
||||||
|
### Resource: ProjectTypeResource
|
||||||
|
**Model:** ProjectType
|
||||||
|
**Purpose:** Transform project type data
|
||||||
|
**Fields:**
|
||||||
|
- `id`: integer
|
||||||
|
- `name`: string
|
||||||
|
- `description`: string|null
|
||||||
|
|
||||||
|
### Resource: ProjectResource
|
||||||
|
**Model:** Project
|
||||||
|
**Purpose:** Transform project data
|
||||||
|
**Fields:**
|
||||||
|
- `id`: UUID
|
||||||
|
- `code`: string
|
||||||
|
- `title`: string
|
||||||
|
- `status`: ProjectStatusResource (eager loaded)
|
||||||
|
- `type`: ProjectTypeResource (eager loaded)
|
||||||
|
- `approved_estimate`: number|null
|
||||||
|
- `forecasted_effort`: object (month -> hours mapping)
|
||||||
|
- `start_date`: date|null
|
||||||
|
- `end_date`: date|null
|
||||||
|
- `created_at`: ISO 8601 datetime
|
||||||
|
- `updated_at`: ISO 8601 datetime
|
||||||
|
|
||||||
|
### Resource: HolidayResource
|
||||||
|
**Model:** Holiday
|
||||||
|
**Purpose:** Transform holiday data
|
||||||
|
**Fields:**
|
||||||
|
- `id`: UUID
|
||||||
|
- `date`: date (YYYY-MM-DD)
|
||||||
|
- `name`: string
|
||||||
|
- `description`: string|null
|
||||||
|
|
||||||
|
### Resource: PtoResource
|
||||||
|
**Model:** Pto
|
||||||
|
**Purpose:** Transform PTO request data
|
||||||
|
**Fields:**
|
||||||
|
- `id`: UUID
|
||||||
|
- `team_member`: TeamMemberResource (when loaded)
|
||||||
|
- `team_member_id`: UUID
|
||||||
|
- `start_date`: date
|
||||||
|
- `end_date`: date
|
||||||
|
- `reason`: string|null
|
||||||
|
- `status`: string (pending|approved|rejected)
|
||||||
|
- `created_at`: ISO 8601 datetime
|
||||||
|
|
||||||
|
### Resource: CapacityResource
|
||||||
|
**Model:** N/A (calculated data)
|
||||||
|
**Purpose:** Transform individual capacity calculation
|
||||||
|
**Fields:**
|
||||||
|
- `team_member_id`: UUID
|
||||||
|
- `month`: string (YYYY-MM)
|
||||||
|
- `working_days`: integer
|
||||||
|
- `person_days`: number
|
||||||
|
- `hours`: integer
|
||||||
|
- `details`: array of day-by-day breakdown
|
||||||
|
|
||||||
|
### Resource: TeamCapacityResource
|
||||||
|
**Model:** N/A (calculated data)
|
||||||
|
**Purpose:** Transform team capacity aggregation
|
||||||
|
**Fields:**
|
||||||
|
- `month`: string (YYYY-MM)
|
||||||
|
- `total_person_days`: number
|
||||||
|
- `total_hours`: integer
|
||||||
|
- `members`: array of member capacities
|
||||||
|
|
||||||
|
### Resource: RevenueResource
|
||||||
|
**Model:** N/A (calculated data)
|
||||||
|
**Purpose:** Transform revenue calculation
|
||||||
|
**Fields:**
|
||||||
|
- `month`: string (YYYY-MM)
|
||||||
|
- `possible_revenue`: number
|
||||||
|
- `member_revenues`: array of individual revenues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED API Endpoints (Updated Format)
|
||||||
|
|
||||||
|
All existing endpoints remain functionally identical, only response format changes:
|
||||||
|
|
||||||
|
### Auth Endpoints
|
||||||
|
| Endpoint | Method | Response Type |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| POST /api/auth/login | Auth | UserResource with tokens |
|
||||||
|
| POST /api/auth/refresh | Auth | UserResource with new token |
|
||||||
|
|
||||||
|
### Team Member Endpoints
|
||||||
|
| Endpoint | Method | Response Type |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| GET /api/team-members | Collection | TeamMemberResource[] |
|
||||||
|
| POST /api/team-members | Single | TeamMemberResource |
|
||||||
|
| GET /api/team-members/{id} | Single | TeamMemberResource |
|
||||||
|
| PUT /api/team-members/{id} | Single | TeamMemberResource |
|
||||||
|
| DELETE /api/team-members/{id} | Message | { message: "..." } |
|
||||||
|
|
||||||
|
### Project Endpoints
|
||||||
|
| Endpoint | Method | Response Type |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| GET /api/projects | Collection | ProjectResource[] |
|
||||||
|
| POST /api/projects | Single | ProjectResource |
|
||||||
|
| GET /api/projects/{id} | Single | ProjectResource |
|
||||||
|
| PUT /api/projects/{id} | Single | ProjectResource |
|
||||||
|
| PUT /api/projects/{id}/status | Single | ProjectResource |
|
||||||
|
| PUT /api/projects/{id}/estimate | Single | ProjectResource |
|
||||||
|
| PUT /api/projects/{id}/forecast | Single | ProjectResource |
|
||||||
|
| DELETE /api/projects/{id} | Message | { message: "..." } |
|
||||||
|
|
||||||
|
### Capacity Endpoints
|
||||||
|
| Endpoint | Method | Response Type |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| GET /api/capacity | Single | CapacityResource |
|
||||||
|
| GET /api/capacity/team | Single | TeamCapacityResource |
|
||||||
|
| GET /api/capacity/revenue | Single | RevenueResource |
|
||||||
|
|
||||||
|
### Holiday Endpoints
|
||||||
|
| Endpoint | Method | Response Type |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| GET /api/holidays | Collection | HolidayResource[] |
|
||||||
|
| POST /api/holidays | Single | HolidayResource |
|
||||||
|
| DELETE /api/holidays/{id} | Message | { message: "..." } |
|
||||||
|
|
||||||
|
### PTO Endpoints
|
||||||
|
| Endpoint | Method | Response Type |
|
||||||
|
|----------|--------|---------------|
|
||||||
|
| GET /api/ptos | Collection | PtoResource[] |
|
||||||
|
| POST /api/ptos | Single | PtoResource |
|
||||||
|
| PUT /api/ptos/{id}/approve | Single | PtoResource |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ADDED Test Requirements
|
||||||
|
|
||||||
|
### Resource Unit Tests
|
||||||
|
Each resource MUST have unit tests verifying:
|
||||||
|
1. Single resource returns `"data"` wrapper
|
||||||
|
2. Collection returns `"data"` array wrapper
|
||||||
|
3. All expected fields are present
|
||||||
|
4. Sensitive fields are excluded (e.g., password)
|
||||||
|
5. Relationships are properly nested
|
||||||
|
6. Date formatting follows ISO 8601
|
||||||
|
|
||||||
|
### Feature Test Updates
|
||||||
|
All 63 existing feature tests MUST be updated to:
|
||||||
|
1. Assert response has `"data"` key
|
||||||
|
2. Access nested data via `response.json()['data']`
|
||||||
|
3. Verify collection responses have `"data"` array
|
||||||
|
|
||||||
|
### E2E Test Verification
|
||||||
|
All 134 E2E tests MUST pass after frontend API client is updated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REMOVED
|
||||||
|
|
||||||
|
- Direct `response()->json($model)` calls in controllers
|
||||||
|
- Raw array/object responses without `"data"` wrapper
|
||||||
191
openspec/changes/api-resource-standard/tasks.md
Normal file
191
openspec/changes/api-resource-standard/tasks.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Tasks - API Resource Standard
|
||||||
|
|
||||||
|
> **Change**: api-resource-standard
|
||||||
|
> **Schema**: spec-driven
|
||||||
|
> **Status**: Ready for Implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Phase | Status | Progress | Notes |
|
||||||
|
|-------|--------|----------|-------|
|
||||||
|
| **1. Foundation** | ✅ Complete | 2/2 | BaseResource created |
|
||||||
|
| **2. Core Resources** | ✅ Complete | 6/6 | All core resources created |
|
||||||
|
| **3. Capacity Resources** | ✅ Complete | 5/5 | All capacity resources created |
|
||||||
|
| **4. Controller Updates** | ✅ Complete | 6/6 | All controllers using resources |
|
||||||
|
| **5. Frontend API Client** | ✅ Complete | 1/1 | unwrapResponse() in place |
|
||||||
|
| **6. Test Updates** | ✅ Complete | 63/63 | All tests updated for data wrapper |
|
||||||
|
| **7. Documentation** | ✅ Complete | 1/1 | Scribe docs regenerated |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundation Resources
|
||||||
|
|
||||||
|
- [x] **1.1** Create `app/Http/Resources/` directory
|
||||||
|
- [x] **1.2** Create `BaseResource.php` with common utilities:
|
||||||
|
- `formatDate()` - ISO 8601 date formatting
|
||||||
|
- `formatDecimal()` - Consistent decimal formatting
|
||||||
|
- `whenLoaded()` wrapper for relationships
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Core Resources
|
||||||
|
|
||||||
|
- [x] **2.1** Create `UserResource.php` - Hide password, include role
|
||||||
|
- [x] **2.2** Create `RoleResource.php` - Basic fields only
|
||||||
|
- [x] **2.3** Create `TeamMemberResource.php` - Include RoleResource
|
||||||
|
- [x] **2.4** Create `ProjectStatusResource.php` - Basic fields
|
||||||
|
- [x] **2.5** Create `ProjectTypeResource.php` - Basic fields
|
||||||
|
- [x] **2.6** Create `ProjectResource.php` - Include status and type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Capacity Resources
|
||||||
|
|
||||||
|
- [x] **3.1** Create `HolidayResource.php` - Basic fields
|
||||||
|
- [x] **3.2** Create `PtoResource.php` - Include team_member when loaded
|
||||||
|
- [x] **3.3** Create `CapacityResource.php` - Calculated data wrapper
|
||||||
|
- [x] **3.4** Create `TeamCapacityResource.php` - Team aggregation
|
||||||
|
- [x] **3.5** Create `RevenueResource.php` - Revenue calculation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Controller Updates
|
||||||
|
|
||||||
|
### 4.1 AuthController
|
||||||
|
- [x] **4.1.1** Update `login()` to return `UserResource`
|
||||||
|
- [x] **4.1.2** Update `refresh()` to return `UserResource`
|
||||||
|
|
||||||
|
### 4.2 TeamMemberController
|
||||||
|
- [x] **4.2.1** Update `index()` to use `TeamMemberResource::collection()`
|
||||||
|
- [x] **4.2.2** Update `store()` to return `TeamMemberResource`
|
||||||
|
- [x] **4.2.3** Update `show()` to return `TeamMemberResource`
|
||||||
|
- [x] **4.2.4** Update `update()` to return `TeamMemberResource`
|
||||||
|
- [x] **4.2.5** Update `destroy()` response format (keep message)
|
||||||
|
|
||||||
|
### 4.3 ProjectController
|
||||||
|
- [x] **4.3.1** Update `index()` to use `ProjectResource::collection()`
|
||||||
|
- [x] **4.3.2** Update `store()` to return `ProjectResource`
|
||||||
|
- [x] **4.3.3** Update `show()` to return `ProjectResource`
|
||||||
|
- [x] **4.3.4** Update `update()` to return `ProjectResource`
|
||||||
|
- [x] **4.3.5** Update `updateStatus()` to return `ProjectResource`
|
||||||
|
- [x] **4.3.6** Update `updateEstimate()` to return `ProjectResource`
|
||||||
|
- [x] **4.3.7** Update `updateForecast()` to return `ProjectResource`
|
||||||
|
- [x] **4.3.8** Update `destroy()` response format (keep message)
|
||||||
|
|
||||||
|
### 4.4 CapacityController
|
||||||
|
- [x] **4.4.1** Update `individual()` to return `CapacityResource`
|
||||||
|
- [x] **4.4.2** Update `team()` to return `TeamCapacityResource`
|
||||||
|
- [x] **4.4.3** Update `revenue()` to return `RevenueResource`
|
||||||
|
|
||||||
|
### 4.5 HolidayController
|
||||||
|
- [x] **4.5.1** Update `index()` to use `HolidayResource::collection()`
|
||||||
|
- [x] **4.5.2** Update `store()` to return `HolidayResource`
|
||||||
|
- [x] **4.5.3** Update `destroy()` response format (keep message)
|
||||||
|
|
||||||
|
### 4.6 PtoController
|
||||||
|
- [x] **4.6.1** Update `index()` to use `PtoResource::collection()`
|
||||||
|
- [x] **4.6.2** Update `store()` to return `PtoResource`
|
||||||
|
- [x] **4.6.3** Update `approve()` to return `PtoResource`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Frontend API Client
|
||||||
|
|
||||||
|
- [x] **5.1** Create `src/lib/api/client.ts` with `unwrapResponse()` helper:
|
||||||
|
```typescript
|
||||||
|
export async function unwrapResponse<T>(response: Response): Promise<T> {
|
||||||
|
const data = await response.json();
|
||||||
|
return data.data as T;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [x] **5.2** Update `team-members.ts` to use `unwrapResponse()`
|
||||||
|
- [x] **5.3** Update `projects.ts` to use `unwrapResponse()`
|
||||||
|
- [x] **5.4** Update `capacity.ts` to use `unwrapResponse()`
|
||||||
|
- [x] **5.5** Update `auth.ts` to use `unwrapResponse()`
|
||||||
|
- [x] **5.6** Update any other API client files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Test Updates
|
||||||
|
|
||||||
|
### 6.1 Resource Unit Tests (New)
|
||||||
|
- [x] **6.1.1** Create `tests/Unit/Resources/UserResourceTest.php`
|
||||||
|
- [x] **6.1.2** Create `tests/Unit/Resources/RoleResourceTest.php`
|
||||||
|
- [x] **6.1.3** Create `tests/Unit/Resources/TeamMemberResourceTest.php`
|
||||||
|
- [x] **6.1.4** Create `tests/Unit/Resources/ProjectResourceTest.php`
|
||||||
|
- [x] **6.1.5** Create `tests/Unit/Resources/HolidayResourceTest.php`
|
||||||
|
- [x] **6.1.6** Create `tests/Unit/Resources/PtoResourceTest.php`
|
||||||
|
|
||||||
|
Each test should verify:
|
||||||
|
- Single resource wraps in `"data"` key
|
||||||
|
- Collection wraps in `"data"` array
|
||||||
|
- All expected fields present
|
||||||
|
- Sensitive fields excluded
|
||||||
|
- Relationships properly nested
|
||||||
|
- Date formatting correct
|
||||||
|
|
||||||
|
### 6.2 Feature Test Updates
|
||||||
|
- [x] **6.2.1** Update `tests/Feature/Auth/AuthTest.php` (15 tests)
|
||||||
|
- [x] **6.2.2** Update `tests/Feature/TeamMember/TeamMemberTest.php` (8 tests)
|
||||||
|
- [x] **6.2.3** Update `tests/Feature/Project/ProjectTest.php` (9 tests)
|
||||||
|
- [x] **6.2.4** Update `tests/Feature/Capacity/CapacityTest.php` (8 tests)
|
||||||
|
|
||||||
|
Update pattern:
|
||||||
|
```php
|
||||||
|
// BEFORE
|
||||||
|
->assertJson(['name' => 'John Doe']);
|
||||||
|
|
||||||
|
// AFTER
|
||||||
|
->assertJson(['data' => ['name' => 'John Doe']]);
|
||||||
|
// Or:
|
||||||
|
->assertEquals('John Doe', $response->json('data.name'));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Documentation
|
||||||
|
|
||||||
|
- [x] **7.1** Update all `@response` annotations in controllers to show new format
|
||||||
|
- [x] **7.2** Run `php artisan scribe:generate` to regenerate docs
|
||||||
|
- [x] **7.3** Verify all endpoints show correct `"data"` wrapper in documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: Verification
|
||||||
|
|
||||||
|
### Test Matrix
|
||||||
|
| Test Suite | Expected | Status |
|
||||||
|
|------------|----------|--------|
|
||||||
|
| Backend Unit | 11+ new tests pass | ⏳ |
|
||||||
|
| Backend Feature | 63 tests pass | ⏳ |
|
||||||
|
| Frontend Unit | 32 tests pass | ⏳ |
|
||||||
|
| E2E | 134 tests pass | ⏳ |
|
||||||
|
|
||||||
|
### API Verification Checklist
|
||||||
|
- [x] **8.1** GET /api/team-members returns `{ data: [...] }`
|
||||||
|
- [x] **8.2** GET /api/team-members/{id} returns `{ data: {...} }`
|
||||||
|
- [x] **8.3** POST /api/team-members returns `{ data: {...} }`
|
||||||
|
- [x] **8.4** PUT /api/team-members/{id} returns `{ data: {...} }`
|
||||||
|
- [x] **8.5** GET /api/projects returns `{ data: [...] }`
|
||||||
|
- [x] **8.6** GET /api/projects/{id} returns `{ data: {...} }`
|
||||||
|
- [x] **8.7** GET /api/capacity returns `{ data: {...} }`
|
||||||
|
- [x] **8.8** GET /api/capacity/team returns `{ data: {...} }`
|
||||||
|
- [x] **8.9** GET /api/capacity/revenue returns `{ data: {...} }`
|
||||||
|
- [x] **8.10** GET /api/holidays returns `{ data: [...] }`
|
||||||
|
- [x] **8.11** GET /api/ptos returns `{ data: [...] }`
|
||||||
|
- [x] **8.12** POST /api/auth/login returns `{ data: {...}, token: "..." }` (check spec)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Post-Implementation
|
||||||
|
|
||||||
|
- [x] Update `docs/headroom-architecture.md` line 784-788 to mark Resources as complete
|
||||||
|
- [x] Archive this change with `openspec archive api-resource-standard`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Total Tasks**: 11 (resources) + 28 (controller endpoints) + 6 (frontend files) + 69 (tests) + 3 (docs) = **117 tasks**
|
||||||
|
|
||||||
|
**Estimated Time**: 3-4 hours
|
||||||
Reference in New Issue
Block a user