Backend: - Add ActualController with Cartesian product query (all projects × members) - Add ActualsService for variance calculations (∞% when actual>0, allocated=0) - Add ActualResource for API response formatting - Add migration for notes column on actuals table - Add global config for inactive project logging (ALLOW_ACTUALS_ON_INACTIVE_PROJECTS) - Implement filters: project_ids[], team_member_ids[], include_inactive, search - Add pagination support (25 per page default) - Register /api/actuals routes Frontend: - Create MultiSelect component with portal rendering (z-index fix for sidebar) - Compact trigger mode to prevent badge overflow - SSR-safe with browser guards - Keyboard navigation and accessibility - Create Pagination component with smart ellipsis - Rebuild actuals page with: - Full Cartesian matrix (shows all projects × members, not just allocations) - Filter section with project/member multi-select - Active filters display area with badge wrapping - URL persistence for all filter state - Month navigation with arrows - Variance display (GREEN ≤5%, YELLOW 5-20%, RED >20%, ∞% for zero allocation) - Read-only cells for inactive projects - Modal for incremental hours logging with notes - Add actualsService with unwrap:false to preserve pagination meta - Add comprehensive TypeScript types for grid items and pagination OpenSpec: - Update actuals-tracking spec with clarified requirements - Mark Capability 6: Actuals Tracking as complete in tasks.md - Update test count: 157 backend tests passing Fixes: - SSR error: Add browser guards to portal rendering - Z-index: Use portal to escape stacking context (sidebar z-30) - Filter overlap: Separate badge display from dropdown triggers - Member filter: Derive visible members from API response data - Pagination meta: Disable auto-unwrap to preserve response structure
443 lines
15 KiB
PHP
443 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Resources\ActualResource;
|
|
use App\Models\Actual;
|
|
use App\Models\Allocation;
|
|
use App\Models\Project;
|
|
use App\Models\TeamMember;
|
|
use App\Services\ActualsService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Pagination\LengthAwarePaginator;
|
|
|
|
class ActualController extends Controller
|
|
{
|
|
protected ActualsService $actualsService;
|
|
private const LOCKED_PROJECT_STATUSES = ['Done', 'Cancelled', 'Closed'];
|
|
|
|
public function __construct(ActualsService $actualsService)
|
|
{
|
|
$this->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;
|
|
}
|
|
}
|