Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions backend/app/Http/Controllers/Api/V1/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,9 @@ private function employeeDashboard(User $user, Request $request): JsonResponse
: null;

// Week range uses the user's timezone so the boundaries align with their calendar week
$weekStart = Carbon::now($tz)->startOfWeek(); // Monday 00:00 local
$weekEnd = Carbon::now($tz)->endOfWeek(); // Sunday 23:59 local
$weekOffset = (int) $request->input('week_offset', 0);
$weekStart = Carbon::now($tz)->startOfWeek()->addWeeks($weekOffset);
$weekEnd = Carbon::now($tz)->endOfWeek()->addWeeks($weekOffset);
[$weekStartUtc, $weekEndUtc] = TimezoneAwareDateRange::toUtcBounds(
$weekStart->toDateString(),
$weekEnd->toDateString(),
Expand Down Expand Up @@ -334,6 +335,9 @@ private function employeeDashboard(User $user, Request $request): JsonResponse
'weekly_hours_target' => $weeklyTarget,
'daily_breakdown' => $dailyBreakdown,
'activity_percentage' => $activityPercentage,
'week_offset' => $weekOffset,
'week_start' => $weekStart->toDateString(),
'week_end' => $weekEnd->toDateString(),
'date_from' => $responseDateFrom,
'date_to' => $responseDateTo,
]);
Expand Down
48 changes: 38 additions & 10 deletions backend/app/Http/Controllers/Api/V1/TimeEntryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ public function update(Request $request, string $id): JsonResponse
$request->validate([
'project_id' => 'nullable|uuid',
'task_id' => 'nullable|uuid',
'task_name' => 'nullable|string|max:255',
'started_at' => 'sometimes|date',
'ended_at' => 'nullable|date|after:started_at',
'notes' => 'nullable|string|max:1000',
Expand All @@ -129,7 +130,33 @@ public function update(Request $request, string $id): JsonResponse
$request->user()->organization->tasks()->findOrFail($request->task_id);
}

$data = $request->only(['project_id', 'task_id', 'started_at', 'ended_at', 'notes']);
// Auto-create task from free-text name if no task_id provided
$taskId = $request->task_id;
if (! $taskId && ! empty($request->task_name)) {
$projectId = $request->project_id ?? $entry->project_id;
if ($projectId) {
$org = $request->user()->organization;
$task = \App\Models\Task::firstOrCreate(
['organization_id' => $org->id, 'project_id' => $projectId, 'name' => trim($request->task_name)],
['created_by' => $request->user()->id]
);
$taskId = $task->id;
}
}

$data = $request->only(['project_id', 'started_at', 'ended_at', 'notes']);
if ($request->has('task_id') || $request->has('task_name')) {
$data['task_id'] = $taskId;
}

// Check if time fields actually changed before we apply the update.
$timeChanged = false;
if ($request->has('started_at') && $entry->started_at->toISOString() !== \Carbon\Carbon::parse($request->started_at)->toISOString()) {
$timeChanged = true;
}
if ($request->has('ended_at') && ($entry->ended_at?->toISOString() ?? null) !== ($request->ended_at ? \Carbon\Carbon::parse($request->ended_at)->toISOString() : null)) {
$timeChanged = true;
}

// Capture pre-edit approval state so we know whether cached report totals
// (which only include approved entries) need busting after the mutation.
Expand All @@ -144,15 +171,12 @@ public function update(Request $request, string $id): JsonResponse
]);
}

// Approval integrity (HIGH): a self-edit of a manual entry must not keep a
// stale 'approved'/'rejected' status when the editor lacks approve
// authority — the (possibly inflated) hours must not reach billable/payroll
// totals without a fresh re-approval. mustResetOnEdit() preserves the
// create() auto-approve-self rule so an org/project-scoped approver editing
// their OWN entry is NOT trapped in a queue only they could clear.
// Tracked/idle entries are not part of the approval workflow — leave them.
// Only reset approval when TIME fields changed — task/notes edits update
// immediately without re-approval.
$approvalReset = false;
if (
$entry->type === 'manual'
$timeChanged
&& $entry->type === 'manual'
&& in_array($entry->approval_status, ['approved', 'rejected'], true)
&& $this->manualTimeEntries->mustResetOnEdit($request->user(), $entry)
) {
Expand All @@ -163,6 +187,7 @@ public function update(Request $request, string $id): JsonResponse
'approved_at' => null,
'rejection_reason' => null,
]);
$approvalReset = true;
}

// Cache staleness (LOW 1): editing a manual entry, or any entry that was
Expand All @@ -172,7 +197,10 @@ public function update(Request $request, string $id): JsonResponse
app(ReportService::class)->flushForOrg($entry->organization_id);
}

return response()->json(['entry' => $entry->fresh()->load(['project', 'task'])]);
return response()->json([
'entry' => $entry->fresh()->load(['project', 'task']),
'approval_reset' => $approvalReset,
]);
}

// TIME-07: Delete entry
Expand Down
1 change: 1 addition & 0 deletions backend/app/Http/Requests/StoreTimeEntryRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function rules(): array
],
'project_id' => ['nullable', 'uuid'],
'task_id' => ['nullable', 'uuid'],
'task_name' => ['nullable', 'string', 'max:255'],
'started_at' => ['required', 'date', 'before_or_equal:now'],
'ended_at' => ['required', 'date', 'after:started_at'],
'notes' => ['nullable', 'string', 'max:1000'],
Expand Down
15 changes: 13 additions & 2 deletions backend/app/Services/ManualTimeEntryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Services;

use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Carbon\Carbon;
Expand Down Expand Up @@ -84,15 +85,25 @@ public function create(User $actor, array $data): TimeEntry
$org->tasks()->findOrFail($data['task_id']);
}

// Auto-create task from free-text name if no task_id provided
$taskId = $data['task_id'] ?? null;
if (! $taskId && ! empty($data['task_name']) && ! empty($data['project_id'])) {
$task = Task::firstOrCreate(
['organization_id' => $org->id, 'project_id' => $data['project_id'], 'name' => trim($data['task_name'])],
['created_by' => $actor->id]
);
$taskId = $task->id;
}

$startedAt = Carbon::parse($data['started_at']);
$endedAt = Carbon::parse($data['ended_at']);

return DB::transaction(function () use ($actor, $targetUser, $org, $data, $startedAt, $endedAt, $approved) {
return DB::transaction(function () use ($actor, $targetUser, $org, $data, $startedAt, $endedAt, $approved, $taskId) {
$entry = TimeEntry::create([
'organization_id' => $org->id,
'user_id' => $targetUser->id,
'project_id' => $data['project_id'] ?? null,
'task_id' => $data['task_id'] ?? null,
'task_id' => $taskId,
'started_at' => $startedAt,
'ended_at' => $endedAt,
'duration_seconds' => (int) abs($endedAt->diffInSeconds($startedAt)),
Expand Down
Loading
Loading