diff --git a/backend/app/Http/Controllers/Api/ActualController.php b/backend/app/Http/Controllers/Api/ActualController.php new file mode 100644 index 00000000..478ced48 --- /dev/null +++ b/backend/app/Http/Controllers/Api/ActualController.php @@ -0,0 +1,442 @@ +actualsService = $actualsService; + } + + public function index(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'month' => ['required', 'date_format:Y-m'], + 'project_ids.*' => ['uuid'], + 'team_member_ids.*' => ['uuid'], + 'include_inactive' => ['boolean'], + 'page' => ['integer', 'min:1'], + 'per_page' => ['integer', 'min:1', 'max:250'], + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + $monthKey = $request->query('month'); + + try { + $monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth(); + } catch (\Throwable) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => ['month' => ['Invalid month format']], + ], 422); + } + + $includeInactive = filter_var($request->query('include_inactive', false), FILTER_VALIDATE_BOOLEAN); + $projectIdsFilter = array_filter((array) $request->query('project_ids', [])); + $teamMemberIdsFilter = array_filter((array) $request->query('team_member_ids', [])); + $searchTerm = $request->query('search'); + $searchTerm = is_string($searchTerm) ? trim($searchTerm) : null; + if ($searchTerm === '') { + $searchTerm = null; + } + + $projects = Project::with('status') + ->when($projectIdsFilter, fn ($query) => $query->whereIn('id', $projectIdsFilter)) + ->when(! $includeInactive, fn ($query) => $query->whereHas('status', fn ($query) => $query->whereNotIn('name', self::LOCKED_PROJECT_STATUSES))) + ->when($searchTerm, fn ($query) => $query->where(fn ($query) => $query->where('code', 'like', "%{$searchTerm}%")->orWhere('title', 'like', "%{$searchTerm}%"))) + ->orderBy('code') + ->get(); + + $teamMembers = TeamMember::query() + ->when($teamMemberIdsFilter, fn ($query) => $query->whereIn('id', $teamMemberIdsFilter)) + ->when(! $includeInactive, fn ($query) => $query->where('active', true)) + ->when($searchTerm, fn ($query) => $query->where('name', 'like', "%{$searchTerm}%")) + ->orderBy('name') + ->get(); + + $allocations = collect(); + $actuals = collect(); + + $projectIds = $projects->pluck('id')->all(); + $teamMemberIds = $teamMembers->pluck('id')->all(); + + if (! empty($projectIds) && ! empty($teamMemberIds)) { + $allocations = Allocation::query() + ->where('month', $monthDate) + ->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds)) + ->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds)) + ->get() + ->keyBy(fn (Allocation $allocation) => $allocation->project_id.'-'.$allocation->team_member_id); + + $actuals = Actual::query() + ->where('month', $monthDate) + ->when($projectIds, fn ($query) => $query->whereIn('project_id', $projectIds)) + ->when($teamMemberIds, fn ($query) => $query->whereIn('team_member_id', $teamMemberIds)) + ->get() + ->keyBy(fn (Actual $actual) => $actual->project_id.'-'.$actual->team_member_id); + } + + $teamMemberMap = $teamMembers->mapWithKeys(fn (TeamMember $member) => [$member->id => $this->formatTeamMember($member)])->all(); + $rows = []; + + foreach ($projects as $project) { + $projectData = $this->formatProject($project); + + foreach ($teamMembers as $teamMember) { + $key = $project->id.'-'.$teamMember->id; + $allocation = $allocations->get($key); + $actual = $actuals->get($key); + $hasAllocation = $allocation !== null; + $hasActual = $actual !== null; + $hasData = $hasAllocation || $hasActual; + + $allocatedHours = $hasAllocation ? (float) $allocation->allocated_hours : 0.0; + $actualHours = $hasActual ? (float) $actual->hours_logged : 0.0; + + $variancePercentage = null; + $varianceDisplay = null; + + if ($hasData) { + if ($allocatedHours <= 0.0) { + if ($actualHours > 0.0) { + $varianceDisplay = '∞%'; + } else { + $variancePercentage = 0.0; + } + } else { + $variancePercentage = (($actualHours - $allocatedHours) / $allocatedHours) * 100.0; + } + } + + $variance = $variancePercentage !== null ? round($variancePercentage, 1) : null; + $indicator = $this->getIndicator($variancePercentage, $hasData); + + $rows[] = [ + 'project_id' => $project->id, + 'project' => $projectData, + 'team_member_id' => $teamMember->id, + 'team_member' => $teamMemberMap[$teamMember->id], + 'month' => $monthKey, + 'allocated_hours' => $this->formatHours($allocatedHours), + 'actual_hours' => $this->formatHours($actualHours), + 'variance_percentage' => $variance, + 'variance_display' => $varianceDisplay, + 'variance_indicator' => $indicator, + 'notes' => $actual?->notes, + 'is_readonly' => $this->isProjectReadonly($project), + ]; + } + } + + $page = max(1, (int) $request->query('page', 1)); + $perPage = max(1, min(250, (int) $request->query('per_page', 25))); + $total = count($rows); + $currentPageItems = array_slice($rows, ($page - 1) * $perPage, $perPage); + + $paginator = new LengthAwarePaginator( + $currentPageItems, + $total, + $perPage, + $page, + ['path' => LengthAwarePaginator::resolveCurrentPath(), 'query' => $request->query()] + ); + + return response()->json([ + 'data' => $paginator->items(), + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + 'last_page' => $paginator->lastPage(), + 'filters' => [ + 'month' => $monthKey, + 'include_inactive' => $includeInactive, + 'search' => $searchTerm, + ], + ], + ]); + } + + public function store(Request $request): JsonResponse + { + $validator = Validator::make($request->all(), [ + 'project_id' => 'required|uuid|exists:projects,id', + 'team_member_id' => 'required|uuid|exists:team_members,id', + 'month' => 'required|date_format:Y-m', + 'hours' => 'required|numeric|min:0', + 'notes' => 'nullable|string|max:1000', + ]); + + $validator->after(function () use ($request, $validator) { + $month = $request->input('month'); + + if (! $month) { + return; + } + + try { + $monthDate = Carbon::createFromFormat('Y-m', $month)->startOfMonth(); + } catch (\Exception) { + return; + } + + if ($monthDate->gt(Carbon::now()->startOfMonth())) { + $validator->errors()->add('month', 'Cannot log hours for future months'); + } + }); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + $project = Project::with('status')->find($request->input('project_id')); + + if ($project && $project->status && ! $this->actualsService->canLogToInactiveProjects()) { + $inactiveStatuses = $this->actualsService->getInactiveProjectStatuses(); + + if (in_array($project->status->name, $inactiveStatuses, true)) { + return response()->json([ + 'message' => 'Cannot log hours to completed projects', + 'errors' => [ + 'project_id' => ['Cannot log hours to completed projects'], + ], + ], 422); + } + } + + $monthKey = $request->input('month'); + $monthDate = Carbon::createFromFormat('Y-m', $monthKey)->startOfMonth()->toDateString(); + $hours = (float) $request->input('hours'); + $notes = $request->input('notes'); + + $existing = Actual::where('project_id', $request->input('project_id')) + ->where('team_member_id', $request->input('team_member_id')) + ->where('month', $monthDate) + ->first(); + + $status = 201; + + if ($existing) { + $existing->hours_logged = (float) $existing->hours_logged + $hours; + + if ($notes) { + $existing->notes = $this->appendNotes($existing->notes, $notes); + } + + $existing->save(); + $actual = $existing; + $status = 200; + } else { + $actual = Actual::create([ + 'project_id' => $request->input('project_id'), + 'team_member_id' => $request->input('team_member_id'), + 'month' => $monthDate, + 'hours_logged' => $hours, + 'notes' => $notes, + ]); + } + + $actual->load(['project.status', 'teamMember']); + $this->hydrateVariance($actual, $monthKey); + + return response()->json([ + 'data' => (new ActualResource($actual))->resolve($request), + ], $status); + } + + public function show(string $id): JsonResponse + { + $actual = Actual::with(['project.status', 'teamMember'])->find($id); + + if (! $actual) { + return response()->json([ + 'message' => 'Actual not found', + ], 404); + } + + $monthKey = $actual->month?->format('Y-m'); + + if ($monthKey) { + $this->hydrateVariance($actual, $monthKey); + } + + return $this->wrapResource(new ActualResource($actual)); + } + + public function update(Request $request, string $id): JsonResponse + { + $actual = Actual::find($id); + + if (! $actual) { + return response()->json([ + 'message' => 'Actual not found', + ], 404); + } + + $validator = Validator::make($request->all(), [ + 'hours' => 'required|numeric|min:0', + 'notes' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed', + 'errors' => $validator->errors(), + ], 422); + } + + $actual->hours_logged = (float) $request->input('hours'); + + if ($request->exists('notes')) { + $actual->notes = $request->input('notes'); + } + + $actual->save(); + $actual->load(['project.status', 'teamMember']); + + $monthKey = $actual->month?->format('Y-m'); + + if ($monthKey) { + $this->hydrateVariance($actual, $monthKey); + } + + return $this->wrapResource(new ActualResource($actual)); + } + + public function destroy(string $id): JsonResponse + { + $actual = Actual::find($id); + + if (! $actual) { + return response()->json([ + 'message' => 'Actual not found', + ], 404); + } + + $actual->delete(); + + return response()->json([ + 'message' => 'Actual deleted successfully', + ]); + } + + private function hydrateVariance(Actual $actual, string $month): void + { + $variance = $this->actualsService->calculateVariance( + $actual->project_id, + $actual->team_member_id, + $month + ); + + $actual->allocated_hours = $variance['allocated']; + $actual->variance_percentage = $variance['variance_percentage']; + $actual->variance_indicator = $variance['indicator']; + } + + private function formatProject(Project $project): array + { + $status = $project->status; + + return [ + 'id' => $project->id, + 'code' => $project->code, + 'title' => $project->title, + 'status' => $status ? [ + 'id' => $status->id, + 'name' => $status->name, + 'is_active' => (bool) $status->is_active, + ] : null, + 'is_active' => $this->isProjectActive($project), + ]; + } + + private function formatTeamMember(TeamMember $teamMember): array + { + return [ + 'id' => $teamMember->id, + 'name' => $teamMember->name, + 'is_active' => (bool) $teamMember->active, + ]; + } + + private function isProjectActive(Project $project): bool + { + return ! $this->isProjectReadonly($project); + } + + private function isProjectReadonly(Project $project): bool + { + $status = $project->status; + + if (! $status) { + return false; + } + + return in_array($status->name, self::LOCKED_PROJECT_STATUSES, true); + } + + private function formatHours(float $hours): string + { + return number_format($hours, 2, '.', ''); + } + + private function getIndicator(?float $variancePercentage, bool $hasData): string + { + if (! $hasData) { + return 'gray'; + } + + if ($variancePercentage === null) { + return 'red'; + } + + $absolute = abs($variancePercentage); + + if ($absolute <= 5) { + return 'green'; + } + + if ($absolute <= 20) { + return 'yellow'; + } + + return 'red'; + } + + private function appendNotes(?string $existing, string $note): string + { + $entry = now()->format('Y-m-d H:i').': '.$note; + + if ($existing) { + return $existing."\n".$entry; + } + + return $entry; + } +} diff --git a/backend/app/Http/Resources/ActualResource.php b/backend/app/Http/Resources/ActualResource.php new file mode 100644 index 00000000..00a03563 --- /dev/null +++ b/backend/app/Http/Resources/ActualResource.php @@ -0,0 +1,27 @@ + $this->id, + 'project_id' => $this->project_id, + 'team_member_id' => $this->team_member_id, + 'month' => $this->month?->format('Y-m'), + 'hours_logged' => $this->formatDecimal($this->hours_logged), + 'notes' => $this->notes, + 'variance' => [ + 'allocated_hours' => isset($this->allocated_hours) ? $this->formatDecimal($this->allocated_hours) : null, + 'variance_percentage' => $this->variance_percentage !== null ? round($this->variance_percentage, 1) : null, + 'variance_indicator' => $this->variance_indicator ?? 'gray', + ], + 'project' => $this->whenLoaded('project', fn () => new ProjectResource($this->project)), + 'team_member' => $this->whenLoaded('teamMember', fn () => new TeamMemberResource($this->teamMember)), + ]; + } +} diff --git a/backend/app/Models/Actual.php b/backend/app/Models/Actual.php index 4505cf02..45d0ee09 100644 --- a/backend/app/Models/Actual.php +++ b/backend/app/Models/Actual.php @@ -2,16 +2,18 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Concerns\HasUuids; class Actual extends Model { use HasFactory, HasUuids; protected $primaryKey = 'id'; + public $incrementing = false; + protected $keyType = 'string'; protected $table = 'actuals'; @@ -21,6 +23,7 @@ class Actual extends Model 'team_member_id', 'month', 'hours_logged', + 'notes', ]; protected $casts = [ diff --git a/backend/app/Services/ActualsService.php b/backend/app/Services/ActualsService.php new file mode 100644 index 00000000..3c9317ee --- /dev/null +++ b/backend/app/Services/ActualsService.php @@ -0,0 +1,62 @@ +where('team_member_id', $teamMemberId) + ->where('month', $monthDate) + ->sum('allocated_hours'); + + $actual = (float) Actual::where('project_id', $projectId) + ->where('team_member_id', $teamMemberId) + ->where('month', $monthDate) + ->sum('hours_logged'); + + if ($allocated <= 0) { + $variancePercentage = $actual === 0 ? 0.0 : 100.0; + } else { + $variancePercentage = (($actual - $allocated) / $allocated) * 100; + } + + return [ + 'allocated' => $allocated, + 'actual' => $actual, + 'variance_percentage' => $variancePercentage, + 'indicator' => $this->getIndicator($variancePercentage), + ]; + } + + public function getInactiveProjectStatuses(): array + { + return ['Done', 'Cancelled']; + } + + public function canLogToInactiveProjects(): bool + { + return (bool) config('actuals.allow_actuals_on_inactive_projects', false); + } + + private function getIndicator(float $variancePercentage): string + { + $absolute = abs($variancePercentage); + + if ($absolute <= 5) { + return 'green'; + } + + if ($absolute <= 20) { + return 'yellow'; + } + + return 'red'; + } +} diff --git a/backend/config/actuals.php b/backend/config/actuals.php new file mode 100644 index 00000000..49b69a03 --- /dev/null +++ b/backend/config/actuals.php @@ -0,0 +1,5 @@ + (bool) env('ALLOW_ACTUALS_ON_INACTIVE_PROJECTS', false), +]; diff --git a/backend/database/migrations/2026_03_09_003222_add_notes_to_actuals_table.php b/backend/database/migrations/2026_03_09_003222_add_notes_to_actuals_table.php new file mode 100644 index 00000000..68c09453 --- /dev/null +++ b/backend/database/migrations/2026_03_09_003222_add_notes_to_actuals_table.php @@ -0,0 +1,33 @@ +text('notes')->nullable()->after('hours_logged'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('actuals', function (Blueprint $table) { + if (Schema::hasColumn('actuals', 'notes')) { + $table->dropColumn('notes'); + } + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index ee4ae053..3cb1cd78 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,5 +1,6 @@ group(function () { Route::put('/ptos/{id}/approve', [PtoController::class, 'approve']); // Allocations + Route::apiResource('actuals', ActualController::class); Route::apiResource('allocations', AllocationController::class); Route::post('/allocations/bulk', [AllocationController::class, 'bulkStore']); diff --git a/frontend/src/lib/components/common/MultiSelect.svelte b/frontend/src/lib/components/common/MultiSelect.svelte new file mode 100644 index 00000000..7cbfa74b --- /dev/null +++ b/frontend/src/lib/components/common/MultiSelect.svelte @@ -0,0 +1,265 @@ + + +
No results
+ {:else} + {#each filteredOptions as option, index (getValue(option))} + + {/each} + {/if} ++ Showing {getStartItem()}-{getEndItem()} of {totalItems} +
+ +Showing 0-0 of 0
+{/if} diff --git a/frontend/src/lib/components/common/index.ts b/frontend/src/lib/components/common/index.ts index 62169cba..a4569760 100644 --- a/frontend/src/lib/components/common/index.ts +++ b/frontend/src/lib/components/common/index.ts @@ -4,3 +4,4 @@ export { default as FilterBar } from './FilterBar.svelte'; export { default as EmptyState } from './EmptyState.svelte'; export { default as LoadingState } from './LoadingState.svelte'; export { default as StatCard } from './StatCard.svelte'; +export { default as Pagination } from './Pagination.svelte'; diff --git a/frontend/src/lib/services/actualsService.ts b/frontend/src/lib/services/actualsService.ts new file mode 100644 index 00000000..6cdebff8 --- /dev/null +++ b/frontend/src/lib/services/actualsService.ts @@ -0,0 +1,64 @@ +import { api } from './api'; +import type { + Actual, + CreateActualRequest, + UpdateActualRequest, + ActualGridItem, + ActualsPaginatedResponse, + ActualsQueryParams +} from '$lib/types/actuals'; + +function buildQuery(month?: string, filters?: ActualsQueryParams): string { + const params = new URLSearchParams(); + + if (month) { + params.set('month', month); + } + + if (filters) { + if (filters.page) { + params.set('page', String(filters.page)); + } + + if (filters.per_page) { + params.set('per_page', String(filters.per_page)); + } + + filters.project_ids?.forEach(id => { + if (id) params.append('project_ids[]', id); + }); + + filters.team_member_ids?.forEach(id => { + if (id) params.append('team_member_ids[]', id); + }); + + if (filters.include_inactive) { + params.set('include_inactive', 'true'); + } + + if (filters.search) { + params.set('search', filters.search); + } + } + + const query = params.toString(); + return query ? `?${query}` : ''; +} + +export const actualsService = { + getAll: (month?: string, filters?: ActualsQueryParams) => { + const query = buildQuery(month, filters); + return api.get| Project | + {#each visibleMembers as member (member.id)} +{member.name} | + {/each} +Untracked | +
|---|---|---|
| + {project.code} - {project.title} + | + {#each rowMembers as cell, index (visibleMembers[index]?.id ?? index)} + !cell.is_readonly && openModal(cell)}
+ >
+ {cell.allocated_hours}h alloc
+ {cell.actual_hours}h actual
+
+ {formatVarianceValue(cell.variance_percentage, cell.variance_display)}
+
+ |
+ {/each}
+ !rowUntracked.is_readonly && openModal(rowUntracked)}
+ >
+ {rowUntracked.allocated_hours}h alloc
+ {rowUntracked.actual_hours}h actual
+
+ {formatVarianceValue(rowUntracked.variance_percentage, rowUntracked.variance_display)}
+
+ |
+