diff --git a/backend/app/Http/Controllers/Api/HolidayController.php b/backend/app/Http/Controllers/Api/HolidayController.php index 3384a9ba..302459b4 100644 --- a/backend/app/Http/Controllers/Api/HolidayController.php +++ b/backend/app/Http/Controllers/Api/HolidayController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Resources\HolidayResource; use App\Models\Holiday; use App\Services\CapacityService; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -65,6 +66,7 @@ class HolidayController extends Controller * "description": "Office closed" * } * } + * @response 422 {"message":"A holiday already exists for this date.","errors":{"date":["A holiday already exists for this date."]}} */ public function store(Request $request): JsonResponse { @@ -74,10 +76,19 @@ class HolidayController extends Controller 'description' => 'nullable|string', ]); - $holiday = Holiday::create($data); - $this->capacityService->forgetCapacityCacheForMonth($holiday->date->format('Y-m')); + try { + $holiday = Holiday::create($data); + $this->capacityService->forgetCapacityCacheForMonth($holiday->date->format('Y-m')); - return $this->wrapResource(new HolidayResource($holiday), 201); + return $this->wrapResource(new HolidayResource($holiday), 201); + } catch (UniqueConstraintViolationException $e) { + return response()->json([ + 'message' => 'A holiday already exists for this date.', + 'errors' => [ + 'date' => ['A holiday already exists for this date.'], + ], + ], 422); + } } /** diff --git a/backend/app/Http/Controllers/Api/PtoController.php b/backend/app/Http/Controllers/Api/PtoController.php index 35845682..ec34d5b8 100644 --- a/backend/app/Http/Controllers/Api/PtoController.php +++ b/backend/app/Http/Controllers/Api/PtoController.php @@ -7,6 +7,7 @@ use App\Http\Resources\PtoResource; use App\Models\Pto; use App\Services\CapacityService; use Carbon\Carbon; +use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -68,7 +69,7 @@ class PtoController extends Controller /** * Request PTO * - * Create a PTO request for a team member and keep it in pending status. + * Create a PTO request for a team member and approve it immediately. * * @group Capacity Planning * @@ -83,7 +84,7 @@ class PtoController extends Controller * "team_member_id": "550e8400-e29b-41d4-a716-446655440000", * "start_date": "2026-02-10", * "end_date": "2026-02-12", - * "status": "pending", + * "status": "approved", * "reason": "Family travel" * } * } @@ -97,10 +98,26 @@ class PtoController extends Controller 'reason' => 'nullable|string', ]); - $pto = Pto::create(array_merge($data, ['status' => 'pending'])); - $pto->load('teamMember'); + try { + $pto = Pto::create(array_merge($data, ['status' => 'approved'])); + $months = $this->monthsBetween($pto->start_date, $pto->end_date); + $this->capacityService->forgetCapacityCacheForTeamMember($pto->team_member_id, $months); - return $this->wrapResource(new PtoResource($pto), 201); + foreach ($months as $month) { + $this->capacityService->forgetCapacityCacheForMonth($month); + } + + $pto->load('teamMember'); + + return $this->wrapResource(new PtoResource($pto), 201); + } catch (UniqueConstraintViolationException $e) { + return response()->json([ + 'message' => 'A PTO request with these details already exists.', + 'errors' => [ + 'general' => ['A PTO request with these details already exists.'], + ], + ], 422); + } } /** @@ -128,6 +145,10 @@ class PtoController extends Controller $pto->save(); $months = $this->monthsBetween($pto->start_date, $pto->end_date); $this->capacityService->forgetCapacityCacheForTeamMember($pto->team_member_id, $months); + + foreach ($months as $month) { + $this->capacityService->forgetCapacityCacheForMonth($month); + } } $pto->load('teamMember'); @@ -135,6 +156,27 @@ class PtoController extends Controller return $this->wrapResource(new PtoResource($pto)); } + public function destroy(string $id): JsonResponse + { + $pto = Pto::find($id); + + if (! $pto) { + return response()->json(['message' => 'PTO not found'], 404); + } + + $months = $this->monthsBetween($pto->start_date, $pto->end_date); + $teamMemberId = $pto->team_member_id; + $pto->delete(); + + $this->capacityService->forgetCapacityCacheForTeamMember($teamMemberId, $months); + + foreach ($months as $month) { + $this->capacityService->forgetCapacityCacheForMonth($month); + } + + return response()->json(['message' => 'PTO deleted']); + } + private function monthsBetween(Carbon|string $start, Carbon|string $end): array { $startMonth = Carbon::create($start)->copy()->startOfMonth(); diff --git a/backend/app/Services/CapacityService.php b/backend/app/Services/CapacityService.php index 9cc9990d..ee22ee24 100644 --- a/backend/app/Services/CapacityService.php +++ b/backend/app/Services/CapacityService.php @@ -61,12 +61,11 @@ class CapacityService continue; } - $availability = $availabilities->get($date, 1.0); $isPto = in_array($date, $ptoDates, true); - - if ($isPto) { - $availability = 0.0; - } + $hasAvailabilityOverride = $availabilities->has($date); + $availability = $hasAvailabilityOverride + ? (float) $availabilities->get($date) + : ($isPto ? 0.0 : 1.0); $details[] = [ 'date' => $date, @@ -198,13 +197,14 @@ class CapacityService foreach ($months as $month) { $tags = $this->getCapacityCacheTags($month, "team_member:{$teamMemberId}"); + $key = $this->buildCacheKey($month, $teamMemberId); + + // Always forget from array store (used in tests and as fallback) + Cache::store('array')->forget($key); if ($useRedis) { $this->flushCapacityTags($tags); - continue; } - - $this->forgetCapacity($this->buildCacheKey($month, $teamMemberId)); } } @@ -213,17 +213,16 @@ class CapacityService */ public function forgetCapacityCacheForMonth(string $month): void { + // Always forget from array store (used in tests and as fallback) + foreach (TeamMember::pluck('id') as $teamMemberId) { + Cache::store('array')->forget($this->buildCacheKey($month, $teamMemberId)); + } + Cache::store('array')->forget($this->buildCacheKey($month, 'team')); + Cache::store('array')->forget($this->buildCacheKey($month, 'revenue')); + if ($this->redisAvailable()) { $this->flushCapacityTags($this->getCapacityCacheTags($month)); - return; } - - foreach (TeamMember::pluck('id') as $teamMemberId) { - $this->forgetCapacity($this->buildCacheKey($month, $teamMemberId)); - } - - $this->forgetCapacity($this->buildCacheKey($month, 'team')); - $this->forgetCapacity($this->buildCacheKey($month, 'revenue')); } /** diff --git a/backend/app/Services/TeamMemberService.php b/backend/app/Services/TeamMemberService.php index 85ad3b03..37f32583 100644 --- a/backend/app/Services/TeamMemberService.php +++ b/backend/app/Services/TeamMemberService.php @@ -3,9 +3,13 @@ namespace App\Services; use App\Models\TeamMember; +use Closure; +use DateTimeInterface; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Validation\ValidationException; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; +use Throwable; /** * Team Member Service @@ -14,28 +18,36 @@ use Illuminate\Support\Facades\Validator; */ class TeamMemberService { + private ?bool $redisAvailable = null; + /** * Get all team members with optional filtering. * - * @param bool|null $active Filter by active status + * @param bool|null $active Filter by active status * @return Collection */ public function getAll(?bool $active = null): Collection { - $query = TeamMember::with('role'); + /** @var Collection $teamMembers */ + $teamMembers = $this->rememberTeamMembers( + $this->buildTeamMembersCacheKey($active), + now()->addHour(), + function () use ($active): Collection { + $query = TeamMember::with('role'); - if ($active !== null) { - $query->where('active', $active); - } + if ($active !== null) { + $query->where('active', $active); + } - return $query->get(); + return $query->get(); + } + ); + + return $teamMembers; } /** * Find a team member by ID. - * - * @param string $id - * @return TeamMember|null */ public function findById(string $id): ?TeamMember { @@ -45,8 +57,6 @@ class TeamMemberService /** * Create a new team member. * - * @param array $data - * @return TeamMember * @throws ValidationException */ public function create(array $data): TeamMember @@ -72,6 +82,7 @@ class TeamMemberService ]); $teamMember->load('role'); + $this->forgetTeamMembersCache(); return $teamMember; } @@ -79,9 +90,6 @@ class TeamMemberService /** * Update an existing team member. * - * @param TeamMember $teamMember - * @param array $data - * @return TeamMember * @throws ValidationException */ public function update(TeamMember $teamMember, array $data): TeamMember @@ -101,6 +109,7 @@ class TeamMemberService $teamMember->update($data); $teamMember->load('role'); + $this->forgetTeamMembersCache(); return $teamMember; } @@ -108,8 +117,6 @@ class TeamMemberService /** * Delete a team member. * - * @param TeamMember $teamMember - * @return void * @throws \RuntimeException */ public function delete(TeamMember $teamMember): void @@ -131,12 +138,12 @@ class TeamMemberService } $teamMember->delete(); + $this->forgetTeamMembersCache(); } /** * Check if a team member can be deleted. * - * @param TeamMember $teamMember * @return array{canDelete: bool, reason?: string} */ public function canDelete(TeamMember $teamMember): array @@ -157,4 +164,77 @@ class TeamMemberService return ['canDelete' => true]; } + + private function buildTeamMembersCacheKey(?bool $active): string + { + if ($active === null) { + return 'team-members:all'; + } + + return $active ? 'team-members:active' : 'team-members:inactive'; + } + + /** + * @param Closure(): Collection $callback + * @return Collection + */ + private function rememberTeamMembers(string $key, DateTimeInterface|int $ttl, Closure $callback): Collection + { + if (! $this->redisAvailable()) { + /** @var Collection $payload */ + $payload = Cache::store('array')->remember($key, $ttl, $callback); + + return $payload; + } + + try { + /** @var Collection $payload */ + $payload = Cache::store('redis')->remember($key, $ttl, $callback); + + return $payload; + } catch (Throwable) { + /** @var Collection $payload */ + $payload = Cache::store('array')->remember($key, $ttl, $callback); + + return $payload; + } + } + + private function forgetTeamMembersCache(): void + { + Cache::store('array')->forget($this->buildTeamMembersCacheKey(null)); + Cache::store('array')->forget($this->buildTeamMembersCacheKey(true)); + Cache::store('array')->forget($this->buildTeamMembersCacheKey(false)); + + if (! $this->redisAvailable()) { + return; + } + + try { + Cache::store('redis')->forget($this->buildTeamMembersCacheKey(null)); + Cache::store('redis')->forget($this->buildTeamMembersCacheKey(true)); + Cache::store('redis')->forget($this->buildTeamMembersCacheKey(false)); + } catch (Throwable) { + // Ignore cache failures when Redis is unavailable. + } + } + + private function redisAvailable(): bool + { + if ($this->redisAvailable !== null) { + return $this->redisAvailable; + } + + if (! config('cache.stores.redis')) { + return $this->redisAvailable = false; + } + + $client = config('database.redis.client', 'phpredis'); + + if ($client === 'predis') { + return $this->redisAvailable = class_exists('Predis\\Client'); + } + + return $this->redisAvailable = extension_loaded('redis'); + } } diff --git a/backend/database/migrations/2024_02_17_000009_create_ptos_table.php b/backend/database/migrations/2024_02_17_000009_create_ptos_table.php index 84b33f60..2feaeaad 100644 --- a/backend/database/migrations/2024_02_17_000009_create_ptos_table.php +++ b/backend/database/migrations/2024_02_17_000009_create_ptos_table.php @@ -17,7 +17,7 @@ return new class extends Migration $table->date('start_date'); $table->date('end_date'); $table->string('reason')->nullable(); - $table->enum('status', ['pending', 'approved', 'rejected'])->default('pending'); + $table->enum('status', ['pending', 'approved', 'rejected'])->default('approved'); $table->timestamps(); }); } diff --git a/backend/routes/api.php b/backend/routes/api.php index a1c64d04..08f2d7e1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -55,5 +55,6 @@ Route::middleware(JwtAuth::class)->group(function () { // PTO Route::get('/ptos', [PtoController::class, 'index']); Route::post('/ptos', [PtoController::class, 'store']); + Route::delete('/ptos/{id}', [PtoController::class, 'destroy']); Route::put('/ptos/{id}/approve', [PtoController::class, 'approve']); }); diff --git a/backend/tests/Feature/Capacity/CapacityTest.php b/backend/tests/Feature/Capacity/CapacityTest.php index c1c9a621..df189ae8 100644 --- a/backend/tests/Feature/Capacity/CapacityTest.php +++ b/backend/tests/Feature/Capacity/CapacityTest.php @@ -189,6 +189,34 @@ test('4.1.17 POST /api/holidays creates holiday', function () { assertDatabaseHas('holidays', ['date' => '2026-02-20 00:00:00', 'name' => 'Test Holiday']); }); +test('4.1.17b POST /api/holidays returns 422 for duplicate date', function () { + $token = loginAsManager($this); + + // Create first holiday + $this->postJson('/api/holidays', [ + 'date' => '2026-02-20', + 'name' => 'First Holiday', + ], [ + 'Authorization' => "Bearer {$token}", + ])->assertStatus(201); + + // Try to create duplicate + $response = $this->postJson('/api/holidays', [ + 'date' => '2026-02-20', + 'name' => 'Duplicate Holiday', + ], [ + 'Authorization' => "Bearer {$token}", + ]); + + $response->assertStatus(422); + $response->assertJson([ + 'message' => 'A holiday already exists for this date.', + 'errors' => [ + 'date' => ['A holiday already exists for this date.'], + ], + ]); +}); + test('4.1.18 POST /api/ptos creates PTO request', function () { $token = loginAsManager($this); $role = Role::factory()->create(); @@ -204,8 +232,96 @@ test('4.1.18 POST /api/ptos creates PTO request', function () { ]); $response->assertStatus(201); - $response->assertJsonPath('data.status', 'pending'); - assertDatabaseHas('ptos', ['team_member_id' => $member->id, 'status' => 'pending']); + $response->assertJsonPath('data.status', 'approved'); + assertDatabaseHas('ptos', ['team_member_id' => $member->id, 'status' => 'approved']); +}); + +test('4.1.19 PTO creation invalidates team and revenue caches', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create([ + 'role_id' => $role->id, + 'hourly_rate' => 80, + 'active' => true, + ]); + + $month = '2026-02'; + + $this->getJson("/api/capacity/team?month={$month}", [ + 'Authorization' => "Bearer {$token}", + ])->assertStatus(200); + + $this->getJson("/api/capacity/revenue?month={$month}", [ + 'Authorization' => "Bearer {$token}", + ])->assertStatus(200); + + $this->postJson('/api/ptos', [ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-26', + 'end_date' => '2026-02-28', + 'reason' => 'Vacation', + ], [ + 'Authorization' => "Bearer {$token}", + ])->assertStatus(201); + + $teamResponse = $this->getJson("/api/capacity/team?month={$month}", [ + 'Authorization' => "Bearer {$token}", + ]); + + $teamResponse->assertStatus(200); + expect((float) $teamResponse->json('data.person_days'))->toBe(18.0); + expect($teamResponse->json('data.hours'))->toBe(144); + + $revenueResponse = $this->getJson("/api/capacity/revenue?month={$month}", [ + 'Authorization' => "Bearer {$token}", + ]); + + $revenueResponse->assertStatus(200); + expect((float) $revenueResponse->json('data.possible_revenue'))->toBe(11520.0); +}); + +test('4.1.20 DELETE /api/ptos/{id} removes PTO and refreshes capacity', function () { + $token = loginAsManager($this); + $role = Role::factory()->create(); + $member = TeamMember::factory()->create([ + 'role_id' => $role->id, + 'hourly_rate' => 80, + 'active' => true, + ]); + + $createResponse = $this->postJson('/api/ptos', [ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-26', + 'end_date' => '2026-02-28', + 'reason' => 'Vacation', + ], [ + 'Authorization' => "Bearer {$token}", + ]); + + $createResponse->assertStatus(201); + $ptoId = $createResponse->json('data.id'); + + $beforeDelete = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [ + 'Authorization' => "Bearer {$token}", + ]); + $beforeDelete->assertStatus(200); + expect((float) $beforeDelete->json('data.person_days'))->toBe(18.0); + + $deleteResponse = $this->deleteJson("/api/ptos/{$ptoId}", [], [ + 'Authorization' => "Bearer {$token}", + ]); + $deleteResponse->assertStatus(200); + $deleteResponse->assertJson(['message' => 'PTO deleted']); + + $afterDelete = $this->getJson("/api/capacity?month=2026-02&team_member_id={$member->id}", [ + 'Authorization' => "Bearer {$token}", + ]); + $afterDelete->assertStatus(200); + expect((float) $afterDelete->json('data.person_days'))->toBe(20.0); + + $this->deleteJson('/api/ptos/non-existent', [], [ + 'Authorization' => "Bearer {$token}", + ])->assertStatus(404); }); function loginAsManager(TestCase $test): string diff --git a/backend/tests/Feature/TeamMember/TeamMemberTest.php b/backend/tests/Feature/TeamMember/TeamMemberTest.php index 9a9a5c83..75ee9df7 100644 --- a/backend/tests/Feature/TeamMember/TeamMemberTest.php +++ b/backend/tests/Feature/TeamMember/TeamMemberTest.php @@ -241,4 +241,35 @@ class TeamMemberTest extends TestCase 'id' => $teamMember->id, ]); } + + public function test_team_member_cache_is_invalidated_after_updates(): void + { + $token = $this->loginAsManager(); + $role = Role::factory()->create(); + $teamMember = TeamMember::factory()->create([ + 'role_id' => $role->id, + 'active' => true, + ]); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/team-members?active=true') + ->assertStatus(200) + ->assertJsonCount(1, 'data'); + + $this->withHeader('Authorization', "Bearer {$token}") + ->putJson("/api/team-members/{$teamMember->id}", [ + 'active' => false, + ]) + ->assertStatus(200); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/team-members?active=true') + ->assertStatus(200) + ->assertJsonCount(0, 'data'); + + $this->withHeader('Authorization', "Bearer {$token}") + ->getJson('/api/team-members?active=false') + ->assertStatus(200) + ->assertJsonCount(1, 'data'); + } } diff --git a/backend/tests/Unit/Resources/PtoResourceTest.php b/backend/tests/Unit/Resources/PtoResourceTest.php index 03df0dc2..79087296 100644 --- a/backend/tests/Unit/Resources/PtoResourceTest.php +++ b/backend/tests/Unit/Resources/PtoResourceTest.php @@ -19,7 +19,7 @@ test('pto resource returns wrapped data with team member', function () { 'start_date' => '2026-02-10', 'end_date' => '2026-02-12', 'reason' => 'Travel', - 'status' => 'pending', + 'status' => 'approved', ]); $pto->load('teamMember'); diff --git a/backend/tests/Unit/Services/CapacityServiceTest.php b/backend/tests/Unit/Services/CapacityServiceTest.php index 19c016d1..ef5e0bbb 100644 --- a/backend/tests/Unit/Services/CapacityServiceTest.php +++ b/backend/tests/Unit/Services/CapacityServiceTest.php @@ -113,3 +113,429 @@ test('4.1.24 Redis caching for capacity', function () { expect(Cache::store('array')->get($key))->not->toBeNull(); }); + +// ============================================================================ +// COMPREHENSIVE CAPACITY CALCULATION TESTS +// ============================================================================ + +test('4.1.25 Capacity with PTO and holiday combined', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // Create 2 weekdays as PTO (Feb 10-11 are Tuesday-Wednesday) + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-11', + 'reason' => 'Vacation', + 'status' => 'approved', + ]); + + // Create 1 holiday (Feb 17 is Tuesday - Presidents Day) + Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // Debug: Check actual values + $details = collect($result['details']); + $ptoDays = $details->where('is_pto', true)->count(); + $holidayExcluded = $details->where('date', '2026-02-17')->count() === 0; + + // Expected: working days - 2 PTO days = capacity + // workingDays already excludes holidays + // Feb 2026: 20 working days - 1 holiday = 19 working days + // 19 working days - 2 PTO = 17 person days + $expectedCapacity = $workingDays - 2; + + expect($workingDays)->toBe(19, 'Working days should be 19 (20 - 1 holiday)') + ->and($ptoDays)->toBe(2, 'Should have 2 PTO days marked') + ->and($holidayExcluded)->toBeTrue('Holiday should be excluded from details') + ->and($result['person_days'])->toBe((float) $expectedCapacity) + ->and($result['person_days'])->toBe(17.0) + ->and($result['hours'])->toBe(136); +}); + +test('4.1.26 PTO spanning weekend days', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // PTO from Friday to Monday (Feb 6-9, 2026: Fri, Sat, Sun, Mon) + // Only 2 working days should be subtracted (Fri Feb 6 and Mon Feb 9) + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-06', + 'end_date' => '2026-02-09', + 'reason' => 'Long weekend', + 'status' => 'approved', + ]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // Only 2 working days subtracted (Fri and Mon), not 4 + $expectedCapacity = $workingDays - 2; + + expect($result['person_days'])->toBe((float) $expectedCapacity); + + // Verify weekend dates are not in details + $details = collect($result['details']); + expect($details->where('date', '2026-02-07')->count())->toBe(0) // Saturday + ->and($details->where('date', '2026-02-08')->count())->toBe(0); // Sunday +}); + +test('4.1.27 PTO on a holiday date', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // Create holiday on Feb 17 (Tuesday) + Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']); + + // Create PTO that includes the holiday (Feb 16-18, Mon-Wed) + // Feb 16 is Monday, Feb 17 is holiday, Feb 18 is Wednesday + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-16', + 'end_date' => '2026-02-18', + 'reason' => 'Vacation', + 'status' => 'approved', + ]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // Holiday is already excluded from working days + // PTO should subtract 2 more days (Mon Feb 16 and Wed Feb 18) + $expectedCapacity = $workingDays - 2; + + expect($result['person_days'])->toBe((float) $expectedCapacity); + + // Verify holiday is not in details at all + $details = collect($result['details']); + expect($details->where('date', '2026-02-17')->count())->toBe(0); +}); + +test('4.1.28 Multiple separate PTO periods', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // First PTO: Feb 3-4 (Tue-Wed) + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-03', + 'end_date' => '2026-02-04', + 'reason' => 'Personal', + 'status' => 'approved', + ]); + + // Second PTO: Feb 24-25 (Tue-Wed) + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-24', + 'end_date' => '2026-02-25', + 'reason' => 'Personal', + 'status' => 'approved', + ]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // 4 PTO days total + $expectedCapacity = $workingDays - 4; + + expect($result['person_days'])->toBe((float) $expectedCapacity); +}); + +test('4.1.29 Half-day availability with PTO', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // Half-day on Feb 3 + TeamMemberAvailability::factory() + ->forDate('2026-02-03') + ->availability(0.5) + ->create(['team_member_id' => $member->id]); + + // PTO on Feb 4-5 + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-04', + 'end_date' => '2026-02-05', + 'reason' => 'Vacation', + 'status' => 'approved', + ]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // working days - 0.5 (half day) - 2 (PTO) = capacity + $expectedCapacity = $workingDays - 0.5 - 2; + + expect($result['person_days'])->toBe($expectedCapacity); +}); + +test('4.1.30 PTO with pending status is not counted', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // Pending PTO should NOT affect capacity + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-12', + 'reason' => 'Pending vacation', + 'status' => 'pending', + ]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // Pending PTO should not subtract any days + expect($result['person_days'])->toBe((float) $workingDays); +}); + +test('4.1.31 PTO spanning month boundary', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // PTO from Jan 29 to Feb 3 (spans Jan/Feb boundary) + // In Feb: Feb 2 (Mon) and Feb 3 (Tue) should be counted + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-01-29', + 'end_date' => '2026-02-03', + 'reason' => 'Vacation', + 'status' => 'approved', + ]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // Only Feb 2 and Feb 3 should be subtracted (2 working days in Feb) + $expectedCapacity = $workingDays - 2; + + expect($result['person_days'])->toBe((float) $expectedCapacity); +}); + +test('4.1.32 Holiday on weekend does not double-count', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // Create holiday on a Saturday (Feb 7, 2026) + Holiday::create(['date' => '2026-02-07', 'name' => 'Saturday Holiday', 'description' => 'Test']); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // Weekend holiday should not affect working days count + // (weekend is already excluded) + expect($result['person_days'])->toBe((float) $workingDays); +}); + +test('4.1.33 Full month capacity verification', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + // Feb 2026: 28 days, 8 weekend days = 20 working days + expect($workingDays)->toBe(20) + ->and($result['person_days'])->toBe(20.0) + ->and($result['hours'])->toBe(160) + ->and(count($result['details']))->toBe(20); +}); + +test('4.1.34 Negative scenario - PTO end before start is ignored', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + // Invalid PTO with end_date before start_date + // This should be caught by validation, but testing service resilience + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + + // Create PTO with invalid range (would normally be rejected by validation) + // Testing that service handles edge cases gracefully + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + + expect($result['person_days'])->toBe((float) $workingDays); +}); + +test('4.1.35 Team capacity sums all active members', function () { + $role = Role::factory()->create(); + + // Create 3 active members + $memberA = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + $memberB = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + $memberC = TeamMember::factory()->create(['role_id' => $role->id, 'active' => true]); + + // Create 1 inactive member (should not be counted) + TeamMember::factory()->create(['role_id' => $role->id, 'active' => false]); + + $service = app(CapacityService::class); + $result = $service->calculateTeamCapacity('2026-02'); + + // Should have exactly 3 members in result + expect(count($result['members']))->toBe(3); + + // Each member has 20 working days in Feb 2026 + $expectedTotalDays = 20 * 3; + expect($result['person_days'])->toBe((float) $expectedTotalDays); +}); + +test('4.1.36 Capacity details mark PTO correctly', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-12', + 'reason' => 'Vacation', + 'status' => 'approved', + ]); + + $service = app(CapacityService::class); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + $details = collect($result['details']); + + // PTO days should have is_pto = true and availability = 0 + $ptoDays = $details->whereIn('date', ['2026-02-10', '2026-02-11', '2026-02-12']); + + foreach ($ptoDays as $day) { + expect($day['is_pto'])->toBeTrue() + ->and($day['availability'])->toBe(0.0); + } + + // Non-PTO days should have is_pto = false and availability = 1 + $nonPtoDay = $details->firstWhere('date', '2026-02-02'); + expect($nonPtoDay['is_pto'])->toBeFalse() + ->and($nonPtoDay['availability'])->toBe(1.0); +}); + +test('4.1.40 PTO day can be overridden to half day availability', 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' => 'Vacation', + 'status' => 'approved', + ]); + + TeamMemberAvailability::factory() + ->forDate('2026-02-10') + ->availability(0.5) + ->create(['team_member_id' => $member->id]); + + $service = app(CapacityService::class); + $result = $service->calculateIndividualCapacity($member->id, '2026-02'); + $details = collect($result['details']); + + $ptoDay = $details->firstWhere('date', '2026-02-10'); + + expect($ptoDay['is_pto'])->toBeTrue() + ->and($ptoDay['availability'])->toBe(0.5) + ->and($result['person_days'])->toBe(19.5); +}); + +test('4.1.37 Cache is invalidated when PTO is approved', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + $service = app(CapacityService::class); + + // Calculate initial capacity (no PTO) + $result1 = $service->calculateIndividualCapacity($member->id, '2026-02'); + $workingDays = $service->calculateWorkingDays('2026-02'); + + expect($result1['person_days'])->toBe((float) $workingDays); + + // Create approved PTO + $pto = Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-12', + 'reason' => 'Vacation', + 'status' => 'approved', + ]); + + // Invalidate cache (simulating what should happen in controller) + $months = []; + $startMonth = \Carbon\Carbon::create($pto->start_date)->copy()->startOfMonth(); + $endMonth = \Carbon\Carbon::create($pto->end_date)->copy()->startOfMonth(); + while ($startMonth <= $endMonth) { + $months[] = $startMonth->format('Y-m'); + $startMonth->addMonth(); + } + $service->forgetCapacityCacheForTeamMember($member->id, $months); + + // Recalculate - should now have PTO applied + $result2 = $service->calculateIndividualCapacity($member->id, '2026-02'); + + expect($result2['person_days'])->toBe((float) ($workingDays - 3)); +}); + +test('4.1.38 PTO created directly with approved status needs cache invalidation', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + $service = app(CapacityService::class); + $workingDays = $service->calculateWorkingDays('2026-02'); + + // First, calculate capacity (this caches the result) + $result1 = $service->calculateIndividualCapacity($member->id, '2026-02'); + expect($result1['person_days'])->toBe((float) $workingDays); + + // Create PTO directly with approved status (bypassing controller) + Pto::create([ + 'team_member_id' => $member->id, + 'start_date' => '2026-02-10', + 'end_date' => '2026-02-11', + 'reason' => 'Direct approved PTO', + 'status' => 'approved', + ]); + + // Without cache invalidation, the cached result would still be returned + // This test verifies that fresh calculation includes the PTO + $service->forgetCapacityCacheForTeamMember($member->id, ['2026-02']); + $result2 = $service->calculateIndividualCapacity($member->id, '2026-02'); + + expect($result2['person_days'])->toBe((float) ($workingDays - 2)); +}); + +test('4.1.39 Holiday created after initial calculation needs cache invalidation', function () { + $role = Role::factory()->create(); + $member = TeamMember::factory()->create(['role_id' => $role->id]); + + $service = app(CapacityService::class); + + // Calculate initial capacity (no holiday) + $result1 = $service->calculateIndividualCapacity($member->id, '2026-02'); + $workingDays = $service->calculateWorkingDays('2026-02'); + + expect($result1['person_days'])->toBe(20.0); + + // Create holiday + Holiday::create(['date' => '2026-02-17', 'name' => 'Presidents Day', 'description' => 'Holiday']); + + // Invalidate cache + $service->forgetCapacityCacheForMonth('2026-02'); + + // Recalculate - should now have holiday excluded + $result2 = $service->calculateIndividualCapacity($member->id, '2026-02'); + + expect($result2['person_days'])->toBe(19.0); +}); diff --git a/frontend/src/lib/api/capacity.ts b/frontend/src/lib/api/capacity.ts index 87bbf105..66aad578 100644 --- a/frontend/src/lib/api/capacity.ts +++ b/frontend/src/lib/api/capacity.ts @@ -67,15 +67,20 @@ export async function getIndividualCapacity( export async function getTeamCapacity(month: string): Promise { const response = await api.get<{ month: string; - total_person_days: number; - total_hours: number; + total_person_days?: number; + total_hours?: number; + person_days?: number; + hours?: number; members: Array<{ id: string; name: string; person_days: number; hours: number }>; }>(`/capacity/team?month=${month}`); + const totalPersonDays = response.total_person_days ?? response.person_days ?? 0; + const totalHours = response.total_hours ?? response.hours ?? 0; + return { month: response.month, - total_person_days: response.total_person_days, - total_hours: response.total_hours, + total_person_days: totalPersonDays, + total_hours: totalHours, member_capacities: response.members.map((member) => ({ team_member_id: member.id, team_member_name: member.name, @@ -141,6 +146,10 @@ export async function approvePTO(id: string): Promise { return api.put(`/ptos/${id}/approve`); } +export async function deletePTO(id: string): Promise { + return api.delete(`/ptos/${id}`); +} + export interface SaveAvailabilityPayload { team_member_id: string; date: string; diff --git a/frontend/src/lib/components/capacity/CapacityCalendar.svelte b/frontend/src/lib/components/capacity/CapacityCalendar.svelte index cbbd4ec3..eaa911d3 100644 --- a/frontend/src/lib/components/capacity/CapacityCalendar.svelte +++ b/frontend/src/lib/components/capacity/CapacityCalendar.svelte @@ -71,23 +71,28 @@ import type { Capacity, Holiday, PTO } from '$lib/types/capacity'; const iso = toIso(current); const dayOfWeek = current.getDay(); const detail = detailsMap.get(iso); - const defaultAvailability = detail?.availability ?? (dayOfWeek % 6 === 0 ? 0 : 1); - const availability = overrides[iso] ?? defaultAvailability; - const effectiveHours = Math.round(availability * 8 * 10) / 10; + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; const isHoliday = holidayMap.has(iso); const holidayName = holidayMap.get(iso); + const isPto = ptoDates.has(iso) || !!detail?.is_pto; + const isBlocked = isWeekend || isHoliday; + const fallbackAvailability = isWeekend ? 0 : isPto ? 0 : 1; + const sourceAvailability = overrides[iso] ?? detail?.availability ?? fallbackAvailability; + const availability = isPto ? sourceAvailability : (isBlocked ? 0 : sourceAvailability); + const effectiveHours = Math.round(availability * 8 * 10) / 10; return { iso, day: i + 1, dayName: weekdayLabels[dayOfWeek], - isWeekend: dayOfWeek === 0 || dayOfWeek === 6, + isWeekend, isHoliday, holidayName, - isPto: ptoDates.has(iso) || detail?.is_pto, + isPto, + isBlocked, availability, effectiveHours, - defaultAvailability + defaultAvailability: fallbackAvailability }; }); @@ -147,12 +152,12 @@ import type { Capacity, Holiday, PTO } from '$lib/types/capacity'; {/each} diff --git a/frontend/src/lib/components/capacity/CapacitySummary.svelte b/frontend/src/lib/components/capacity/CapacitySummary.svelte index ae5f6ead..f89f1a9d 100644 --- a/frontend/src/lib/components/capacity/CapacitySummary.svelte +++ b/frontend/src/lib/components/capacity/CapacitySummary.svelte @@ -6,17 +6,35 @@ export let revenue: Revenue | null = null; export let teamMembers: TeamMember[] = []; + type MemberRow = TeamCapacity['member_capacities'][number] & { + role_label: string; + hourly_rate_label: string; + hourly_rate: number; + }; + + type RoleRow = { + role: string; + person_days: number; + hours: number; + hourly_rate: number; + }; + const currencyFormatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); - function formatCurrency(value) { - return currencyFormatter.format(value); + function toNumber(value: unknown): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + function formatCurrency(value: unknown): string { + return currencyFormatter.format(toNumber(value)); } $: memberMap = new Map(teamMembers.map((member) => [member.id, member])); - $: memberRows = (teamCapacity?.member_capacities ?? []).map((member) => { + $: memberRows = (teamCapacity?.member_capacities ?? []).map((member): MemberRow => { const details = memberMap.get(member.team_member_id); const hourlyRate = details ? Number(details.hourly_rate) : member.hourly_rate; const roleName = details?.role?.name ?? member.role; @@ -29,7 +47,7 @@ }; }); - $: roleRows = memberRows.reduce((acc, member) => { + $: roleRows = memberRows.reduce>((acc, member) => { const roleKey = member.role_label || 'Unknown'; if (!acc[roleKey]) { @@ -52,9 +70,9 @@

Team capacity

- {teamCapacity ? teamCapacity.total_person_days.toFixed(1) : '0.0'}d + {toNumber(teamCapacity?.total_person_days).toFixed(1)}d

-

{teamCapacity ? teamCapacity.total_hours : 0} hrs

+

{toNumber(teamCapacity?.total_hours)} hrs

Possible revenue

diff --git a/frontend/src/lib/components/capacity/PTOManager.svelte b/frontend/src/lib/components/capacity/PTOManager.svelte index b3a0bf0f..23b1a0b7 100644 --- a/frontend/src/lib/components/capacity/PTOManager.svelte +++ b/frontend/src/lib/components/capacity/PTOManager.svelte @@ -1,6 +1,5 @@