@extends('layouts.app') @php // One duration format across every report and export — see App\Support\Duration. $hm = fn ($sec) => \App\Support\Duration::hhmmOrDash((int) $sec); // The free-time rules live in App\Support\FreeTime — Live People colours its // active-hours badge by the same figure, and the two must not drift. $freeMinutes = fn ($sec) => \App\Support\FreeTime::minutes((int) $sec); $hmSigned = fn ($sec) => \App\Support\FreeTime::label((int) $sec); $freeClass = fn ($sec) => \App\Support\FreeTime::textClass((int) $sec); @endphp @section('header')
Reports

Timesheet

@endsection @section('content') {{-- Filters --}}
{{-- Display only. The real values ride in the hidden inputs below, so the server contract stays ?from=&to= and the form still submits the current range if JS never runs. --}}
{{-- type=button: inside the filter form, a default submit would reload the page instead of exporting. --}}
@if ($q !== '' || $status !== \App\Support\EmployeeStatus::ACTIVE || request()->hasAny(['from', 'to'])) @endif
{{-- Every column sorts except the first: in grouped mode it holds a span rather than a point in time, and the server has already ordered the rows sensibly for both modes. --}} @forelse ($rows as $r) {{-- data-csv carries the exact export value per cell. The first two columns render more than they export (a Working badge, the clock range, employee id, shift), so scraping their text would drag all of that into the file. --}} @if ($grouped) @php // One shift in a wide range still reads as a single // date — "04 Aug – 04 Aug 2026" is just noise. $span = $r['first_date'] === $r['last_date'] ? \Illuminate\Support\Carbon::parse($r['first_date'])->format('d M Y') : \Illuminate\Support\Carbon::parse($r['first_date'])->format('d M') . ' – ' . \Illuminate\Support\Carbon::parse($r['last_date'])->format('d M Y'); @endphp @else @endif {{-- Measured keyboard/mouse time, with the share of the session it was measured against. An em dash means the agent never sampled — a pre-1.15.0 row — which is not the same as having sat still. --}} {{-- The percentage alone. The measured duration stays on hover: as a column it competed with Total Active Hours right beside it, and the share is the thing being compared between rows. --}} @empty @endforelse @if ($rows->isNotEmpty()) {{-- In grouped mode a row is an employee, not a shift, so count both: "12 employees · 84 shifts". --}} @endif
{{ $grouped ? 'Date Range' : 'Shift Date' }} Employee Total Working Total Active Hours Input Activity Idle/Free Time
{{ $span }} @if ($r['in_progress']) Working @endif
{{-- The row is a sum, so say how many shifts it sums. Without this a light week and a full one look identical until you read the hours. --}}
{{ $r['shifts'] }} shift{{ $r['shifts'] === 1 ? '' : 's' }}
{{ \Illuminate\Support\Carbon::parse($r['shift_date'])->format('D, d M Y') }} @if ($r['in_progress']) {{-- Shift hasn't reached its scheduled end, so every figure on this row is still climbing. --}} Working @endif
{{ $r['started_at']->copy()->tz($r['tz'])->format('H:i') }} → {{ $r['ended_at']->copy()->tz($r['tz'])->format('H:i') }} @if ($r['ended_at']->copy()->tz($r['tz'])->toDateString() !== $r['shift_date']) +1d @endif
{{ $r['name'] }}
{{ $r['employee_id'] ?: $r['designation'] ?: '—' }} @if ($r['shift_start']) · shift {{ substr($r['shift_start'], 0, 5) }}–{{ substr($r['shift_end'], 0, 5) }} @else · no shift set @endif
{{ $hm($r['working_sec']) }} {{ $hm($r['active_sec']) }} @if ($r['activity_pct'] === null) @else {{ $r['activity_pct'] }}% @endif {{ $hmSigned($r['free_sec']) }}
No tracked time in this range.
@if ($grouped) {{ $rows->count() }} employee{{ $rows->count() === 1 ? '' : 's' }} · {{ $rows->sum('shifts') }} shift{{ $rows->sum('shifts') === 1 ? '' : 's' }} @else {{ $rows->count() }} shift{{ $rows->count() === 1 ? '' : 's' }} @endif {{ $hm($totals['working_sec']) }} {{ $hm($totals['active_sec']) }} @if ($totals['activity_pct'] === null) @else {{ $totals['activity_pct'] }}% @endif {{ $hmSigned($totals['free_sec']) }}
{{-- Below the table on purpose: it explains the columns, so it's reference for someone already reading them, not a preamble to get past first. --}}
@if ($grouped) Date Range rows sum every shift an employee worked in the range — one row each, not one per day. Pick a single date to see the shifts individually. @else Shift Date is the day a shift started. A night shift that runs past midnight — or a flexible day that stretches into the next morning — stays on the date it began, so it reads as one row rather than two half-days. @endif Total Working is the scheduled shift minus the rostered break — a 9h shift with an hour out is an 8h day. It is what was expected, not what was tracked. Employees with no shift on file are reported on plain calendar days and assumed to work {{ \App\Services\Reports\TimesheetService::DEFAULT_SHIFT_HOURS }} hours. Idle/Free Time is capped by that expected day — Total Working − Active, never more than the shift itself. Red is time still owed, blue is exactly on target, and green is time active beyond the shift.
@endsection @push('styles') {{-- Tabler's overrides for Litepicker live in this vendors bundle, which the base layout doesn't load. Litepicker v2 itself needs no stylesheet — it injects its base CSS from the JS bundle. --}} @include('partials.litepicker-theme') @endpush @push('scripts') @endpush