fix(capacity): Fix three defects - caching, save, filters
1. Slow Team Member Dropdown - Fixed - Added cached team members store with 5-minute TTL - Dropdown now loads instantly on subsequent visits 2. Error Preventing Capacity Save - Fixed - Added saveAvailability API endpoint - Added backend service method to persist availability overrides - Added proper error handling and success feedback - Cache invalidation on save 3. Filters Not Working - Fixed - Fixed PTOManager to use shared selectedMemberId - Filters now react to team member selection Test Results: - Backend: 76 passed ✅ - Frontend Unit: 10 passed ✅ - E2E: 130 passed, 24 skipped ✅ Refs: openspec/changes/headroom-foundation
This commit is contained in:
@@ -6,10 +6,12 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\CapacityResource;
|
||||
use App\Http\Resources\RevenueResource;
|
||||
use App\Http\Resources\TeamCapacityResource;
|
||||
use App\Http\Resources\TeamMemberAvailabilityResource;
|
||||
use App\Models\TeamMember;
|
||||
use App\Services\CapacityService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CapacityController extends Controller
|
||||
{
|
||||
@@ -157,4 +159,40 @@ class CapacityController extends Controller
|
||||
'member_revenues' => $memberRevenues,
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Team Member Availability
|
||||
*
|
||||
* Persist a daily availability override and refresh cached capacity totals.
|
||||
*
|
||||
* @group Capacity Planning
|
||||
*
|
||||
* @bodyParam team_member_id string required The team member UUID.
|
||||
* @bodyParam date string required The date for the availability override (YYYY-MM-DD).
|
||||
* @bodyParam availability numeric required The availability value (0, 0.5, 1.0).
|
||||
*
|
||||
* @response 201 {
|
||||
* "data": {
|
||||
* "team_member_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
* "date": "2026-02-03",
|
||||
* "availability": 0.5
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
public function saveAvailability(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'team_member_id' => 'required|exists:team_members,id',
|
||||
'date' => 'required|date_format:Y-m-d',
|
||||
'availability' => ['required', 'numeric', Rule::in([0, 0.5, 1])],
|
||||
]);
|
||||
|
||||
$entry = $this->capacityService->upsertTeamMemberAvailability(
|
||||
$data['team_member_id'],
|
||||
$data['date'],
|
||||
(float) $data['availability']
|
||||
);
|
||||
|
||||
return $this->wrapResource(new TeamMemberAvailabilityResource($entry), 201);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\TeamMemberAvailability;
|
||||
|
||||
class TeamMemberAvailabilityResource extends BaseResource
|
||||
{
|
||||
public function toArray($request): array
|
||||
{
|
||||
/** @var TeamMemberAvailability $availability */
|
||||
$availability = $this->resource;
|
||||
|
||||
return [
|
||||
'team_member_id' => $availability->team_member_id,
|
||||
'date' => $availability->date?->toDateString(),
|
||||
'availability' => (float) $availability->availability,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,21 @@ class CapacityService
|
||||
->mapWithKeys(fn (TeamMemberAvailability $entry) => [$entry->date->toDateString() => (float) $entry->availability]);
|
||||
}
|
||||
|
||||
public function upsertTeamMemberAvailability(string $teamMemberId, string $date, float $availability): TeamMemberAvailability
|
||||
{
|
||||
$entry = TeamMemberAvailability::updateOrCreate(
|
||||
['team_member_id' => $teamMemberId, 'date' => $date],
|
||||
['availability' => $availability]
|
||||
);
|
||||
|
||||
$month = Carbon::createFromFormat('Y-m-d', $date)->format('Y-m');
|
||||
|
||||
$this->forgetCapacityCacheForTeamMember($teamMemberId, [$month]);
|
||||
$this->forgetCapacityCacheForMonth($month);
|
||||
|
||||
return $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a CarbonPeriod for the given month.
|
||||
*/
|
||||
|
||||
@@ -45,6 +45,7 @@ Route::middleware(JwtAuth::class)->group(function () {
|
||||
Route::get('/capacity', [CapacityController::class, 'individual']);
|
||||
Route::get('/capacity/team', [CapacityController::class, 'team']);
|
||||
Route::get('/capacity/revenue', [CapacityController::class, 'revenue']);
|
||||
Route::post('/capacity/availability', [CapacityController::class, 'saveAvailability']);
|
||||
|
||||
// Holidays
|
||||
Route::get('/holidays', [HolidayController::class, 'index']);
|
||||
|
||||
@@ -149,6 +149,31 @@ test('4.1.16 GET /api/capacity/revenue calculates possible revenue', function ()
|
||||
expect(round($response->json('data.possible_revenue'), 2))->toBe(round($expectedRevenue, 2));
|
||||
});
|
||||
|
||||
test('4.1.25 POST /api/capacity/availability saves entry', function () {
|
||||
$token = loginAsManager($this);
|
||||
$role = Role::factory()->create();
|
||||
$member = TeamMember::factory()->create(['role_id' => $role->id]);
|
||||
|
||||
$payload = [
|
||||
'team_member_id' => $member->id,
|
||||
'date' => '2026-02-03',
|
||||
'availability' => 0.5,
|
||||
];
|
||||
|
||||
$response = $this->postJson('/api/capacity/availability', $payload, [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('data.date', '2026-02-03');
|
||||
|
||||
assertDatabaseHas('team_member_daily_availabilities', [
|
||||
'team_member_id' => $member->id,
|
||||
'date' => '2026-02-03 00:00:00',
|
||||
'availability' => 0.5,
|
||||
]);
|
||||
});
|
||||
|
||||
test('4.1.17 POST /api/holidays creates holiday', function () {
|
||||
$token = loginAsManager($this);
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
Holiday,
|
||||
PTO,
|
||||
Revenue,
|
||||
TeamCapacity
|
||||
TeamCapacity,
|
||||
TeamMemberAvailability
|
||||
} from '$lib/types/capacity';
|
||||
|
||||
export interface CreateHolidayData {
|
||||
@@ -139,3 +140,15 @@ export async function createPTO(data: CreatePTOData): Promise<PTO> {
|
||||
export async function approvePTO(id: string): Promise<PTO> {
|
||||
return api.put<PTO>(`/ptos/${id}/approve`);
|
||||
}
|
||||
|
||||
export interface SaveAvailabilityPayload {
|
||||
team_member_id: string;
|
||||
date: string;
|
||||
availability: 0 | 0.5 | 1;
|
||||
}
|
||||
|
||||
export async function saveAvailability(
|
||||
data: SaveAvailabilityPayload
|
||||
): Promise<TeamMemberAvailability> {
|
||||
return api.post<TeamMemberAvailability>('/capacity/availability', data);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
|
||||
export let teamMembers: TeamMember[] = [];
|
||||
export let month: string;
|
||||
|
||||
let selectedMemberId = '';
|
||||
export let selectedMemberId = '';
|
||||
let submitting = false;
|
||||
let actionLoadingId: string | null = null;
|
||||
let error: string | null = null;
|
||||
|
||||
34
frontend/src/lib/stores/teamMembers.ts
Normal file
34
frontend/src/lib/stores/teamMembers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { TeamMember } from '$lib/services/teamMemberService';
|
||||
import { teamMemberService } from '$lib/services/teamMemberService';
|
||||
|
||||
interface CachedTeamMembers {
|
||||
data: TeamMember[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
function createTeamMembersStore() {
|
||||
const { subscribe, set } = writable<TeamMember[]>([]);
|
||||
let cache: CachedTeamMembers | null = null;
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
async load() {
|
||||
if (cache && Date.now() - cache.timestamp < CACHE_TTL) {
|
||||
return cache.data;
|
||||
}
|
||||
|
||||
const data = await teamMemberService.getAll();
|
||||
cache = { data, timestamp: Date.now() };
|
||||
set(data);
|
||||
return data;
|
||||
},
|
||||
invalidate() {
|
||||
cache = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const teamMembersStore = createTeamMembersStore();
|
||||
@@ -6,7 +6,6 @@
|
||||
import CapacitySummary from '$lib/components/capacity/CapacitySummary.svelte';
|
||||
import HolidayManager from '$lib/components/capacity/HolidayManager.svelte';
|
||||
import PTOManager from '$lib/components/capacity/PTOManager.svelte';
|
||||
import { teamMemberService } from '$lib/services/teamMemberService';
|
||||
import { selectedPeriod } from '$lib/stores/period';
|
||||
import {
|
||||
holidaysStore,
|
||||
@@ -18,9 +17,9 @@
|
||||
revenueStore,
|
||||
teamCapacityStore
|
||||
} from '$lib/stores/capacity';
|
||||
import { getIndividualCapacity } from '$lib/api/capacity';
|
||||
import { teamMembersStore } from '$lib/stores/teamMembers';
|
||||
import { getIndividualCapacity, saveAvailability } from '$lib/api/capacity';
|
||||
import type { Capacity } from '$lib/types/capacity';
|
||||
import type { TeamMember } from '$lib/services/teamMemberService';
|
||||
|
||||
type TabKey = 'calendar' | 'summary' | 'holidays' | 'pto';
|
||||
|
||||
@@ -32,26 +31,20 @@
|
||||
];
|
||||
|
||||
let activeTab: TabKey = 'calendar';
|
||||
let teamMembers: TeamMember[] = [];
|
||||
let selectedMemberId = '';
|
||||
let individualCapacity: Capacity | null = null;
|
||||
let loadingIndividual = false;
|
||||
let calendarError: string | null = null;
|
||||
let availabilitySaving = false;
|
||||
let availabilityError: string | null = null;
|
||||
|
||||
onMount(async () => {
|
||||
await loadTeamMembers();
|
||||
});
|
||||
|
||||
async function loadTeamMembers() {
|
||||
try {
|
||||
teamMembers = await teamMemberService.getAll();
|
||||
if (!selectedMemberId && teamMembers.length) {
|
||||
selectedMemberId = teamMembers[0].id;
|
||||
}
|
||||
await teamMembersStore.load();
|
||||
} catch (error) {
|
||||
console.error('Unable to load team members', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function refreshIndividualCapacity(memberId: string, period: string) {
|
||||
if (
|
||||
@@ -76,6 +69,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($teamMembersStore.length) {
|
||||
const exists = $teamMembersStore.some((member) => member.id === selectedMemberId);
|
||||
if (!selectedMemberId || !exists) {
|
||||
selectedMemberId = $teamMembersStore[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAvailabilityChange(event: CustomEvent<{ date: string; availability: number }>) {
|
||||
const { date, availability } = event.detail;
|
||||
const period = $selectedPeriod;
|
||||
|
||||
if (!selectedMemberId || !period) {
|
||||
return;
|
||||
}
|
||||
|
||||
availabilitySaving = true;
|
||||
availabilityError = null;
|
||||
|
||||
try {
|
||||
await saveAvailability({
|
||||
team_member_id: selectedMemberId,
|
||||
date,
|
||||
availability
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
refreshIndividualCapacity(selectedMemberId, period),
|
||||
loadTeamCapacity(period),
|
||||
loadRevenue(period)
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Failed to save availability', error);
|
||||
availabilityError = 'Unable to save availability changes.';
|
||||
} finally {
|
||||
availabilitySaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($selectedPeriod) {
|
||||
loadTeamCapacity($selectedPeriod);
|
||||
loadRevenue($selectedPeriod);
|
||||
@@ -121,7 +152,7 @@
|
||||
bind:value={selectedMemberId}
|
||||
aria-label="Select team member"
|
||||
>
|
||||
{#each teamMembers as member}
|
||||
{#each $teamMembersStore as member}
|
||||
<option value={member.id}>{member.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -131,6 +162,17 @@
|
||||
<div class="alert alert-error text-sm">{calendarError}</div>
|
||||
{/if}
|
||||
|
||||
{#if availabilityError}
|
||||
<div class="alert alert-error text-sm">{availabilityError}</div>
|
||||
{/if}
|
||||
|
||||
{#if availabilitySaving}
|
||||
<div class="flex items-center gap-2 text-sm text-base-content/60">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Saving availability...
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !selectedMemberId}
|
||||
<p class="text-sm text-base-content/60">Select a team member to view the calendar.</p>
|
||||
{:else if loadingIndividual}
|
||||
@@ -144,6 +186,7 @@
|
||||
capacity={individualCapacity}
|
||||
holidays={$holidaysStore}
|
||||
ptos={$ptosStore}
|
||||
on:availabilitychange={handleAvailabilityChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -151,12 +194,16 @@
|
||||
<CapacitySummary
|
||||
teamCapacity={$teamCapacityStore}
|
||||
revenue={$revenueStore}
|
||||
{teamMembers}
|
||||
teamMembers={$teamMembersStore}
|
||||
/>
|
||||
{:else if activeTab === 'holidays'}
|
||||
<HolidayManager month={$selectedPeriod} holidays={$holidaysStore} />
|
||||
{:else}
|
||||
<PTOManager {teamMembers} month={$selectedPeriod} />
|
||||
<PTOManager
|
||||
teamMembers={$teamMembersStore}
|
||||
month={$selectedPeriod}
|
||||
bind:selectedMemberId={selectedMemberId}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user