Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 643
0.00% covered (danger)
0.00%
0 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimesheetController
0.00% covered (danger)
0.00%
0 / 643
0.00% covered (danger)
0.00%
0 / 42
21462
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getDb
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getCentralDb
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getBuykioskDb
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTimesheetRepository
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTimePunchRepository
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTimePunchAuditRepository
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getShiftRepository
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getTimesheetExporter
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getOvertimeCalculator
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getLaborCostCalculator
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 checkReadAuth
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 checkWriteAuth
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 sendJsonResponse
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 sendErrorResponse
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 sendCsvResponse
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getCurrentUserId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStoreTimezone
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getWeekStart
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 getWeekEnd
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 parseWeekStartParamAsLocalWeekStart
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 getWeekRangeUtcFromLocalWeekStart
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getPayrollConfig
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 getDashboard
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
90
 getEmployeeTimesheet
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 approveTimesheet
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
42
 approveAll
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 unlockTimesheet
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 exportTimesheets
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
56
 editPunch
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
132
 addPunch
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
156
 deletePunch
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 formatTimesheetForDashboard
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 formatTimesheetForDetail
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
2
 formatPunchForApi
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 buildDayBreakdown
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
90
 calculateWeekTotals
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 getEmployeeInfo
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 generateTimesheetForEmployee
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
6
 recalculateTimesheet
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 ensureTimesheetsForWeek
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getEmployeesWithActivityInWeek
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace BuyerKiosk\Scheduling\Controllers;
4
5use BuyerKiosk\Scheduling\Models\Timesheet;
6use BuyerKiosk\Scheduling\Models\TimePunch;
7use BuyerKiosk\Scheduling\Repositories\TimesheetRepository;
8use BuyerKiosk\Scheduling\Repositories\TimePunchRepository;
9use BuyerKiosk\Scheduling\Repositories\TimePunchAuditRepository;
10use BuyerKiosk\Scheduling\Repositories\ShiftRepository;
11use BuyerKiosk\Scheduling\Services\TimesheetExporter;
12use BuyerKiosk\Scheduling\Services\OvertimeCalculator;
13use BuyerKiosk\Scheduling\Services\LaborCostCalculator;
14use DateTime;
15use DateTimeZone;
16use Exception;
17use PDO;
18
19/**
20 * TimesheetController - REST API for Timesheet Management
21 *
22 * Provides endpoints for managing timesheets using the BuyerKiosk native provider.
23 *
24 * Timesheet Endpoints:
25 * 1. GET    /api/:typeNum/schedule/timesheets                     - Dashboard summary
26 * 2. GET    /api/:typeNum/schedule/timesheets/:employeeId         - Employee detail view
27 * 3. PUT    /api/:typeNum/schedule/timesheets/:employeeId/approve - Approve individual
28 * 4. POST   /api/:typeNum/schedule/timesheets/approve-all         - Bulk approve
29 * 5. PUT    /api/:typeNum/schedule/timesheets/:employeeId/unlock  - Unlock approved
30 * 6. POST   /api/:typeNum/schedule/timesheets/export              - CSV export
31 *
32 * Punch Endpoints:
33 * 7. PUT    /api/:typeNum/schedule/punches/:punchId               - Edit punch
34 * 8. POST   /api/:typeNum/schedule/punches                        - Add manual punch
35 * 9. DELETE /api/:typeNum/schedule/punches/:punchId               - Delete punch
36 *
37 * Authentication: All endpoints require session authentication + uri_schedule_timesheet permission
38 *
39 * @package BuyerKiosk\Scheduling\Controllers
40 * @see docs/specs/013-employee-scheduling/solution-design.md
41 * @see docs/specs/013-employee-scheduling/product-requirements.md Features 11-14
42 */
43class TimesheetController
44{
45    /**
46     * @var \Slim\Slim Slim application instance
47     */
48    private $app;
49
50    /**
51     * @var \Store Store object
52     */
53    private $store;
54
55    /**
56     * @var string Store type number
57     */
58    private string $typeNum;
59
60    /**
61     * @var PDO|null Store database connection
62     */
63    private ?PDO $db = null;
64
65    /**
66     * @var PDO|null Central database connection
67     */
68    private ?PDO $centralDb = null;
69
70    /**
71     * @var PDO|null Buykiosk database connection
72     */
73    private ?PDO $buykioskDb = null;
74
75    /**
76     * @var TimesheetRepository|null
77     */
78    private ?TimesheetRepository $timesheetRepository = null;
79
80    /**
81     * @var TimePunchRepository|null
82     */
83    private ?TimePunchRepository $timePunchRepository = null;
84
85    /**
86     * @var TimePunchAuditRepository|null
87     */
88    private ?TimePunchAuditRepository $timePunchAuditRepository = null;
89
90    /**
91     * @var ShiftRepository|null
92     */
93    private ?ShiftRepository $shiftRepository = null;
94
95    /**
96     * @var TimesheetExporter|null
97     */
98    private ?TimesheetExporter $timesheetExporter = null;
99
100    /**
101     * @var OvertimeCalculator|null
102     */
103    private ?OvertimeCalculator $overtimeCalculator = null;
104
105    /**
106     * @var LaborCostCalculator|null
107     */
108    private ?LaborCostCalculator $laborCostCalculator = null;
109
110    /**
111     * Constructor
112     *
113     * @param \Slim\Slim $app Slim application instance
114     * @param \Store $store Store object (validated)
115     */
116    public function __construct($app, \Store $store)
117    {
118        $this->app = $app;
119        $this->store = $store;
120        $this->typeNum = $store->getTypeNum();
121    }
122
123    // =========================================================================
124    // LAZY INITIALIZATION
125    // =========================================================================
126
127    /**
128     * Get store database connection
129     */
130    private function getDb(): PDO
131    {
132        if ($this->db === null) {
133            $this->db = dbConnectByName($this->store->getDbName());
134        }
135        return $this->db;
136    }
137
138    /**
139     * Get central database connection (kiosk_users)
140     */
141    private function getCentralDb(): PDO
142    {
143        if ($this->centralDb === null) {
144            $this->centralDb = dbConnectByName('kiosk_users');
145        }
146        return $this->centralDb;
147    }
148
149    /**
150     * Get buykiosk database connection (kiosk_buykiosk for stores table)
151     */
152    private function getBuykioskDb(): PDO
153    {
154        if ($this->buykioskDb === null) {
155            $this->buykioskDb = dbConnectByName('kiosk_buykiosk');
156        }
157        return $this->buykioskDb;
158    }
159
160    /**
161     * Get TimesheetRepository instance
162     */
163    private function getTimesheetRepository(): TimesheetRepository
164    {
165        if ($this->timesheetRepository === null) {
166            $this->timesheetRepository = new TimesheetRepository($this->getDb());
167        }
168        return $this->timesheetRepository;
169    }
170
171    /**
172     * Get TimePunchRepository instance
173     */
174    private function getTimePunchRepository(): TimePunchRepository
175    {
176        if ($this->timePunchRepository === null) {
177            $this->timePunchRepository = new TimePunchRepository($this->getDb());
178        }
179        return $this->timePunchRepository;
180    }
181
182    /**
183     * Get TimePunchAuditRepository instance
184     */
185    private function getTimePunchAuditRepository(): TimePunchAuditRepository
186    {
187        if ($this->timePunchAuditRepository === null) {
188            $this->timePunchAuditRepository = new TimePunchAuditRepository($this->getDb());
189        }
190        return $this->timePunchAuditRepository;
191    }
192
193    /**
194     * Get ShiftRepository instance
195     */
196    private function getShiftRepository(): ShiftRepository
197    {
198        if ($this->shiftRepository === null) {
199            $this->shiftRepository = new ShiftRepository($this->getDb());
200        }
201        return $this->shiftRepository;
202    }
203
204    /**
205     * Get TimesheetExporter instance
206     */
207    private function getTimesheetExporter(): TimesheetExporter
208    {
209        if ($this->timesheetExporter === null) {
210            $this->timesheetExporter = new TimesheetExporter($this->getTimesheetRepository());
211        }
212        return $this->timesheetExporter;
213    }
214
215    /**
216     * Get OvertimeCalculator instance
217     */
218    private function getOvertimeCalculator(): OvertimeCalculator
219    {
220        if ($this->overtimeCalculator === null) {
221            $this->overtimeCalculator = new OvertimeCalculator(
222                $this->getCentralDb(),
223                $this->getTimePunchRepository()
224            );
225        }
226        return $this->overtimeCalculator;
227    }
228
229    /**
230     * Get LaborCostCalculator instance
231     */
232    private function getLaborCostCalculator(): LaborCostCalculator
233    {
234        if ($this->laborCostCalculator === null) {
235            $this->laborCostCalculator = new LaborCostCalculator(
236                $this->getCentralDb(),
237                $this->getShiftRepository(),
238                $this->getTimePunchRepository(),
239                $this->getOvertimeCalculator()
240            );
241        }
242        return $this->laborCostCalculator;
243    }
244
245    // =========================================================================
246    // AUTHENTICATION & PERMISSION CHECKS
247    // =========================================================================
248
249    /**
250     * Check session authentication and uri_schedule_timesheet permission (read access)
251     */
252    private function checkReadAuth(): bool
253    {
254        if (!isset($this->app->user) || !$this->app->user) {
255            $this->sendErrorResponse('Authentication required', 401, 'UNAUTHORIZED');
256            return false;
257        }
258
259        // For timesheets, we use uri_schedule permission (same as schedule viewing)
260        if (!$this->app->user->checkAccess('uri_schedule')) {
261            $this->sendErrorResponse('Access denied. Requires uri_schedule permission', 403, 'FORBIDDEN');
262            return false;
263        }
264
265        return true;
266    }
267
268    /**
269     * Check session authentication and uri_schedule_timesheet_approve permission (write access)
270     */
271    private function checkWriteAuth(): bool
272    {
273        if (!isset($this->app->user) || !$this->app->user) {
274            $this->sendErrorResponse('Authentication required', 401, 'UNAUTHORIZED');
275            return false;
276        }
277
278        // For timesheet approval and punch editing, require uri_schedule_manage
279        if (!$this->app->user->checkAccess('uri_schedule_manage')) {
280            $this->sendErrorResponse('Access denied. Requires uri_schedule_manage permission', 403, 'FORBIDDEN');
281            return false;
282        }
283
284        return true;
285    }
286
287    // =========================================================================
288    // RESPONSE HELPERS
289    // =========================================================================
290
291    /**
292     * Send JSON success response
293     */
294    private function sendJsonResponse($data, int $status = 200): void
295    {
296        $this->app->response->headers->set('Content-Type', 'application/json');
297        $this->app->response->setStatus($status);
298        $this->app->response->setBody(json_encode($data));
299    }
300
301    /**
302     * Send JSON error response
303     */
304    private function sendErrorResponse(string $message, int $status = 400, string $code = 'ERROR'): void
305    {
306        $this->app->response->headers->set('Content-Type', 'application/json');
307        $this->app->response->setStatus($status);
308        $this->app->response->setBody(json_encode([
309            'success' => false,
310            'error' => $message,
311            'code' => $code
312        ]));
313    }
314
315    /**
316     * Send CSV file response
317     */
318    private function sendCsvResponse(string $csv, string $filename): void
319    {
320        $this->app->response->headers->set('Content-Type', 'text/csv');
321        $this->app->response->headers->set('Content-Disposition', 'attachment; filename="' . $filename . '"');
322        $this->app->response->setStatus(200);
323        $this->app->response->setBody($csv);
324    }
325
326    /**
327     * Get current user ID
328     */
329    private function getCurrentUserId(): int
330    {
331        return (int)($this->app->user->id ?? 0);
332    }
333
334    /**
335     * Get store timezone
336     */
337    private function getStoreTimezone(): string
338    {
339        return $this->store->getTimeZone() ?: 'America/Los_Angeles';
340    }
341
342    /**
343     * Get the start of the week containing a given date
344     */
345    private function getWeekStart(DateTime $date): DateTime
346    {
347        // Get store's configured week start day (default to Monday)
348        $weekStartDay = 1; // Monday
349
350        try {
351            $stmt = $this->getBuykioskDb()->prepare("
352                SELECT workWeekStartDay FROM stores WHERE typeNum = :typeNum
353            ");
354            $stmt->bindValue(':typeNum', $this->typeNum);
355            $stmt->execute();
356            $result = $stmt->fetch(PDO::FETCH_ASSOC);
357
358            if ($result && $result['workWeekStartDay']) {
359                $dayMap = ['sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6];
360                $weekStartDay = $dayMap[strtolower($result['workWeekStartDay'])] ?? 1;
361            }
362        } catch (Exception $e) {
363            // Use default (Monday) on error
364        }
365
366        $weekStart = clone $date;
367        $currentDayOfWeek = (int)$weekStart->format('w'); // 0 = Sunday
368
369        // Calculate days to subtract to get to week start
370        $daysToSubtract = ($currentDayOfWeek - $weekStartDay + 7) % 7;
371        $weekStart->modify("-{$daysToSubtract} days");
372        $weekStart->setTime(0, 0, 0);
373
374        return $weekStart;
375    }
376
377    /**
378     * Get the end of the week from a week start date
379     */
380    private function getWeekEnd(DateTime $weekStart): DateTime
381    {
382        $weekEnd = clone $weekStart;
383        $weekEnd->modify('+6 days');
384        $weekEnd->setTime(23, 59, 59);
385        return $weekEnd;
386    }
387
388    /**
389     * Parse a weekStart request parameter into a store-local week start at midnight.
390     *
391     * Accepts either:
392     * - YYYY-MM-DD (interpreted as store-local date)
393     * - a datetime string (interpreted as an instant, then converted to store-local before week start calculation)
394     */
395    private function parseWeekStartParamAsLocalWeekStart(?string $weekStartParam, DateTimeZone $storeTz): DateTime
396    {
397        if (!$weekStartParam) {
398            return $this->getWeekStart(new DateTime('now', $storeTz));
399        }
400
401        if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $weekStartParam) === 1) {
402            $dayLocal = new DateTime($weekStartParam, $storeTz);
403            return $this->getWeekStart($dayLocal);
404        }
405
406        $instant = new DateTime($weekStartParam, $storeTz);
407        $instant->setTimezone($storeTz);
408        return $this->getWeekStart($instant);
409    }
410
411    /**
412     * Convert a store-local week start (midnight) into UTC query bounds.
413     *
414     * Shifts and punches are stored in UTC, so all range queries against
415     * `scheduleShifts.shiftStart` and `scheduleTimePunches.punchTime` must use UTC bounds.
416     *
417     * @return array{startUtc: DateTime, endUtcExclusive: DateTime}
418     */
419    private function getWeekRangeUtcFromLocalWeekStart(DateTime $weekStartLocal): array
420    {
421        $weekStartLocalMidnight = clone $weekStartLocal;
422        $weekStartLocalMidnight->setTime(0, 0, 0);
423
424        $weekEndLocalExclusive = clone $weekStartLocalMidnight;
425        $weekEndLocalExclusive->modify('+7 days');
426
427        $utc = new DateTimeZone('UTC');
428
429        $startUtc = clone $weekStartLocalMidnight;
430        $startUtc->setTimezone($utc);
431
432        $endUtcExclusive = clone $weekEndLocalExclusive;
433        $endUtcExclusive->setTimezone($utc);
434
435        return [
436            'startUtc' => $startUtc,
437            'endUtcExclusive' => $endUtcExclusive,
438        ];
439    }
440
441    /**
442     * Get store config for payroll rounding
443     */
444    private function getPayrollConfig(): array
445    {
446        try {
447            $stmt = $this->getBuykioskDb()->prepare("
448                SELECT payrollRoundingIncrementMinutes, payrollRoundingMode
449                FROM stores WHERE typeNum = :typeNum
450            ");
451            $stmt->bindValue(':typeNum', $this->typeNum);
452            $stmt->execute();
453            $result = $stmt->fetch(PDO::FETCH_ASSOC);
454
455            return [
456                'roundingIncrementMinutes' => (int)($result['payrollRoundingIncrementMinutes'] ?? 15),
457                'roundingMode' => $result['payrollRoundingMode'] ?? 'none',
458            ];
459        } catch (Exception $e) {
460            return [
461                'roundingIncrementMinutes' => 15,
462                'roundingMode' => 'none',
463            ];
464        }
465    }
466
467    // =========================================================================
468    // TIMESHEET ENDPOINTS
469    // =========================================================================
470
471    /**
472     * GET /api/:typeNum/schedule/timesheets
473     *
474     * Get timesheet dashboard summary with employee list for a week.
475     * Dynamically generates/updates timesheets from punch data for accuracy.
476     *
477     * Query Parameters:
478     * - weekStart: ISO date (optional, defaults to current week)
479     * - filter: string (optional) - 'all' | 'overtime' | 'missing_punches' | 'pending' | 'approved'
480     *
481     * @see PRD Feature 11: Timesheet Dashboard
482     */
483    public function getDashboard(): void
484    {
485        if (!$this->checkReadAuth()) {
486            return;
487        }
488
489        try {
490            $tz = new DateTimeZone($this->getStoreTimezone());
491            $weekStartParam = $this->app->request->get('weekStart');
492            $filter = $this->app->request->get('filter') ?? 'all';
493
494            $weekStart = $this->parseWeekStartParamAsLocalWeekStart($weekStartParam, $tz);
495
496            $weekEnd = $this->getWeekEnd($weekStart);
497
498            // First, ensure timesheets exist and are up-to-date for all employees
499            // with punches or shifts in this week
500            $this->ensureTimesheetsForWeek($weekStart);
501
502            // Get week summary stats (after ensuring data is current)
503            $summary = $this->getTimesheetRepository()->getWeekSummary($weekStart);
504
505            // Get timesheets based on filter
506            $timesheets = match ($filter) {
507                'overtime' => $this->getTimesheetRepository()->findWithOvertime($weekStart),
508                'pending' => $this->getTimesheetRepository()->findByStatus(Timesheet::STATUS_PENDING, $weekStart),
509                'approved' => $this->getTimesheetRepository()->findByStatus(Timesheet::STATUS_APPROVED, $weekStart),
510                default => $this->getTimesheetRepository()->findByWeek($weekStart),
511            };
512
513            // Build response with employee list
514            $employees = [];
515            foreach ($timesheets as $timesheet) {
516                $employees[] = $this->formatTimesheetForDashboard($timesheet);
517            }
518
519            // Check for employees with missing punches if that filter is requested
520            if ($filter === 'missing_punches') {
521                $employees = array_filter($employees, function ($emp) {
522                    return $emp['hasMissingPunches'] ?? false;
523                });
524                $employees = array_values($employees); // Re-index
525            }
526
527            $this->sendJsonResponse([
528                'weekStart' => $weekStart->format('Y-m-d'),
529                'weekEnd' => $weekEnd->format('Y-m-d'),
530                'summary' => [
531                    'totalEmployees' => $summary['totalEmployees'],
532                    'totalHours' => round($summary['totalHours'], 2),
533                    'regularHours' => round($summary['totalHours'] - $summary['overtimeHours'] - $summary['doubletimeHours'], 2),
534                    'overtimeHours' => round($summary['overtimeHours'], 2),
535                    'doubletimeHours' => round($summary['doubletimeHours'], 2),
536                    'totalPay' => round($summary['totalPay'], 2),
537                    'pending' => $summary['pending'],
538                    'approved' => $summary['approved'],
539                    'exported' => $summary['exported'],
540                ],
541                'employees' => $employees,
542            ]);
543        } catch (Exception $e) {
544            error_log("TimesheetController::getDashboard error: " . $e->getMessage());
545            $this->sendErrorResponse('Failed to retrieve timesheet dashboard', 500, 'SERVER_ERROR');
546        }
547    }
548
549    /**
550     * GET /api/:typeNum/schedule/timesheets/:employeeId
551     *
552     * Get detailed timesheet view for an employee.
553     *
554     * Query Parameters:
555     * - weekStart: ISO date (optional, defaults to current week)
556     *
557     * @param int $employeeId Employee ID
558     * @see PRD Feature 11: Timesheet Dashboard (detail view)
559     */
560    public function getEmployeeTimesheet(int $employeeId): void
561    {
562        if (!$this->checkReadAuth()) {
563            return;
564        }
565
566        try {
567            $tz = new DateTimeZone($this->getStoreTimezone());
568            $weekStartParam = $this->app->request->get('weekStart');
569
570            $weekStart = $this->parseWeekStartParamAsLocalWeekStart($weekStartParam, $tz);
571
572            $weekRangeUtc = $this->getWeekRangeUtcFromLocalWeekStart($weekStart);
573            $weekStartUtc = $weekRangeUtc['startUtc'];
574            $weekEndUtcExclusive = $weekRangeUtc['endUtcExclusive'];
575
576            // Get timesheet (or null if no data yet)
577            $timesheet = $this->getTimesheetRepository()->findByEmployeeAndWeek($employeeId, $weekStart);
578
579            // Get punches for the week
580            $punches = $this->getTimePunchRepository()->findByEmployeeAndDateRange(
581                $employeeId,
582                $weekStartUtc,
583                $weekEndUtcExclusive
584            );
585
586            // Get shifts for the week
587            $shifts = $this->getShiftRepository()->findByEmployeeAndDateRange(
588                $employeeId,
589                $weekStartUtc,
590                $weekEndUtcExclusive
591            );
592
593            // Get employee info
594            $employeeInfo = $this->getEmployeeInfo($employeeId);
595
596            // Build day-by-day breakdown
597            $days = $this->buildDayBreakdown($weekStart, $punches, $shifts, $tz);
598
599            $this->sendJsonResponse([
600                'employeeId' => $employeeId,
601                'employeeName' => $employeeInfo['name'] ?? 'Unknown',
602                'weekStart' => $weekStart->format('Y-m-d'),
603                'weekEnd' => $this->getWeekEnd($weekStart)->format('Y-m-d'),
604                'timesheet' => $timesheet ? $this->formatTimesheetForDetail($timesheet) : null,
605                'days' => $days,
606                'totals' => $this->calculateWeekTotals($days, $timesheet),
607            ]);
608        } catch (Exception $e) {
609            error_log("TimesheetController::getEmployeeTimesheet error: " . $e->getMessage());
610            $this->sendErrorResponse('Failed to retrieve employee timesheet', 500, 'SERVER_ERROR');
611        }
612    }
613
614    /**
615     * PUT /api/:typeNum/schedule/timesheets/:employeeId/approve
616     *
617     * Approve an individual employee's timesheet.
618     *
619     * @param int $employeeId Employee ID
620     * @see PRD Feature 13: Timesheet Approval Workflow
621     */
622    public function approveTimesheet(int $employeeId): void
623    {
624        if (!$this->checkWriteAuth()) {
625            return;
626        }
627
628        try {
629            $tz = new DateTimeZone($this->getStoreTimezone());
630            $data = json_decode($this->app->request->getBody(), true);
631            $weekStartParam = $data['weekStart'] ?? null;
632
633            if (!$weekStartParam) {
634                $this->sendErrorResponse('weekStart is required', 400, 'MISSING_PARAMS');
635                return;
636            }
637
638            $weekStart = $this->parseWeekStartParamAsLocalWeekStart($weekStartParam, $tz);
639
640            // Find or create timesheet
641            $timesheet = $this->getTimesheetRepository()->findByEmployeeAndWeek($employeeId, $weekStart);
642
643            if ($timesheet === null) {
644                // Create timesheet from calculated data
645                $timesheet = $this->generateTimesheetForEmployee($employeeId, $weekStart);
646                $timesheet = $this->getTimesheetRepository()->create($timesheet);
647            }
648
649            if ($timesheet->isApproved()) {
650                $this->sendErrorResponse('Timesheet is already approved', 400, 'ALREADY_APPROVED');
651                return;
652            }
653
654            // Approve the timesheet
655            $approvedTimesheet = $this->getTimesheetRepository()->approve(
656                $timesheet->getTimesheetId(),
657                $this->getCurrentUserId()
658            );
659
660            $this->sendJsonResponse([
661                'success' => true,
662                'timesheet' => $this->formatTimesheetForDetail($approvedTimesheet),
663            ]);
664        } catch (Exception $e) {
665            error_log("TimesheetController::approveTimesheet error: " . $e->getMessage());
666            $this->sendErrorResponse('Failed to approve timesheet', 500, 'SERVER_ERROR');
667        }
668    }
669
670    /**
671     * POST /api/:typeNum/schedule/timesheets/approve-all
672     *
673     * Bulk approve all pending timesheets for a week.
674     *
675     * @see PRD Feature 13: Timesheet Approval Workflow
676     */
677    public function approveAll(): void
678    {
679        if (!$this->checkWriteAuth()) {
680            return;
681        }
682
683        try {
684            $tz = new DateTimeZone($this->getStoreTimezone());
685            $data = json_decode($this->app->request->getBody(), true);
686            $weekStartParam = $data['weekStart'] ?? null;
687
688            if (!$weekStartParam) {
689                $this->sendErrorResponse('weekStart is required', 400, 'MISSING_PARAMS');
690                return;
691            }
692
693            $weekStart = $this->parseWeekStartParamAsLocalWeekStart($weekStartParam, $tz);
694
695            // Get all pending timesheets for the week
696            $pendingTimesheets = $this->getTimesheetRepository()->findByStatus(
697                Timesheet::STATUS_PENDING,
698                $weekStart
699            );
700
701            $timesheetIds = array_map(
702                fn($ts) => $ts->getTimesheetId(),
703                $pendingTimesheets
704            );
705
706            // Approve all
707            $approvedCount = $this->getTimesheetRepository()->approveMultiple(
708                $timesheetIds,
709                $this->getCurrentUserId()
710            );
711
712            $this->sendJsonResponse([
713                'success' => true,
714                'approvedCount' => $approvedCount,
715            ]);
716        } catch (Exception $e) {
717            error_log("TimesheetController::approveAll error: " . $e->getMessage());
718            $this->sendErrorResponse('Failed to approve timesheets', 500, 'SERVER_ERROR');
719        }
720    }
721
722    /**
723     * PUT /api/:typeNum/schedule/timesheets/:employeeId/unlock
724     *
725     * Unlock an approved timesheet to allow edits.
726     *
727     * @param int $employeeId Employee ID
728     * @see PRD Feature 13: Timesheet Approval Workflow
729     */
730    public function unlockTimesheet(int $employeeId): void
731    {
732        if (!$this->checkWriteAuth()) {
733            return;
734        }
735
736        try {
737            $tz = new DateTimeZone($this->getStoreTimezone());
738            $data = json_decode($this->app->request->getBody(), true);
739            $weekStartParam = $data['weekStart'] ?? null;
740
741            if (!$weekStartParam) {
742                $this->sendErrorResponse('weekStart is required', 400, 'MISSING_PARAMS');
743                return;
744            }
745
746            $weekStart = $this->parseWeekStartParamAsLocalWeekStart($weekStartParam, $tz);
747
748            $timesheet = $this->getTimesheetRepository()->findByEmployeeAndWeek($employeeId, $weekStart);
749
750            if ($timesheet === null) {
751                $this->sendErrorResponse('Timesheet not found', 404, 'NOT_FOUND');
752                return;
753            }
754
755            if (!$timesheet->isApproved()) {
756                $this->sendErrorResponse('Timesheet is not approved', 400, 'NOT_APPROVED');
757                return;
758            }
759
760            // Unlock the timesheet
761            $unlockedTimesheet = $this->getTimesheetRepository()->unlock($timesheet->getTimesheetId());
762
763            $this->sendJsonResponse([
764                'success' => true,
765                'timesheet' => $this->formatTimesheetForDetail($unlockedTimesheet),
766            ]);
767        } catch (Exception $e) {
768            error_log("TimesheetController::unlockTimesheet error: " . $e->getMessage());
769            $this->sendErrorResponse('Failed to unlock timesheet', 500, 'SERVER_ERROR');
770        }
771    }
772
773    /**
774     * POST /api/:typeNum/schedule/timesheets/export
775     *
776     * Export timesheets to CSV.
777     *
778     * @see PRD Feature 14: Timesheet Export
779     */
780    public function exportTimesheets(): void
781    {
782        if (!$this->checkWriteAuth()) {
783            return;
784        }
785
786        try {
787            $tz = new DateTimeZone($this->getStoreTimezone());
788            $data = json_decode($this->app->request->getBody(), true);
789            $weekStartParam = $data['weekStart'] ?? null;
790            $includeAll = $data['includeAll'] ?? false;
791            $markExported = $data['markExported'] ?? true;
792
793            if (!$weekStartParam) {
794                $this->sendErrorResponse('weekStart is required', 400, 'MISSING_PARAMS');
795                return;
796            }
797
798            $weekStart = $this->parseWeekStartParamAsLocalWeekStart($weekStartParam, $tz);
799
800            // Get timesheets to export
801            if ($includeAll) {
802                $timesheets = $this->getTimesheetRepository()->findByWeek($weekStart);
803            } else {
804                $timesheets = $this->getTimesheetRepository()->findReadyForExport($weekStart);
805            }
806
807            if (empty($timesheets)) {
808                $this->sendErrorResponse('No approved timesheets to export', 400, 'NO_DATA');
809                return;
810            }
811
812            // Get payroll config for rounding
813            $payrollConfig = $this->getPayrollConfig();
814
815            // Generate CSV
816            $csv = $this->getTimesheetExporter()->exportToCsv($timesheets, [
817                'roundingMode' => $payrollConfig['roundingMode'],
818                'roundingIncrementMinutes' => $payrollConfig['roundingIncrementMinutes'],
819                'storeName' => $this->store->getCompanyName(),
820            ]);
821
822            // Mark as exported if requested
823            if ($markExported) {
824                $timesheetIds = array_map(fn($ts) => $ts->getTimesheetId(), $timesheets);
825                $this->getTimesheetRepository()->markExported($timesheetIds);
826            }
827
828            // Generate filename
829            $weekEndDate = $this->getWeekEnd($weekStart)->format('Y-m-d');
830            $storeName = preg_replace('/[^a-zA-Z0-9]/', '_', $this->store->getCompanyName());
831            $filename = "timesheets_{$storeName}_{$weekStart->format('Y-m-d')}_to_{$weekEndDate}.csv";
832
833            $this->sendCsvResponse($csv, $filename);
834        } catch (Exception $e) {
835            error_log("TimesheetController::exportTimesheets error: " . $e->getMessage());
836            $this->sendErrorResponse('Failed to export timesheets', 500, 'SERVER_ERROR');
837        }
838    }
839
840    // =========================================================================
841    // PUNCH ENDPOINTS
842    // =========================================================================
843
844    /**
845     * PUT /api/:typeNum/schedule/punches/:punchId
846     *
847     * Edit an existing time punch.
848     *
849     * @param int $punchId Punch ID
850     * @see PRD Feature 12: Time Punch Editing
851     */
852    public function editPunch(int $punchId): void
853    {
854        if (!$this->checkWriteAuth()) {
855            return;
856        }
857
858        try {
859            $data = json_decode($this->app->request->getBody(), true);
860
861            if (!$data) {
862                $this->sendErrorResponse('Request body is required', 400, 'MISSING_BODY');
863                return;
864            }
865
866            // Note is required for all edits
867            if (!isset($data['note']) || trim($data['note']) === '') {
868                $this->sendErrorResponse('Edit note is required', 400, 'NOTE_REQUIRED');
869                return;
870            }
871
872            $punch = $this->getTimePunchRepository()->findById($punchId);
873
874            if ($punch === null) {
875                $this->sendErrorResponse('Punch not found', 404, 'NOT_FOUND');
876                return;
877            }
878
879            // Check if timesheet is approved (locked)
880            $weekStart = $this->getWeekStart($punch->getPunchTime());
881            $timesheet = $this->getTimesheetRepository()->findByEmployeeAndWeek(
882                $punch->getEmployeeId(),
883                $weekStart
884            );
885
886            if ($timesheet !== null && $timesheet->isApproved()) {
887                $this->sendErrorResponse('Timesheet is approved. Unlock to edit punches.', 400, 'TIMESHEET_LOCKED');
888                return;
889            }
890
891            // Capture old values for audit
892            $oldValues = $punch->toDbArray();
893
894            // Apply edits
895            $tz = new DateTimeZone($this->getStoreTimezone());
896
897            // Determine new punch time (use existing if not provided)
898            $newPunchTime = isset($data['punchTime'])
899                ? new DateTime($data['punchTime'], $tz)
900                : $punch->getPunchTime();
901
902            if (isset($data['breakType'])) {
903                $punch->setBreakType($data['breakType']);
904            }
905
906            // Set edit metadata (uses setEdit which updates time, editor, note, and timestamp)
907            $punch->setEdit(
908                $this->getCurrentUserId(),
909                $data['note'],
910                $newPunchTime
911            );
912
913            // Save changes
914            $updatedPunch = $this->getTimePunchRepository()->update($punch);
915
916            // Write audit log
917            $this->getTimePunchAuditRepository()->logEdit(
918                $updatedPunch->getPunchId(),
919                $this->getCurrentUserId(),
920                $oldValues,
921                $updatedPunch->toDbArray(),
922                $data['note']
923            );
924
925            // Recalculate timesheet
926            $this->recalculateTimesheet($punch->getEmployeeId(), $weekStart);
927
928            $this->sendJsonResponse([
929                'success' => true,
930                'punch' => $this->formatPunchForApi($updatedPunch),
931            ]);
932        } catch (Exception $e) {
933            error_log("TimesheetController::editPunch error: " . $e->getMessage());
934            $this->sendErrorResponse('Failed to edit punch', 500, 'SERVER_ERROR');
935        }
936    }
937
938    /**
939     * POST /api/:typeNum/schedule/punches
940     *
941     * Add a manual time punch.
942     *
943     * @see PRD Feature 12: Time Punch Editing
944     */
945    public function addPunch(): void
946    {
947        if (!$this->checkWriteAuth()) {
948            return;
949        }
950
951        try {
952            $data = json_decode($this->app->request->getBody(), true);
953
954            if (!$data) {
955                $this->sendErrorResponse('Request body is required', 400, 'MISSING_BODY');
956                return;
957            }
958
959            // Validate required fields
960            $required = ['employeeId', 'punchType', 'punchTime', 'note'];
961            foreach ($required as $field) {
962                if (!isset($data[$field]) || (is_string($data[$field]) && trim($data[$field]) === '')) {
963                    $this->sendErrorResponse("$field is required", 400, 'MISSING_PARAMS');
964                    return;
965                }
966            }
967
968            $tz = new DateTimeZone($this->getStoreTimezone());
969            $punchTime = new DateTime($data['punchTime'], $tz);
970
971            // Check if timesheet is approved (locked)
972            $weekStart = $this->getWeekStart($punchTime);
973            $timesheet = $this->getTimesheetRepository()->findByEmployeeAndWeek(
974                (int)$data['employeeId'],
975                $weekStart
976            );
977
978            if ($timesheet !== null && $timesheet->isApproved()) {
979                $this->sendErrorResponse('Timesheet is approved. Unlock to add punches.', 400, 'TIMESHEET_LOCKED');
980                return;
981            }
982
983            // Create the punch
984            $punch = new TimePunch(
985                (int)$data['employeeId'],
986                $data['punchType'],
987                $punchTime,
988                $this->getCurrentUserId()
989            );
990
991            // Mark as manual entry
992            $punch->setIsManualEntry(true);
993            $punch->setManualEntryNote($data['note']);
994
995            // Optional fields
996            if (isset($data['shiftId'])) {
997                $punch->setShiftId((int)$data['shiftId']);
998            }
999
1000            if (isset($data['breakType'])) {
1001                $punch->setBreakType($data['breakType']);
1002            }
1003
1004            // Save
1005            $createdPunch = $this->getTimePunchRepository()->create($punch);
1006
1007            // Write audit log
1008            $this->getTimePunchAuditRepository()->logCreate(
1009                $createdPunch->getPunchId(),
1010                $this->getCurrentUserId(),
1011                $createdPunch->toDbArray(),
1012                $data['note']
1013            );
1014
1015            // Recalculate timesheet
1016            $this->recalculateTimesheet((int)$data['employeeId'], $weekStart);
1017
1018            $this->sendJsonResponse([
1019                'success' => true,
1020                'punch' => $this->formatPunchForApi($createdPunch),
1021            ], 201);
1022        } catch (Exception $e) {
1023            error_log("TimesheetController::addPunch error: " . $e->getMessage());
1024            $this->sendErrorResponse('Failed to add punch', 500, 'SERVER_ERROR');
1025        }
1026    }
1027
1028    /**
1029     * DELETE /api/:typeNum/schedule/punches/:punchId
1030     *
1031     * Soft delete a time punch.
1032     *
1033     * @param int $punchId Punch ID
1034     * @see PRD Feature 12: Time Punch Editing
1035     */
1036    public function deletePunch(int $punchId): void
1037    {
1038        if (!$this->checkWriteAuth()) {
1039            return;
1040        }
1041
1042        try {
1043            $data = json_decode($this->app->request->getBody(), true);
1044
1045            // Note is required for deletion
1046            if (!isset($data['note']) || trim($data['note']) === '') {
1047                $this->sendErrorResponse('Deletion note is required', 400, 'NOTE_REQUIRED');
1048                return;
1049            }
1050
1051            $punch = $this->getTimePunchRepository()->findById($punchId);
1052
1053            if ($punch === null) {
1054                $this->sendErrorResponse('Punch not found', 404, 'NOT_FOUND');
1055                return;
1056            }
1057
1058            // Check if timesheet is approved (locked)
1059            $weekStart = $this->getWeekStart($punch->getPunchTime());
1060            $timesheet = $this->getTimesheetRepository()->findByEmployeeAndWeek(
1061                $punch->getEmployeeId(),
1062                $weekStart
1063            );
1064
1065            if ($timesheet !== null && $timesheet->isApproved()) {
1066                $this->sendErrorResponse('Timesheet is approved. Unlock to delete punches.', 400, 'TIMESHEET_LOCKED');
1067                return;
1068            }
1069
1070            // Capture for audit
1071            $oldValues = $punch->toDbArray();
1072
1073            // Soft delete
1074            $deleted = $this->getTimePunchRepository()->softDelete(
1075                $punchId,
1076                $this->getCurrentUserId(),
1077                $data['note']
1078            );
1079
1080            if ($deleted) {
1081                // Write audit log
1082                $this->getTimePunchAuditRepository()->logDelete(
1083                    $punchId,
1084                    $this->getCurrentUserId(),
1085                    $oldValues,
1086                    $data['note']
1087                );
1088
1089                // Recalculate timesheet
1090                $this->recalculateTimesheet($punch->getEmployeeId(), $weekStart);
1091            }
1092
1093            $this->sendJsonResponse(['success' => $deleted]);
1094        } catch (Exception $e) {
1095            error_log("TimesheetController::deletePunch error: " . $e->getMessage());
1096            $this->sendErrorResponse('Failed to delete punch', 500, 'SERVER_ERROR');
1097        }
1098    }
1099
1100    // =========================================================================
1101    // HELPER METHODS
1102    // =========================================================================
1103
1104    /**
1105     * Format a timesheet for dashboard display
1106     */
1107    private function formatTimesheetForDashboard(Timesheet $timesheet): array
1108    {
1109        $variance = $timesheet->getTotalHours() - $timesheet->getScheduledTotalHours();
1110        $hasOvertime = $timesheet->getOvertimeHours() > 0 || $timesheet->getDoubletimeHours() > 0;
1111        $unexpectedOvertime = $timesheet->getOvertimeHours() > $timesheet->getScheduledOvertimeHours();
1112
1113        return [
1114            'timesheetId' => $timesheet->getTimesheetId(),
1115            'employeeId' => $timesheet->getEmployeeId(),
1116            'employeeName' => $timesheet->getEmployeeName(),
1117            'scheduledHours' => round($timesheet->getScheduledTotalHours(), 2),
1118            'actualHours' => round($timesheet->getTotalHours(), 2),
1119            'variance' => round($variance, 2),
1120            'regularHours' => round($timesheet->getRegularHours(), 2),
1121            'overtimeHours' => round($timesheet->getOvertimeHours(), 2),
1122            'doubletimeHours' => round($timesheet->getDoubletimeHours(), 2),
1123            'totalPay' => round($timesheet->getTotalPay(), 2),
1124            'status' => $timesheet->getStatus(),
1125            'hasOvertime' => $hasOvertime,
1126            'unexpectedOvertime' => $unexpectedOvertime,
1127            'hasMissingPunches' => false, // Would need to check punches to determine
1128            'approvedBy' => $timesheet->getApproverName(),
1129            'approvedAt' => $timesheet->getApprovedAt()?->format('c'),
1130        ];
1131    }
1132
1133    /**
1134     * Format a timesheet for detail view
1135     */
1136    private function formatTimesheetForDetail(Timesheet $timesheet): array
1137    {
1138        return [
1139            'timesheetId' => $timesheet->getTimesheetId(),
1140            'employeeId' => $timesheet->getEmployeeId(),
1141            'employeeName' => $timesheet->getEmployeeName(),
1142            'weekStartDate' => $timesheet->getWeekStartDate()->format('Y-m-d'),
1143            'weekEndDate' => $timesheet->getWeekEndDate()->format('Y-m-d'),
1144            'scheduledTotalHours' => round($timesheet->getScheduledTotalHours(), 2),
1145            'scheduledRegularHours' => round($timesheet->getScheduledRegularHours(), 2),
1146            'scheduledOvertimeHours' => round($timesheet->getScheduledOvertimeHours(), 2),
1147            'scheduledDoubletimeHours' => round($timesheet->getScheduledDoubletimeHours(), 2),
1148            'totalHours' => round($timesheet->getTotalHours(), 2),
1149            'regularHours' => round($timesheet->getRegularHours(), 2),
1150            'overtimeHours' => round($timesheet->getOvertimeHours(), 2),
1151            'doubletimeHours' => round($timesheet->getDoubletimeHours(), 2),
1152            'totalPay' => round($timesheet->getTotalPay(), 2),
1153            'status' => $timesheet->getStatus(),
1154            'approvedByUserId' => $timesheet->getApprovedByUserId(),
1155            'approvedBy' => $timesheet->getApproverName(),
1156            'approvedAt' => $timesheet->getApprovedAt()?->format('c'),
1157            'exportedAt' => $timesheet->getExportedAt()?->format('c'),
1158        ];
1159    }
1160
1161    /**
1162     * Format a punch for API response
1163     */
1164    private function formatPunchForApi(TimePunch $punch): array
1165    {
1166        return [
1167            'punchId' => $punch->getPunchId(),
1168            'employeeId' => $punch->getEmployeeId(),
1169            'employeeName' => $punch->getEmployeeName(),
1170            'shiftId' => $punch->getShiftId(),
1171            'punchType' => $punch->getPunchType(),
1172            'punchTime' => $punch->getPunchTime()->format('c'),
1173            'breakType' => $punch->getBreakType(),
1174            'isManualEntry' => $punch->isManualEntry(),
1175            'isEdited' => $punch->hasBeenEdited(),
1176            'editNote' => $punch->getEditNote(),
1177            'editedAt' => $punch->getEditedAt()?->format('c'),
1178        ];
1179    }
1180
1181    /**
1182     * Build day-by-day breakdown for employee timesheet
1183     */
1184    private function buildDayBreakdown(DateTime $weekStart, array $punches, array $shifts, DateTimeZone $tz): array
1185    {
1186        $days = [];
1187
1188        for ($i = 0; $i < 7; $i++) {
1189            $dayStart = clone $weekStart;
1190            $dayStart->modify("+{$i} days");
1191            $dayStart->setTimezone($tz);
1192
1193            $dayEnd = clone $dayStart;
1194            $dayEnd->setTime(23, 59, 59);
1195
1196            $dayPunches = array_filter($punches, function ($punch) use ($dayStart, $dayEnd) {
1197                $punchTime = $punch->getPunchTime();
1198                return $punchTime >= $dayStart && $punchTime <= $dayEnd;
1199            });
1200
1201            $dayShifts = array_filter($shifts, function ($shift) use ($dayStart, $dayEnd) {
1202                $shiftStart = $shift->getShiftStart();
1203                return $shiftStart >= $dayStart && $shiftStart <= $dayEnd;
1204            });
1205
1206            // Calculate worked hours for the day
1207            $workedMinutes = 0;
1208            $punchList = array_values($dayPunches);
1209            $clockIn = null;
1210
1211            foreach ($punchList as $punch) {
1212                if ($punch->getPunchType() === TimePunch::TYPE_CLOCK_IN) {
1213                    $clockIn = $punch->getPunchTime();
1214                } elseif ($punch->getPunchType() === TimePunch::TYPE_CLOCK_OUT && $clockIn !== null) {
1215                    $workedMinutes += ($punch->getPunchTime()->getTimestamp() - $clockIn->getTimestamp()) / 60;
1216                    $clockIn = null;
1217                }
1218            }
1219
1220            // Calculate scheduled hours for the day
1221            $scheduledMinutes = 0;
1222            foreach ($dayShifts as $shift) {
1223                $scheduledMinutes += ($shift->getShiftEnd()->getTimestamp() - $shift->getShiftStart()->getTimestamp()) / 60;
1224            }
1225
1226            $days[] = [
1227                'date' => $dayStart->format('Y-m-d'),
1228                'dayOfWeek' => $dayStart->format('D'),
1229                'punches' => array_map([$this, 'formatPunchForApi'], array_values($dayPunches)),
1230                'shifts' => array_map(function ($shift) {
1231                    return [
1232                        'shiftId' => $shift->getShiftId(),
1233                        'shiftStart' => $shift->getShiftStart()->format('c'),
1234                        'shiftEnd' => $shift->getShiftEnd()->format('c'),
1235                        'positionName' => $shift->getPositionName(),
1236                    ];
1237                }, array_values($dayShifts)),
1238                'workedHours' => round($workedMinutes / 60, 2),
1239                'scheduledHours' => round($scheduledMinutes / 60, 2),
1240                'variance' => round(($workedMinutes - $scheduledMinutes) / 60, 2),
1241            ];
1242        }
1243
1244        return $days;
1245    }
1246
1247    /**
1248     * Calculate week totals from day breakdown
1249     */
1250    private function calculateWeekTotals(array $days, ?Timesheet $timesheet): array
1251    {
1252        $totalWorked = 0;
1253        $totalScheduled = 0;
1254
1255        foreach ($days as $day) {
1256            $totalWorked += $day['workedHours'];
1257            $totalScheduled += $day['scheduledHours'];
1258        }
1259
1260        return [
1261            'workedHours' => round($totalWorked, 2),
1262            'scheduledHours' => round($totalScheduled, 2),
1263            'variance' => round($totalWorked - $totalScheduled, 2),
1264            'regularHours' => $timesheet ? round($timesheet->getRegularHours(), 2) : round($totalWorked, 2),
1265            'overtimeHours' => $timesheet ? round($timesheet->getOvertimeHours(), 2) : 0,
1266            'doubletimeHours' => $timesheet ? round($timesheet->getDoubletimeHours(), 2) : 0,
1267            'totalPay' => $timesheet ? round($timesheet->getTotalPay(), 2) : 0,
1268        ];
1269    }
1270
1271    /**
1272     * Get employee info from central users table
1273     *
1274     * Note: The scheduling system uses kiosk_users.users.id as employeeId,
1275     * NOT the store-local employees table. This is consistent with how
1276     * shifts, time punches, and timesheets reference employees.
1277     */
1278    private function getEmployeeInfo(int $employeeId): array
1279    {
1280        $stmt = $this->getCentralDb()->prepare("
1281            SELECT id, firstName, lastName
1282            FROM users
1283            WHERE id = :employeeId
1284        ");
1285        $stmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
1286        $stmt->execute();
1287
1288        $row = $stmt->fetch(PDO::FETCH_ASSOC);
1289
1290        if (!$row) {
1291            return ['name' => 'Unknown', 'position' => null];
1292        }
1293
1294        return [
1295            'id' => (int)$row['id'],
1296            'name' => trim($row['firstName'] . ' ' . $row['lastName']),
1297            'position' => null, // Position not stored in central users table
1298        ];
1299    }
1300
1301    /**
1302     * Generate a timesheet for an employee (calculate from punches)
1303     */
1304    private function generateTimesheetForEmployee(int $employeeId, DateTime $weekStart): Timesheet
1305    {
1306        $weekEnd = $this->getWeekEnd($weekStart);
1307        $weekRangeUtc = $this->getWeekRangeUtcFromLocalWeekStart($weekStart);
1308        $weekStartUtc = $weekRangeUtc['startUtc'];
1309        $weekEndUtcExclusive = $weekRangeUtc['endUtcExclusive'];
1310
1311        // Get punches for the week
1312        $workedHours = $this->getTimePunchRepository()->calculateWorkedHours(
1313            $employeeId,
1314            $weekStartUtc,
1315            $weekEndUtcExclusive,
1316            true
1317        );
1318
1319        // Get scheduled hours
1320        $shifts = $this->getShiftRepository()->findByEmployeeAndDateRange($employeeId, $weekStartUtc, $weekEndUtcExclusive);
1321        $scheduledMinutes = 0;
1322        foreach ($shifts as $shift) {
1323            $scheduledMinutes += ($shift->getShiftEnd()->getTimestamp() - $shift->getShiftStart()->getTimestamp()) / 60;
1324        }
1325        $scheduledHours = $scheduledMinutes / 60;
1326
1327        // Calculate overtime (simplified - just use 40hr threshold for now)
1328        $regularHours = min($workedHours, 40);
1329        $overtimeHours = max(0, $workedHours - 40);
1330
1331        $timesheet = new Timesheet($employeeId, $weekStart, $weekEnd);
1332        $timesheet->setScheduledHours(
1333            $scheduledHours,
1334            min($scheduledHours, 40),
1335            max(0, $scheduledHours - 40),
1336            0
1337        );
1338        $timesheet->setActualHours(
1339            $workedHours,
1340            $regularHours,
1341            $overtimeHours,
1342            0
1343        );
1344
1345        return $timesheet;
1346    }
1347
1348    /**
1349     * Recalculate timesheet after punch changes
1350     *
1351     * Creates a new timesheet if one doesn't exist, or updates existing one
1352     * (unless it's approved).
1353     */
1354    private function recalculateTimesheet(int $employeeId, DateTime $weekStart): void
1355    {
1356        try {
1357            $existingTimesheet = $this->getTimesheetRepository()->findByEmployeeAndWeek($employeeId, $weekStart);
1358
1359            // Generate fresh calculated data
1360            $newData = $this->generateTimesheetForEmployee($employeeId, $weekStart);
1361
1362            if ($existingTimesheet === null) {
1363                // Create new timesheet from calculated data
1364                $this->getTimesheetRepository()->create($newData);
1365            } elseif (!$existingTimesheet->isApproved()) {
1366                // Update existing timesheet with recalculated values
1367                $existingTimesheet->setActualHours(
1368                    $newData->getTotalHours(),
1369                    $newData->getRegularHours(),
1370                    $newData->getOvertimeHours(),
1371                    $newData->getDoubletimeHours()
1372                );
1373                $existingTimesheet->setScheduledHours(
1374                    $newData->getScheduledTotalHours(),
1375                    $newData->getScheduledRegularHours(),
1376                    $newData->getScheduledOvertimeHours(),
1377                    $newData->getScheduledDoubletimeHours()
1378                );
1379
1380                $this->getTimesheetRepository()->update($existingTimesheet);
1381            }
1382            // If approved, don't change - it's locked
1383        } catch (Exception $e) {
1384            error_log("TimesheetController::recalculateTimesheet error: " . $e->getMessage());
1385            // Don't fail the main operation if recalculation fails
1386        }
1387    }
1388
1389    /**
1390     * Ensure timesheets exist and are up-to-date for all employees with punches/shifts in a week
1391     *
1392     * This method:
1393     * 1. Finds all employees who have punches or shifts in the week
1394     * 2. Creates or updates timesheet records with dynamically calculated hours
1395     * 3. Skips approved timesheets (they're locked)
1396     */
1397    private function ensureTimesheetsForWeek(DateTime $weekStart): void
1398    {
1399        try {
1400            $weekRangeUtc = $this->getWeekRangeUtcFromLocalWeekStart($weekStart);
1401
1402            // Get all employee IDs who have punches in this week
1403            $employeeIds = $this->getEmployeesWithActivityInWeek(
1404                $weekRangeUtc['startUtc'],
1405                $weekRangeUtc['endUtcExclusive']
1406            );
1407
1408            foreach ($employeeIds as $employeeId) {
1409                $this->recalculateTimesheet($employeeId, $weekStart);
1410            }
1411        } catch (Exception $e) {
1412            error_log("TimesheetController::ensureTimesheetsForWeek error: " . $e->getMessage());
1413            // Continue with existing data on error
1414        }
1415    }
1416
1417    /**
1418     * Get all employee IDs who have punches or shifts in a given week
1419     */
1420    private function getEmployeesWithActivityInWeek(DateTime $weekStartUtc, DateTime $weekEndUtcExclusive): array
1421    {
1422        // Query for employees with punches OR shifts in this week
1423        $sql = "
1424            SELECT DISTINCT employeeId FROM (
1425                SELECT DISTINCT employeeId
1426                FROM scheduleTimePunches
1427                WHERE punchTime >= :punchStart
1428                  AND punchTime < :punchEnd
1429                  AND deleted_at IS NULL
1430                UNION
1431                SELECT DISTINCT employeeId
1432                FROM scheduleShifts
1433                WHERE shiftStart >= :shiftStart
1434                  AND shiftStart < :shiftEnd
1435                  AND deleted_at IS NULL
1436            ) AS combined
1437        ";
1438
1439        $stmt = $this->getDb()->prepare($sql);
1440        $stmt->bindValue(':punchStart', $weekStartUtc->format('Y-m-d H:i:s'));
1441        $stmt->bindValue(':punchEnd', $weekEndUtcExclusive->format('Y-m-d H:i:s'));
1442        $stmt->bindValue(':shiftStart', $weekStartUtc->format('Y-m-d H:i:s'));
1443        $stmt->bindValue(':shiftEnd', $weekEndUtcExclusive->format('Y-m-d H:i:s'));
1444        $stmt->execute();
1445
1446        $employeeIds = [];
1447        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
1448            $employeeIds[] = (int)$row['employeeId'];
1449        }
1450
1451        return $employeeIds;
1452    }
1453}