Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 1540
0.00% covered (danger)
0.00%
0 / 56
CRAP
0.00% covered (danger)
0.00%
0 / 1
TimePunchController
0.00% covered (danger)
0.00%
0 / 1540
0.00% covered (danger)
0.00%
0 / 56
115260
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getPredis
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getSchedulePanelCacheKey
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getSchedulePanelFromCache
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 saveSchedulePanelToCache
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 invalidateSchedulePanelCache
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 isWiwEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isBuyerKioskProvider
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isSchedulingEnabled
0.00% covered (danger)
0.00%
0 / 1
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
 getShiftRepository
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
 getSchedulingConfig
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 findScheduledShiftForEmployee
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
56
 validateClockInWindow
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 getUserIdForEmployee
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getEmployeeIdForUser
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getClockableEmployees
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 getClockableEmployeesForBuyerKiosk
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
12
 getClockableEmployeesForWhenIWork
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 getPunchState
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
90
 getPunchStateForBuyerKiosk
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
42
 getPunchStateForWhenIWork
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
20
 clockIn
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
90
 clockInForBuyerKiosk
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 1
342
 clockInForWhenIWork
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 clockOut
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
90
 clockOutForBuyerKiosk
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
42
 clockOutForWhenIWork
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
56
 startBreak
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
90
 startBreakForBuyerKiosk
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
56
 startBreakForWhenIWork
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
110
 endBreak
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
90
 endBreakForBuyerKiosk
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
42
 endBreakForWhenIWork
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
132
 overrideClockAction
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
110
 overrideClockActionForBuyerKiosk
0.00% covered (danger)
0.00%
0 / 159
0.00% covered (danger)
0.00%
0 / 1
306
 overrideClockActionForWhenIWork
0.00% covered (danger)
0.00%
0 / 119
0.00% covered (danger)
0.00%
0 / 1
756
 getSchedulePanelData
0.00% covered (danger)
0.00%
0 / 65
0.00% covered (danger)
0.00%
0 / 1
110
 getSchedulePanelDataForBuyerKiosk
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
110
 getSchedulePanelDataForWhenIWork
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
182
 getClockedInEmployeesNotScheduledBuyerKiosk
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
56
 selectMostRelevantShift
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
240
 getPunchStateFromWiw
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getClockedInEmployeesNotScheduled
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
56
 getEmployeeById
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 validateManagerOverride
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 checkManagerPermission
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
72
 checkManagerRole
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 calculateDuration
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 logPunchAction
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 ensurePunchLogTable
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getRoleColor
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 getRequestBody
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 jsonResponse
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 jsonError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace BuyerKiosk\Workbook\Controllers;
4
5use Exception;
6use BuyerKiosk\Employee\Employee;
7use BuyerKiosk\Workbook\WorkbookAbly;
8use BuyerKiosk\Workbook\WhenIWorkSchedule;
9use BuyerKiosk\Scheduling\BuyerKioskSchedule;
10use BuyerKiosk\Scheduling\Models\TimePunch;
11use BuyerKiosk\Scheduling\Repositories\TimePunchRepository;
12use BuyerKiosk\Scheduling\Repositories\TimePunchAuditRepository;
13use BuyerKiosk\Scheduling\Repositories\ShiftRepository;
14use BuyerKiosk\WhenIWork\Controllers\EmployeesController;
15use BuyerKiosk\TeamMember\Services\RoleConfigService;
16use DateTime;
17use DateTimeZone;
18
19/**
20 * Time Punch API Controller (Kiosk Mode)
21 *
22 * Handles clock in/out and break operations for employees using WhenIWork integration.
23 * Designed for shared kiosk/terminal use where employees select their name and enter a PIN.
24 *
25 * Endpoints:
26 * - GET  /api/:typeNum/workbook/timepunch/employees/  - Get list of clockable employees
27 * - POST /api/:typeNum/workbook/timepunch/state/      - Get punch state for specific employee (requires PIN)
28 * - POST /api/:typeNum/workbook/timepunch/clockin/    - Clock in (requires PIN)
29 * - POST /api/:typeNum/workbook/timepunch/clockout/   - Clock out (requires PIN)
30 * - POST /api/:typeNum/workbook/timepunch/break/start/ - Start break (requires PIN)
31 * - POST /api/:typeNum/workbook/timepunch/break/end/  - End break (requires PIN)
32 *
33 * @package BuyerKiosk\Workbook
34 */
35class TimePunchController
36{
37    private $app;
38    private \Store $store;
39    private \PDO $db;
40    private ?\Wheniwork $wiw = null;
41    private ?WorkbookAbly $ably = null;
42    private ?\Predis\Client $predis = null;
43    private ?RoleConfigService $roleConfigService = null;
44
45    /**
46     * @var string Active scheduling provider: 'buyerkiosk', 'wheniwork', 'homebase', or 'none'
47     */
48    private string $schedulingProvider;
49
50    /**
51     * @var TimePunchRepository|null Lazy-loaded repository for BuyerKiosk provider
52     */
53    private ?TimePunchRepository $timePunchRepository = null;
54
55    /**
56     * @var ShiftRepository|null Lazy-loaded repository for BuyerKiosk provider
57     */
58    private ?ShiftRepository $shiftRepository = null;
59
60    /**
61     * @var TimePunchAuditRepository|null Lazy-loaded repository for BuyerKiosk provider
62     */
63    private ?TimePunchAuditRepository $timePunchAuditRepository = null;
64
65    /**
66     * @var array|null Cached scheduling configuration from stores table
67     */
68    private ?array $schedulingConfig = null;
69
70    /**
71     * @var array<string, object|null> Cache of punch states by WhenIWork user ID
72     */
73    private array $punchStateCache = [];
74
75    /**
76     * @var int Schedule panel cache TTL in seconds (5 minutes)
77     */
78    private const SCHEDULE_PANEL_CACHE_TTL = 300;
79
80    public function __construct($app, \Store $store)
81    {
82        $this->app = $app;
83        $this->store = $store;
84        $this->db = dbConnectByName($store->getDbName());
85        $this->ably = new WorkbookAbly($store->getTypeNum());
86
87        // Determine the scheduling provider
88        $this->schedulingProvider = $store->getSchedulingProvider();
89
90        // Initialize WhenIWork client if using WhenIWork provider or legacy mode
91        if ($this->isWiwEnabled()) {
92            $this->wiw = new \Wheniwork($store->getWiwToken());
93        }
94
95        // Initialize RoleConfigService for role colors
96        try {
97            $centralDb = dbConnectByName($_ENV['DB_NAME']);
98            $this->roleConfigService = new RoleConfigService($centralDb, $store->getTypeNum());
99        } catch (\Exception $e) {
100            // If RoleConfigService fails, continue without it (fallback to defaults)
101            $this->roleConfigService = null;
102        }
103    }
104
105    /**
106     * Get Redis client (lazy-loaded)
107     *
108     * @return \Predis\Client Redis client
109     */
110    private function getPredis(): \Predis\Client
111    {
112        if ($this->predis === null) {
113            $this->predis = new \Predis\Client($_ENV['REDIS_URL']);
114        }
115        return $this->predis;
116    }
117
118    /**
119     * Get schedule panel cache key for today
120     *
121     * @return string Cache key in format {typeNum}_schedule_panel_{YYYY-MM-DD}
122     */
123    private function getSchedulePanelCacheKey(): string
124    {
125        $storeTimezone = new \DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
126        $today = new \DateTime('now', $storeTimezone);
127        return $this->store->getTypeNum() . '_schedule_panel_' . $today->format('Y-m-d');
128    }
129
130    /**
131     * Get schedule panel data from Redis cache
132     *
133     * @return array|null Cached data or null if not found/expired
134     */
135    private function getSchedulePanelFromCache(): ?array
136    {
137        $key = $this->getSchedulePanelCacheKey();
138        $cached = $this->getPredis()->get($key);
139
140        if (!$cached) {
141            return null;
142        }
143
144        $data = json_decode($cached, true);
145
146        // Validate cache structure
147        if (!isset($data['employees']) || !isset($data['lastUpdated'])) {
148            return null;
149        }
150
151        return $data;
152    }
153
154    /**
155     * Save schedule panel data to Redis cache
156     *
157     * @param array $data Schedule panel data to cache
158     * @return void
159     */
160    private function saveSchedulePanelToCache(array $data): void
161    {
162        $key = $this->getSchedulePanelCacheKey();
163        $this->getPredis()->setex($key, self::SCHEDULE_PANEL_CACHE_TTL, json_encode($data));
164    }
165
166    /**
167     * Invalidate schedule panel cache
168     *
169     * Call this when an employee's clock status changes (clock in/out, break start/end)
170     * to ensure the schedule panel shows current data.
171     *
172     * @param string $typeNum Store type number
173     * @return void
174     */
175    public static function invalidateSchedulePanelCache(string $typeNum): void
176    {
177        $predis = new \Predis\Client($_ENV['REDIS_URL']);
178        $storeTimezone = 'America/Chicago'; // Default timezone for key generation
179
180        // Try to get store timezone if possible
181        try {
182            $storeController = new \BuyerKiosk\StoreController($typeNum);
183            $store = $storeController->getStore();
184            if ($store) {
185                $storeTimezone = $store->getTimezone() ?: 'America/Chicago';
186            }
187        } catch (\Exception $e) {
188            // Use default timezone on error
189        }
190
191        $today = new \DateTime('now', new \DateTimeZone($storeTimezone));
192        $key = $typeNum . '_schedule_panel_' . $today->format('Y-m-d');
193        $predis->del($key);
194    }
195
196    /**
197     * Check if WhenIWork is enabled for this store
198     */
199    private function isWiwEnabled(): bool
200    {
201        return $this->store->getWiwEnable() > 0 && !empty($this->store->getWiwToken());
202    }
203
204    /**
205     * Check if BuyerKiosk native scheduling is the active provider
206     *
207     * @return bool True if BuyerKiosk is the scheduling provider
208     */
209    private function isBuyerKioskProvider(): bool
210    {
211        return $this->schedulingProvider === 'buyerkiosk';
212    }
213
214    /**
215     * Check if any scheduling provider is enabled (BuyerKiosk or WhenIWork)
216     *
217     * @return bool True if scheduling is enabled
218     */
219    private function isSchedulingEnabled(): bool
220    {
221        return $this->isBuyerKioskProvider() || $this->isWiwEnabled();
222    }
223
224    /**
225     * Get TimePunchRepository (lazy-loaded)
226     *
227     * @return TimePunchRepository
228     */
229    private function getTimePunchRepository(): TimePunchRepository
230    {
231        if ($this->timePunchRepository === null) {
232            $this->timePunchRepository = new TimePunchRepository($this->db);
233        }
234        return $this->timePunchRepository;
235    }
236
237    /**
238     * Get ShiftRepository (lazy-loaded)
239     *
240     * @return ShiftRepository
241     */
242    private function getShiftRepository(): ShiftRepository
243    {
244        if ($this->shiftRepository === null) {
245            $this->shiftRepository = new ShiftRepository($this->db);
246        }
247        return $this->shiftRepository;
248    }
249
250    /**
251     * Get TimePunchAuditRepository (lazy-loaded)
252     *
253     * @return TimePunchAuditRepository
254     */
255    private function getTimePunchAuditRepository(): TimePunchAuditRepository
256    {
257        if ($this->timePunchAuditRepository === null) {
258            $this->timePunchAuditRepository = new TimePunchAuditRepository($this->db);
259        }
260        return $this->timePunchAuditRepository;
261    }
262
263    /**
264     * Get scheduling configuration from stores table
265     *
266     * Includes clock window settings, manager override requirements, etc.
267     *
268     * @return array Scheduling configuration
269     */
270    private function getSchedulingConfig(): array
271    {
272        if ($this->schedulingConfig !== null) {
273            return $this->schedulingConfig;
274        }
275
276        try {
277            $buykioskDb = dbConnectByName('kiosk_buykiosk');
278            $stmt = $buykioskDb->prepare("
279                SELECT
280                    clockInEarlyMinutes,
281                    clockInLateMinutes,
282                    clockOutLateMinutes,
283                    requireManagerOverrideOutsideClockWindow,
284                    requireManagerOverrideForUnscheduledClockIn
285                FROM stores
286                WHERE typeNum = :typeNum
287                LIMIT 1
288            ");
289            $stmt->bindValue(':typeNum', $this->store->getTypeNum());
290            $stmt->execute();
291
292            $config = $stmt->fetch(\PDO::FETCH_ASSOC);
293
294            if ($config) {
295                $this->schedulingConfig = [
296                    'clockInEarlyMinutes' => (int)($config['clockInEarlyMinutes'] ?? 15),
297                    'clockInLateMinutes' => (int)($config['clockInLateMinutes'] ?? 15),
298                    'clockOutLateMinutes' => (int)($config['clockOutLateMinutes'] ?? 30),
299                    'requireManagerOverrideOutsideClockWindow' => (bool)($config['requireManagerOverrideOutsideClockWindow'] ?? true),
300                    'requireManagerOverrideForUnscheduledClockIn' => (bool)($config['requireManagerOverrideForUnscheduledClockIn'] ?? true),
301                ];
302            } else {
303                // Default configuration if not found
304                $this->schedulingConfig = [
305                    'clockInEarlyMinutes' => 15,
306                    'clockInLateMinutes' => 15,
307                    'clockOutLateMinutes' => 30,
308                    'requireManagerOverrideOutsideClockWindow' => true,
309                    'requireManagerOverrideForUnscheduledClockIn' => true,
310                ];
311            }
312        } catch (Exception $e) {
313            error_log("TimePunchController::getSchedulingConfig error: " . $e->getMessage());
314            // Return defaults on error
315            $this->schedulingConfig = [
316                'clockInEarlyMinutes' => 15,
317                'clockInLateMinutes' => 15,
318                'clockOutLateMinutes' => 30,
319                'requireManagerOverrideOutsideClockWindow' => true,
320                'requireManagerOverrideForUnscheduledClockIn' => true,
321            ];
322        }
323
324        return $this->schedulingConfig;
325    }
326
327    /**
328     * Find the scheduled shift for an employee at current time
329     *
330     * @param int $employeeId Employee ID (from employees table)
331     * @return array|null Shift data or null if not scheduled
332     */
333    private function findScheduledShiftForEmployee(int $employeeId): ?array
334    {
335        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
336        $now = new DateTime('now', $storeTimezone);
337
338        // For BuyerKiosk provider, the employeeId in scheduleShifts is the user ID from kiosk_users
339        // We need to look up the user_id for this employee
340        $userId = $this->getUserIdForEmployee($employeeId);
341        if (!$userId) {
342            return null;
343        }
344
345        // Look for shifts today using UTC bounds (shifts are stored in UTC)
346        $config = $this->getSchedulingConfig();
347        $dayStartLocal = clone $now;
348        $dayStartLocal->setTime(0, 0, 0);
349        $dayEndLocal = (clone $dayStartLocal)->modify('+1 day');
350
351        $dayStartUtc = clone $dayStartLocal;
352        $dayStartUtc->setTimezone(new DateTimeZone('UTC'));
353        $dayEndUtc = clone $dayEndLocal;
354        $dayEndUtc->setTimezone(new DateTimeZone('UTC'));
355
356        $shifts = $this->getShiftRepository()->findByEmployeeAndDateRange($userId, $dayStartUtc, $dayEndUtc);
357
358        if (empty($shifts)) {
359            return null;
360        }
361
362        // Find the most relevant shift (closest to now or currently in progress)
363        $clockInEarlyMinutes = $config['clockInEarlyMinutes'];
364        $clockInLateMinutes = $config['clockInLateMinutes'];
365        $clockOutLateMinutes = $config['clockOutLateMinutes'];
366
367        foreach ($shifts as $shift) {
368            $shiftStartUtc = $shift->getShiftStart();
369            $shiftEndUtc = $shift->getShiftEnd();
370
371            $shiftStart = clone $shiftStartUtc;
372            $shiftStart->setTimezone($storeTimezone);
373            $shiftEnd = clone $shiftEndUtc;
374            $shiftEnd->setTimezone($storeTimezone);
375
376            // Calculate clock window
377            $earliestClockIn = (clone $shiftStart)->modify("-{$clockInEarlyMinutes} minutes");
378            $latestClockIn = (clone $shiftStart)->modify("+{$clockInLateMinutes} minutes");
379            $latestClockOut = (clone $shiftEnd)->modify("+{$clockOutLateMinutes} minutes");
380
381            // Check if shift is currently in the clock window or in progress
382            if ($now >= $earliestClockIn && $now <= $latestClockOut) {
383                return [
384                    'shiftId' => $shift->getShiftId(),
385                    'employeeId' => $employeeId,
386                    'userId' => $userId,
387                    'shiftStart' => $shiftStart->format('Y-m-d H:i:s'),
388                    'shiftEnd' => $shiftEnd->format('Y-m-d H:i:s'),
389                    'positionId' => $shift->getPositionId(),
390                    'positionName' => $shift->getPositionName(),
391                    'earliestClockIn' => $earliestClockIn->format('Y-m-d H:i:s'),
392                    'latestClockIn' => $latestClockIn->format('Y-m-d H:i:s'),
393                    'latestClockOut' => $latestClockOut->format('Y-m-d H:i:s'),
394                ];
395            }
396        }
397
398        return null;
399    }
400
401    /**
402     * Validate if clock-in is within the allowed window
403     *
404     * @param array|null $shift Scheduled shift data
405     * @return array Validation result with 'valid', 'reason', and 'requiresOverride' keys
406     */
407    private function validateClockInWindow(?array $shift): array
408    {
409        $config = $this->getSchedulingConfig();
410
411        // No scheduled shift
412        if ($shift === null) {
413            if ($config['requireManagerOverrideForUnscheduledClockIn']) {
414                return [
415                    'valid' => false,
416                    'reason' => 'unscheduled',
417                    'requiresOverride' => true,
418                    'message' => 'No shift scheduled. Manager override required for unscheduled clock-in.'
419                ];
420            }
421            // Allow unscheduled clock-in without override
422            return ['valid' => true, 'reason' => null, 'requiresOverride' => false];
423        }
424
425        // Check if within clock window
426        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
427        $now = new DateTime('now', $storeTimezone);
428        $earliestClockIn = new DateTime($shift['earliestClockIn'], $storeTimezone);
429        $latestClockIn = new DateTime($shift['latestClockIn'], $storeTimezone);
430
431        if ($now < $earliestClockIn) {
432            if ($config['requireManagerOverrideOutsideClockWindow']) {
433                return [
434                    'valid' => false,
435                    'reason' => 'too_early',
436                    'requiresOverride' => true,
437                    'message' => 'Too early to clock in. Shift starts at ' . (new DateTime($shift['shiftStart'], $storeTimezone))->format('g:i A') . '.'
438                ];
439            }
440        }
441
442        if ($now > $latestClockIn) {
443            if ($config['requireManagerOverrideOutsideClockWindow']) {
444                return [
445                    'valid' => false,
446                    'reason' => 'too_late',
447                    'requiresOverride' => true,
448                    'message' => 'Late clock-in. Manager override required.'
449                ];
450            }
451        }
452
453        return ['valid' => true, 'reason' => null, 'requiresOverride' => false];
454    }
455
456    /**
457     * Get user ID from kiosk_users for an employee
458     *
459     * For BuyerKiosk scheduling, shifts and punches use user IDs from kiosk_users.users,
460     * not employee IDs from the store's employees table.
461     *
462     * @param int $employeeId Employee ID from employees table
463     * @return int|null User ID from kiosk_users.users, or null if not linked
464     */
465    private function getUserIdForEmployee(int $employeeId): ?int
466    {
467        try {
468            $userDb = dbConnectByName('kiosk_users');
469            $stmt = $userDb->prepare("
470                SELECT userId
471                FROM user_employee_links
472                WHERE employeeId = :employeeId
473                AND typeNum = :typeNum
474                LIMIT 1
475            ");
476            $stmt->execute([
477                ':employeeId' => $employeeId,
478                ':typeNum' => $this->store->getTypeNum()
479            ]);
480            $link = $stmt->fetch(\PDO::FETCH_ASSOC);
481
482            return $link ? (int)$link['userId'] : null;
483        } catch (Exception $e) {
484            error_log("TimePunchController::getUserIdForEmployee error: " . $e->getMessage());
485            return null;
486        }
487    }
488
489    /**
490     * Get employee ID from employees table for a user
491     *
492     * @param int $userId User ID from kiosk_users.users
493     * @return int|null Employee ID from employees table, or null if not linked
494     */
495    private function getEmployeeIdForUser(int $userId): ?int
496    {
497        try {
498            $userDb = dbConnectByName('kiosk_users');
499            $stmt = $userDb->prepare("
500                SELECT employeeId
501                FROM user_employee_links
502                WHERE userId = :userId
503                AND typeNum = :typeNum
504                LIMIT 1
505            ");
506            $stmt->execute([
507                ':userId' => $userId,
508                ':typeNum' => $this->store->getTypeNum()
509            ]);
510            $link = $stmt->fetch(\PDO::FETCH_ASSOC);
511
512            return $link ? (int)$link['employeeId'] : null;
513        } catch (Exception $e) {
514            error_log("TimePunchController::getEmployeeIdForUser error: " . $e->getMessage());
515            return null;
516        }
517    }
518
519    /**
520     * GET /api/:typeNum/workbook/timepunch/employees/
521     * Get list of employees who can clock in
522     *
523     * For BuyerKiosk provider: Returns employees with linked user accounts
524     * For WhenIWork provider: Returns employees with WhenIWork external IDs
525     */
526    public function getClockableEmployees(string $typeNum): void
527    {
528        try {
529            // Check if any scheduling provider is enabled
530            if (!$this->isSchedulingEnabled()) {
531                $this->jsonResponse([
532                    'success' => true,
533                    'data' => [
534                        'enabled' => false,
535                        'provider' => 'none',
536                        'message' => 'Scheduling is not enabled for this store',
537                        'employees' => []
538                    ]
539                ]);
540                return;
541            }
542
543            $employees = [];
544
545            if ($this->isBuyerKioskProvider()) {
546                // BuyerKiosk provider: Get employees with linked user accounts
547                $employees = $this->getClockableEmployeesForBuyerKiosk();
548            } else {
549                // WhenIWork provider: Get employees with WhenIWork external IDs
550                $employees = $this->getClockableEmployeesForWhenIWork();
551            }
552
553            $this->jsonResponse([
554                'success' => true,
555                'data' => [
556                    'enabled' => true,
557                    'provider' => $this->schedulingProvider,
558                    'employees' => $employees
559                ]
560            ]);
561        } catch (Exception $e) {
562            error_log("TimePunchController::getClockableEmployees error: " . $e->getMessage());
563            $this->jsonError('Failed to get employees: ' . $e->getMessage(), 500);
564        }
565    }
566
567    /**
568     * Get clockable employees for BuyerKiosk native provider
569     *
570     * Returns employees who have linked user accounts in user_employee_links.
571     *
572     * @return array Employee data
573     */
574    private function getClockableEmployeesForBuyerKiosk(): array
575    {
576        // Get employees with linked user accounts
577        $userDb = dbConnectByName('kiosk_users');
578        $stmt = $userDb->prepare("
579            SELECT uel.employeeId, uel.userId
580            FROM user_employee_links uel
581            INNER JOIN users u ON uel.userId = u.id
582            WHERE uel.typeNum = :typeNum
583            AND u.enabled = 1
584        ");
585        $stmt->execute([':typeNum' => $this->store->getTypeNum()]);
586        $links = $stmt->fetchAll(\PDO::FETCH_ASSOC);
587
588        if (empty($links)) {
589            return [];
590        }
591
592        // Get employee IDs that have linked accounts
593        $linkedEmployeeIds = array_column($links, 'employeeId');
594        $placeholders = implode(',', array_fill(0, count($linkedEmployeeIds), '?'));
595
596        // Fetch employee details from store database
597        $stmt = $this->db->prepare("
598            SELECT employeeID, employeeFirstName, employeeLastName, photoUrl,
599                   position, clockPin IS NOT NULL as hasPin
600            FROM employees
601            WHERE active = 1
602            AND employeeID IN ({$placeholders})
603            ORDER BY employeeFirstName, employeeLastName
604        ");
605        $stmt->execute($linkedEmployeeIds);
606        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
607
608        $employees = [];
609        foreach ($rows as $row) {
610            $employees[] = [
611                'id' => (int)$row['employeeID'],
612                'firstName' => $row['employeeFirstName'],
613                'lastName' => $row['employeeLastName'],
614                'fullName' => trim($row['employeeFirstName'] . ' ' . $row['employeeLastName']),
615                'photoUrl' => $row['photoUrl'],
616                'position' => $row['position'],
617                'hasPin' => (bool)$row['hasPin']
618            ];
619        }
620
621        return $employees;
622    }
623
624    /**
625     * Get clockable employees for WhenIWork provider
626     *
627     * @return array Employee data
628     */
629    private function getClockableEmployeesForWhenIWork(): array
630    {
631        $stmt = $this->db->prepare("
632            SELECT employeeID, employeeFirstName, employeeLastName, photoUrl,
633                   position, externalId, clockPin IS NOT NULL as hasPin
634            FROM employees
635            WHERE active = 1
636            AND source = 'wheniwork'
637            AND externalId IS NOT NULL
638            AND externalId != ''
639            ORDER BY employeeFirstName, employeeLastName
640        ");
641        $stmt->execute();
642        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
643
644        $employees = [];
645        foreach ($rows as $row) {
646            $employees[] = [
647                'id' => (int)$row['employeeID'],
648                'firstName' => $row['employeeFirstName'],
649                'lastName' => $row['employeeLastName'],
650                'fullName' => trim($row['employeeFirstName'] . ' ' . $row['employeeLastName']),
651                'photoUrl' => $row['photoUrl'],
652                'position' => $row['position'],
653                'hasPin' => (bool)$row['hasPin']
654            ];
655        }
656
657        return $employees;
658    }
659
660    /**
661     * POST /api/:typeNum/workbook/timepunch/state/
662     * Get current punch state for an employee (requires PIN verification)
663     *
664     * Request body:
665     * - employeeId: int (required)
666     * - pin: string (required)
667     */
668    public function getPunchState(string $typeNum): void
669    {
670        try {
671            if (!$this->isSchedulingEnabled()) {
672                $this->jsonResponse([
673                    'success' => true,
674                    'data' => [
675                        'enabled' => false,
676                        'provider' => 'none',
677                        'message' => 'Scheduling is not enabled for this store'
678                    ]
679                ]);
680                return;
681            }
682
683            $data = $this->getRequestBody();
684            $employeeId = $data['employeeId'] ?? null;
685            $pin = $data['pin'] ?? null;
686
687            if (!$employeeId) {
688                $this->jsonError('Employee ID is required', 400);
689                return;
690            }
691
692            // Get employee and verify PIN
693            $employee = $this->getEmployeeById($employeeId);
694            if (!$employee) {
695                $this->jsonError('Employee not found', 404);
696                return;
697            }
698
699            // Verify PIN if employee has one set
700            if ($employee['clockPin']) {
701                if (!$pin) {
702                    $this->jsonError('PIN is required', 400);
703                    return;
704                }
705                if ($employee['clockPin'] !== $pin) {
706                    $this->jsonError('Invalid PIN', 401);
707                    return;
708                }
709            }
710
711            // Route to appropriate provider
712            if ($this->isBuyerKioskProvider()) {
713                $punchState = $this->getPunchStateForBuyerKiosk((int)$employeeId, $employee);
714            } else {
715                $punchState = $this->getPunchStateForWhenIWork((int)$employeeId, $employee);
716            }
717
718            $this->jsonResponse([
719                'success' => true,
720                'data' => $punchState
721            ]);
722        } catch (Exception $e) {
723            error_log("TimePunchController::getPunchState error: " . $e->getMessage());
724            $this->jsonError('Failed to get punch state: ' . $e->getMessage(), 500);
725        }
726    }
727
728    /**
729     * Get punch state for BuyerKiosk native provider
730     *
731     * @param int $employeeId Employee ID
732     * @param array $employee Employee data
733     * @return array Punch state data
734     */
735    private function getPunchStateForBuyerKiosk(int $employeeId, array $employee): array
736    {
737        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
738        $userId = $this->getUserIdForEmployee($employeeId);
739
740        if (!$userId) {
741            return [
742                'enabled' => true,
743                'provider' => 'buyerkiosk',
744                'employeeId' => $employeeId,
745                'employeeName' => $employeeName,
746                'canClockIn' => false,
747                'canClockOut' => false,
748                'canStartBreak' => false,
749                'canEndBreak' => false,
750                'errorCode' => 'NO_USER_LINK',
751                'message' => 'Employee is not linked to a user account'
752            ];
753        }
754
755        // Get active session from TimePunchRepository
756        $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
757        $isOnBreak = $this->getTimePunchRepository()->isOnBreak($userId);
758
759        // Get scheduled shift for clock window validation
760        $scheduledShift = $this->findScheduledShiftForEmployee($employeeId);
761        $clockValidation = $this->validateClockInWindow($scheduledShift);
762
763        $punchState = [
764            'enabled' => true,
765            'provider' => 'buyerkiosk',
766            'employeeId' => $employeeId,
767            'userId' => $userId,
768            'employeeName' => $employeeName,
769            'canClockIn' => $activeSession === null,
770            'canClockOut' => $activeSession !== null && !$isOnBreak,
771            'canStartBreak' => $activeSession !== null && !$isOnBreak,
772            'canEndBreak' => $isOnBreak,
773            'punchStartTime' => $activeSession?->getPunchTime()->format('Y-m-d\TH:i:s\Z'),
774            'punchId' => $activeSession?->getPunchId(),
775            'shift' => $scheduledShift,
776            'clockWindowValidation' => $clockValidation,
777            'requiresOverride' => !$clockValidation['valid'] && $clockValidation['requiresOverride'],
778            'errorCode' => null
779        ];
780
781        // Calculate duration if clocked in
782        if ($punchState['punchStartTime']) {
783            $punchState['clockedInDuration'] = $this->calculateDuration($punchState['punchStartTime']);
784        }
785
786        return $punchState;
787    }
788
789    /**
790     * Get punch state for WhenIWork provider
791     *
792     * @param int $employeeId Employee ID
793     * @param array $employee Employee data
794     * @return array Punch state data
795     */
796    private function getPunchStateForWhenIWork(int $employeeId, array $employee): array
797    {
798        $wiwUserId = $employee['externalId'];
799        if (!$wiwUserId) {
800            throw new Exception('Employee is not linked to WhenIWork');
801        }
802
803        // Call WhenIWork punch state API
804        $result = $this->wiw->get('punch/state', [
805            'userId' => $wiwUserId,
806            'locationId' => $this->store->getWiwLocationID(),
807            'deviceType' => 'terminal'
808        ]);
809
810        if (!$result) {
811            throw new Exception('Failed to get punch state from WhenIWork');
812        }
813
814        // Format response
815        $punchState = [
816            'enabled' => true,
817            'provider' => 'wheniwork',
818            'employeeId' => $employeeId,
819            'employeeName' => trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']),
820            'wiwUserId' => (int)$wiwUserId,
821            'canClockIn' => $result->canClockIn ?? false,
822            'canClockOut' => $result->canClockOut ?? false,
823            'canStartBreak' => $result->canStartBreak ?? false,
824            'canEndBreak' => $result->canEndBreak ?? false,
825            'punchStartTime' => $result->punchStartTime ?? null,
826            'punchTimeId' => $result->punchTimeId ?? null,
827            'shift' => $result->shift ?? null,
828            'break' => $result->break ?? null,
829            'scheduledBreaks' => $result->scheduledBreaks ?? [],
830            'unscheduledBreaks' => $result->unscheduledBreaks ?? [],
831            'availableShifts' => $result->availableShifts ?? [],
832            'errorCode' => $result->errorCode ?? null
833        ];
834
835        // Calculate duration if clocked in
836        if ($punchState['punchStartTime']) {
837            $punchState['clockedInDuration'] = $this->calculateDuration($punchState['punchStartTime']);
838        }
839
840        return $punchState;
841    }
842
843    /**
844     * POST /api/:typeNum/workbook/timepunch/clockin/
845     * Clock in an employee (requires PIN verification)
846     *
847     * Request body:
848     * - employeeId: int (required)
849     * - pin: string (required if employee has PIN)
850     * - shiftId: int (optional, 0 for unscheduled)
851     * - notes: string (optional)
852     * - managerOverride: bool (optional, for clock window violations)
853     * - managerPin: string (required if managerOverride is true)
854     * - overrideReason: string (optional, reason for override)
855     */
856    public function clockIn(string $typeNum): void
857    {
858        try {
859            if (!$this->isSchedulingEnabled()) {
860                $this->jsonError('Scheduling is not enabled for this store', 400);
861                return;
862            }
863
864            $data = $this->getRequestBody();
865            $employeeId = $data['employeeId'] ?? null;
866            $pin = $data['pin'] ?? null;
867
868            if (!$employeeId) {
869                $this->jsonError('Employee ID is required', 400);
870                return;
871            }
872
873            // Get employee and verify PIN
874            $employee = $this->getEmployeeById($employeeId);
875            if (!$employee) {
876                $this->jsonError('Employee not found', 404);
877                return;
878            }
879
880            if ($employee['clockPin']) {
881                if (!$pin) {
882                    $this->jsonError('PIN is required', 400);
883                    return;
884                }
885                if ($employee['clockPin'] !== $pin) {
886                    $this->jsonError('Invalid PIN', 401);
887                    return;
888                }
889            }
890
891            // Route to appropriate provider
892            if ($this->isBuyerKioskProvider()) {
893                $this->clockInForBuyerKiosk($typeNum, (int)$employeeId, $employee, $data);
894            } else {
895                $this->clockInForWhenIWork($typeNum, (int)$employeeId, $employee, $data);
896            }
897        } catch (Exception $e) {
898            error_log("TimePunchController::clockIn error: " . $e->getMessage());
899            $this->jsonError('Failed to clock in: ' . $e->getMessage(), 500);
900        }
901    }
902
903    /**
904     * Clock in for BuyerKiosk native provider
905     *
906     * @param string $typeNum Store identifier
907     * @param int $employeeId Employee ID
908     * @param array $employee Employee data
909     * @param array $data Request data
910     */
911    private function clockInForBuyerKiosk(string $typeNum, int $employeeId, array $employee, array $data): void
912    {
913        $userId = $this->getUserIdForEmployee($employeeId);
914        if (!$userId) {
915            $this->jsonError('Employee is not linked to a user account', 400);
916            return;
917        }
918
919        // Check if already clocked in
920        $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
921        if ($activeSession !== null) {
922            $this->jsonError('Employee is already clocked in', 400);
923            return;
924        }
925
926        // Get scheduled shift and validate clock window
927        $scheduledShift = $this->findScheduledShiftForEmployee($employeeId);
928        $clockValidation = $this->validateClockInWindow($scheduledShift);
929
930        $managerUserId = null;
931        $managerEmployeeId = null;
932        $overrideReason = $data['overrideReason'] ?? null;
933        $isManagerOverride = false;
934        $requiresOverride = !$clockValidation['valid'] && $clockValidation['requiresOverride'];
935
936        // If clock window validation fails, require manager override
937        if ($requiresOverride) {
938            if (empty($data['managerOverride'])) {
939                $this->jsonResponse([
940                    'success' => false,
941                    'requiresOverride' => true,
942                    'reason' => $clockValidation['reason'],
943                    'message' => $clockValidation['message']
944                ], 200);
945                return;
946            }
947
948            // Validate manager override
949            $managerPin = $data['managerPin'] ?? null;
950            if (!$managerPin) {
951                $this->jsonError('Manager PIN is required for override', 400);
952                return;
953            }
954
955            $manager = $this->validateManagerOverride($managerPin);
956            if (!$manager) {
957                $this->jsonError('Invalid manager PIN or insufficient permissions', 401);
958                return;
959            }
960
961            $managerEmployeeId = (int)$manager['employeeID'];
962            $managerUserId = $this->getUserIdForEmployee($managerEmployeeId);
963            $isManagerOverride = true;
964        }
965
966        // Create the clock-in punch
967        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
968        $punchTime = new DateTime('now', $storeTimezone);
969        $punchTime->setTimezone(new DateTimeZone('UTC')); // Store in UTC
970
971        $punch = new TimePunch(
972            $userId,
973            TimePunch::TYPE_CLOCK_IN,
974            $punchTime,
975            $userId // enteredByUserId - the employee themselves
976        );
977
978        // Set shift association if scheduled
979        if ($scheduledShift && isset($scheduledShift['shiftId'])) {
980            $punch->setShiftId($scheduledShift['shiftId']);
981        }
982
983        // Set flags
984        $punch->setIsUnscheduled($scheduledShift === null);
985        $punch->setIsManagerOverride($isManagerOverride);
986
987        if ($isManagerOverride && $managerUserId) {
988            $punch->setApproval($managerUserId, $overrideReason);
989        }
990
991        // Persist the punch
992        $createdPunch = $this->getTimePunchRepository()->create($punch);
993
994        // Log audit trail
995        $auditNote = null;
996        if ($isManagerOverride) {
997            $auditNote = "Clock-in with manager override: " . ($clockValidation['reason'] ?? 'override');
998            if (!$managerUserId && $managerEmployeeId) {
999                $auditNote .= " (managerEmployeeId: {$managerEmployeeId})";
1000            }
1001        }
1002        $this->getTimePunchAuditRepository()->logCreate(
1003            $createdPunch->getPunchId(),
1004            $userId,
1005            $createdPunch->toDbArray(),
1006            $auditNote
1007        );
1008
1009        // If manager override was used, log that specifically
1010        if ($isManagerOverride && $managerUserId) {
1011            $this->getTimePunchAuditRepository()->logManagerOverride(
1012                $createdPunch->getPunchId(),
1013                $managerUserId,
1014                $createdPunch->toDbArray(),
1015                $clockValidation['reason'] ?? 'manual_override',
1016                $overrideReason ?? 'Manager override for clock-in'
1017            );
1018        }
1019
1020        // Log to workbook punch log table
1021        $this->logPunchAction($employeeId, 'clock_in', [
1022            'punchId' => $createdPunch->getPunchId(),
1023            'shiftId' => $scheduledShift['shiftId'] ?? null,
1024            'isUnscheduled' => $scheduledShift === null,
1025            'isManagerOverride' => $isManagerOverride
1026        ]);
1027
1028        // Invalidate caches
1029        self::invalidateSchedulePanelCache($typeNum);
1030
1031        // Broadcast clock-in event via Ably (always, regardless of provider)
1032        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1033        $startTime = $punchTime->format('Y-m-d\TH:i:s\Z');
1034        $this->ably->employeeClockedIn($employeeId, $employeeName, $startTime);
1035
1036        $this->jsonResponse([
1037            'success' => true,
1038            'message' => 'Successfully clocked in',
1039            'data' => [
1040                'provider' => 'buyerkiosk',
1041                'punchId' => $createdPunch->getPunchId(),
1042                'startTime' => $startTime,
1043                'shiftId' => $scheduledShift['shiftId'] ?? null,
1044                'isUnscheduled' => $scheduledShift === null,
1045                'isManagerOverride' => $isManagerOverride
1046            ]
1047        ]);
1048    }
1049
1050    /**
1051     * Clock in for WhenIWork provider
1052     *
1053     * @param string $typeNum Store identifier
1054     * @param int $employeeId Employee ID
1055     * @param array $employee Employee data
1056     * @param array $data Request data
1057     */
1058    private function clockInForWhenIWork(string $typeNum, int $employeeId, array $employee, array $data): void
1059    {
1060        $wiwUserId = $employee['externalId'];
1061        if (!$wiwUserId) {
1062            $this->jsonError('Employee is not linked to WhenIWork', 400);
1063            return;
1064        }
1065
1066        // Build clock in request
1067        $clockInData = [
1068            'id' => (int)$wiwUserId,
1069            'location_id' => $this->store->getWiwLocationID(),
1070            'terminal' => true  // Kiosk/terminal clock in
1071        ];
1072
1073        if (isset($data['shiftId'])) {
1074            $clockInData['shift_id'] = (int)$data['shiftId'];
1075        }
1076
1077        if (!empty($data['notes'])) {
1078            $clockInData['notes'] = $data['notes'];
1079        }
1080
1081        // Call WhenIWork clock in API
1082        $result = $this->wiw->post('times/clockin', $clockInData);
1083
1084        if (!$result) {
1085            throw new Exception('Failed to clock in via WhenIWork');
1086        }
1087
1088        if (isset($result->error) || isset($result->errors)) {
1089            $errorMsg = isset($result->error) ? $result->error : implode(', ', (array)$result->errors);
1090            throw new Exception($errorMsg);
1091        }
1092
1093        // Log the action
1094        $this->logPunchAction($employeeId, 'clock_in', $result);
1095
1096        // Invalidate caches
1097        EmployeesController::invalidateClockedInCache($typeNum);
1098        self::invalidateSchedulePanelCache($typeNum);
1099
1100        // Broadcast clock-in event via Ably
1101        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1102        $startTime = $result->time->start_time ?? null;
1103        $this->ably->employeeClockedIn($employeeId, $employeeName, $startTime);
1104
1105        $this->jsonResponse([
1106            'success' => true,
1107            'message' => 'Successfully clocked in',
1108            'data' => [
1109                'provider' => 'wheniwork',
1110                'time' => $result->time ?? null,
1111                'punchTimeId' => $result->time->id ?? null,
1112                'startTime' => $startTime
1113            ]
1114        ]);
1115    }
1116
1117    /**
1118     * POST /api/:typeNum/workbook/timepunch/clockout/
1119     * Clock out an employee (requires PIN verification)
1120     */
1121    public function clockOut(string $typeNum): void
1122    {
1123        try {
1124            if (!$this->isSchedulingEnabled()) {
1125                $this->jsonError('Scheduling is not enabled for this store', 400);
1126                return;
1127            }
1128
1129            $data = $this->getRequestBody();
1130            $employeeId = $data['employeeId'] ?? null;
1131            $pin = $data['pin'] ?? null;
1132
1133            if (!$employeeId) {
1134                $this->jsonError('Employee ID is required', 400);
1135                return;
1136            }
1137
1138            $employee = $this->getEmployeeById($employeeId);
1139            if (!$employee) {
1140                $this->jsonError('Employee not found', 404);
1141                return;
1142            }
1143
1144            if ($employee['clockPin']) {
1145                if (!$pin) {
1146                    $this->jsonError('PIN is required', 400);
1147                    return;
1148                }
1149                if ($employee['clockPin'] !== $pin) {
1150                    $this->jsonError('Invalid PIN', 401);
1151                    return;
1152                }
1153            }
1154
1155            // Route to appropriate provider
1156            if ($this->isBuyerKioskProvider()) {
1157                $this->clockOutForBuyerKiosk($typeNum, (int)$employeeId, $employee, $data);
1158            } else {
1159                $this->clockOutForWhenIWork($typeNum, (int)$employeeId, $employee, $data);
1160            }
1161        } catch (Exception $e) {
1162            error_log("TimePunchController::clockOut error: " . $e->getMessage());
1163            $this->jsonError('Failed to clock out: ' . $e->getMessage(), 500);
1164        }
1165    }
1166
1167    /**
1168     * Clock out for BuyerKiosk native provider
1169     *
1170     * @param string $typeNum Store identifier
1171     * @param int $employeeId Employee ID
1172     * @param array $employee Employee data
1173     * @param array $data Request data
1174     */
1175    private function clockOutForBuyerKiosk(string $typeNum, int $employeeId, array $employee, array $data): void
1176    {
1177        $userId = $this->getUserIdForEmployee($employeeId);
1178        if (!$userId) {
1179            $this->jsonError('Employee is not linked to a user account', 400);
1180            return;
1181        }
1182
1183        // Check if employee is clocked in
1184        $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
1185        if ($activeSession === null) {
1186            $this->jsonError('Employee is not clocked in', 400);
1187            return;
1188        }
1189
1190        // Check if on break - must end break first
1191        if ($this->getTimePunchRepository()->isOnBreak($userId)) {
1192            $this->jsonError('Please end your break before clocking out', 400);
1193            return;
1194        }
1195
1196        // Create the clock-out punch
1197        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
1198        $punchTime = new DateTime('now', $storeTimezone);
1199        $punchTime->setTimezone(new DateTimeZone('UTC')); // Store in UTC
1200
1201        $punch = new TimePunch(
1202            $userId,
1203            TimePunch::TYPE_CLOCK_OUT,
1204            $punchTime,
1205            $userId // enteredByUserId - the employee themselves
1206        );
1207
1208        // Associate with the same shift as clock-in
1209        if ($activeSession->getShiftId()) {
1210            $punch->setShiftId($activeSession->getShiftId());
1211        }
1212
1213        // Persist the punch
1214        $createdPunch = $this->getTimePunchRepository()->create($punch);
1215
1216        // Log audit trail
1217        $this->getTimePunchAuditRepository()->logCreate(
1218            $createdPunch->getPunchId(),
1219            $userId,
1220            $createdPunch->toDbArray(),
1221            null
1222        );
1223
1224        // Log to workbook punch log table
1225        $this->logPunchAction($employeeId, 'clock_out', [
1226            'punchId' => $createdPunch->getPunchId(),
1227            'shiftId' => $activeSession->getShiftId()
1228        ]);
1229
1230        // Invalidate caches
1231        self::invalidateSchedulePanelCache($typeNum);
1232
1233        // Broadcast clock-out event via Ably
1234        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1235        $endTime = $punchTime->format('Y-m-d\TH:i:s\Z');
1236        $this->ably->employeeClockedOut($employeeId, $employeeName, $endTime);
1237
1238        $this->jsonResponse([
1239            'success' => true,
1240            'message' => 'Successfully clocked out',
1241            'data' => [
1242                'provider' => 'buyerkiosk',
1243                'punchId' => $createdPunch->getPunchId(),
1244                'endTime' => $endTime
1245            ]
1246        ]);
1247    }
1248
1249    /**
1250     * Clock out for WhenIWork provider
1251     *
1252     * @param string $typeNum Store identifier
1253     * @param int $employeeId Employee ID
1254     * @param array $employee Employee data
1255     * @param array $data Request data
1256     */
1257    private function clockOutForWhenIWork(string $typeNum, int $employeeId, array $employee, array $data): void
1258    {
1259        $wiwUserId = $employee['externalId'];
1260        if (!$wiwUserId) {
1261            $this->jsonError('Employee is not linked to WhenIWork', 400);
1262            return;
1263        }
1264
1265        $clockOutData = [
1266            'id' => (int)$wiwUserId,
1267            'terminal' => true
1268        ];
1269
1270        if (!empty($data['notes'])) {
1271            $clockOutData['notes'] = $data['notes'];
1272        }
1273
1274        $result = $this->wiw->post('times/clockout', $clockOutData);
1275
1276        if (!$result) {
1277            throw new Exception('Failed to clock out via WhenIWork');
1278        }
1279
1280        if (isset($result->error) || isset($result->errors)) {
1281            $errorMsg = isset($result->error) ? $result->error : implode(', ', (array)$result->errors);
1282            throw new Exception($errorMsg);
1283        }
1284
1285        $this->logPunchAction($employeeId, 'clock_out', $result);
1286
1287        // Invalidate caches
1288        EmployeesController::invalidateClockedInCache($typeNum);
1289        self::invalidateSchedulePanelCache($typeNum);
1290
1291        // Broadcast clock-out event via Ably
1292        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1293        $endTime = $result->time->end_time ?? null;
1294        $this->ably->employeeClockedOut($employeeId, $employeeName, $endTime);
1295
1296        $this->jsonResponse([
1297            'success' => true,
1298            'message' => 'Successfully clocked out',
1299            'data' => [
1300                'provider' => 'wheniwork',
1301                'time' => $result->time ?? null,
1302                'endTime' => $endTime
1303            ]
1304        ]);
1305    }
1306
1307    /**
1308     * POST /api/:typeNum/workbook/timepunch/break/start/
1309     * Start a break (requires PIN verification)
1310     *
1311     * Request body:
1312     * - employeeId: int (required)
1313     * - pin: string (required if employee has PIN)
1314     * - breakType: string (optional, 'paid' or 'unpaid', default 'unpaid')
1315     */
1316    public function startBreak(string $typeNum): void
1317    {
1318        try {
1319            if (!$this->isSchedulingEnabled()) {
1320                $this->jsonError('Scheduling is not enabled for this store', 400);
1321                return;
1322            }
1323
1324            $data = $this->getRequestBody();
1325            $employeeId = $data['employeeId'] ?? null;
1326            $pin = $data['pin'] ?? null;
1327
1328            if (!$employeeId) {
1329                $this->jsonError('Employee ID is required', 400);
1330                return;
1331            }
1332
1333            $employee = $this->getEmployeeById($employeeId);
1334            if (!$employee) {
1335                $this->jsonError('Employee not found', 404);
1336                return;
1337            }
1338
1339            if ($employee['clockPin']) {
1340                if (!$pin) {
1341                    $this->jsonError('PIN is required', 400);
1342                    return;
1343                }
1344                if ($employee['clockPin'] !== $pin) {
1345                    $this->jsonError('Invalid PIN', 401);
1346                    return;
1347                }
1348            }
1349
1350            // Route to appropriate provider
1351            if ($this->isBuyerKioskProvider()) {
1352                $this->startBreakForBuyerKiosk($typeNum, (int)$employeeId, $employee, $data);
1353            } else {
1354                $this->startBreakForWhenIWork($typeNum, (int)$employeeId, $employee, $data);
1355            }
1356        } catch (Exception $e) {
1357            error_log("TimePunchController::startBreak error: " . $e->getMessage());
1358            $this->jsonError('Failed to start break: ' . $e->getMessage(), 500);
1359        }
1360    }
1361
1362    /**
1363     * Start break for BuyerKiosk native provider
1364     *
1365     * @param string $typeNum Store identifier
1366     * @param int $employeeId Employee ID
1367     * @param array $employee Employee data
1368     * @param array $data Request data
1369     */
1370    private function startBreakForBuyerKiosk(string $typeNum, int $employeeId, array $employee, array $data): void
1371    {
1372        $userId = $this->getUserIdForEmployee($employeeId);
1373        if (!$userId) {
1374            $this->jsonError('Employee is not linked to a user account', 400);
1375            return;
1376        }
1377
1378        // Check if employee is clocked in
1379        $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
1380        if ($activeSession === null) {
1381            $this->jsonError('Employee must be clocked in to start a break', 400);
1382            return;
1383        }
1384
1385        // Check if already on break
1386        if ($this->getTimePunchRepository()->isOnBreak($userId)) {
1387            $this->jsonError('Employee is already on break', 400);
1388            return;
1389        }
1390
1391        // Create the break start punch
1392        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
1393        $punchTime = new DateTime('now', $storeTimezone);
1394        $punchTime->setTimezone(new DateTimeZone('UTC')); // Store in UTC
1395
1396        $punch = new TimePunch(
1397            $userId,
1398            TimePunch::TYPE_BREAK_START,
1399            $punchTime,
1400            $userId
1401        );
1402
1403        // Set break type (default to unpaid)
1404        $breakType = $data['breakType'] ?? 'unpaid';
1405        $punch->setBreakType($breakType === 'paid' ? TimePunch::BREAK_TYPE_PAID : TimePunch::BREAK_TYPE_UNPAID);
1406
1407        // Associate with the same shift as clock-in
1408        if ($activeSession->getShiftId()) {
1409            $punch->setShiftId($activeSession->getShiftId());
1410        }
1411
1412        // Persist the punch
1413        $createdPunch = $this->getTimePunchRepository()->create($punch);
1414
1415        // Log audit trail
1416        $this->getTimePunchAuditRepository()->logCreate(
1417            $createdPunch->getPunchId(),
1418            $userId,
1419            $createdPunch->toDbArray(),
1420            null
1421        );
1422
1423        // Log to workbook punch log table
1424        $this->logPunchAction($employeeId, 'break_start', [
1425            'punchId' => $createdPunch->getPunchId(),
1426            'breakType' => $breakType
1427        ]);
1428
1429        // Invalidate schedule panel cache
1430        self::invalidateSchedulePanelCache($typeNum);
1431
1432        // Broadcast break start event via Ably
1433        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1434        $breakStartTime = $punchTime->format('Y-m-d\TH:i:s\Z');
1435        $this->ably->employeeBreakStarted($employeeId, $employeeName, $breakStartTime);
1436
1437        $this->jsonResponse([
1438            'success' => true,
1439            'message' => 'Break started',
1440            'data' => [
1441                'provider' => 'buyerkiosk',
1442                'punchId' => $createdPunch->getPunchId(),
1443                'startTime' => $breakStartTime,
1444                'breakType' => $breakType
1445            ]
1446        ]);
1447    }
1448
1449    /**
1450     * Start break for WhenIWork provider
1451     *
1452     * @param string $typeNum Store identifier
1453     * @param int $employeeId Employee ID
1454     * @param array $employee Employee data
1455     * @param array $data Request data
1456     */
1457    private function startBreakForWhenIWork(string $typeNum, int $employeeId, array $employee, array $data): void
1458    {
1459        $wiwUserId = $employee['externalId'];
1460        if (!$wiwUserId) {
1461            $this->jsonError('Employee is not linked to WhenIWork', 400);
1462            return;
1463        }
1464
1465        // Get punch state to find the current time ID
1466        $punchState = $this->wiw->get('punch/state', [
1467            'userId' => $wiwUserId,
1468            'locationId' => $this->store->getWiwLocationID(),
1469            'deviceType' => 'terminal'
1470        ]);
1471
1472        if (!$punchState || !isset($punchState->punchTimeId)) {
1473            $this->jsonError('Employee must be clocked in to start a break', 400);
1474            return;
1475        }
1476
1477        // Build break request
1478        $breakData = [
1479            'timeId' => $punchState->punchTimeId,
1480            'start' => (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'),
1481            'type' => isset($data['type']) ? (int)$data['type'] : 2  // Default unpaid
1482        ];
1483
1484        if (isset($data['scheduledBreakId'])) {
1485            unset($breakData['type']);
1486            $breakData['scheduledBreakId'] = (int)$data['scheduledBreakId'];
1487        }
1488
1489        // Use v3 endpoint
1490        $this->wiw->setEndpoint('https://api.wheniwork.com');
1491        $result = $this->wiw->post('v3/shift-breaks', $breakData);
1492        $this->wiw->setEndpoint('https://api.wheniwork.com/2');
1493
1494        if (!$result) {
1495            throw new Exception('Failed to start break via WhenIWork');
1496        }
1497
1498        if (isset($result->error) || isset($result->errors)) {
1499            $errorMsg = isset($result->error) ? $result->error : implode(', ', (array)$result->errors);
1500            throw new Exception($errorMsg);
1501        }
1502
1503        $this->logPunchAction($employeeId, 'break_start', $result);
1504
1505        // Invalidate schedule panel cache
1506        self::invalidateSchedulePanelCache($typeNum);
1507
1508        // Broadcast break start event via Ably
1509        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1510        $breakStartTime = $result->data->start ?? null;
1511        $this->ably->employeeBreakStarted($employeeId, $employeeName, $breakStartTime);
1512
1513        $this->jsonResponse([
1514            'success' => true,
1515            'message' => 'Break started',
1516            'data' => [
1517                'provider' => 'wheniwork',
1518                'breakId' => $result->data->id ?? null,
1519                'startTime' => $breakStartTime,
1520                'type' => $result->data->type ?? null
1521            ]
1522        ]);
1523    }
1524
1525    /**
1526     * POST /api/:typeNum/workbook/timepunch/break/end/
1527     * End a break (requires PIN verification)
1528     */
1529    public function endBreak(string $typeNum): void
1530    {
1531        try {
1532            if (!$this->isSchedulingEnabled()) {
1533                $this->jsonError('Scheduling is not enabled for this store', 400);
1534                return;
1535            }
1536
1537            $data = $this->getRequestBody();
1538            $employeeId = $data['employeeId'] ?? null;
1539            $pin = $data['pin'] ?? null;
1540
1541            if (!$employeeId) {
1542                $this->jsonError('Employee ID is required', 400);
1543                return;
1544            }
1545
1546            $employee = $this->getEmployeeById($employeeId);
1547            if (!$employee) {
1548                $this->jsonError('Employee not found', 404);
1549                return;
1550            }
1551
1552            if ($employee['clockPin']) {
1553                if (!$pin) {
1554                    $this->jsonError('PIN is required', 400);
1555                    return;
1556                }
1557                if ($employee['clockPin'] !== $pin) {
1558                    $this->jsonError('Invalid PIN', 401);
1559                    return;
1560                }
1561            }
1562
1563            // Route to appropriate provider
1564            if ($this->isBuyerKioskProvider()) {
1565                $this->endBreakForBuyerKiosk($typeNum, (int)$employeeId, $employee, $data);
1566            } else {
1567                $this->endBreakForWhenIWork($typeNum, (int)$employeeId, $employee, $data);
1568            }
1569        } catch (Exception $e) {
1570            error_log("TimePunchController::endBreak error: " . $e->getMessage());
1571            $this->jsonError('Failed to end break: ' . $e->getMessage(), 500);
1572        }
1573    }
1574
1575    /**
1576     * End break for BuyerKiosk native provider
1577     *
1578     * @param string $typeNum Store identifier
1579     * @param int $employeeId Employee ID
1580     * @param array $employee Employee data
1581     * @param array $data Request data
1582     */
1583    private function endBreakForBuyerKiosk(string $typeNum, int $employeeId, array $employee, array $data): void
1584    {
1585        $userId = $this->getUserIdForEmployee($employeeId);
1586        if (!$userId) {
1587            $this->jsonError('Employee is not linked to a user account', 400);
1588            return;
1589        }
1590
1591        // Check if employee is on break
1592        if (!$this->getTimePunchRepository()->isOnBreak($userId)) {
1593            $this->jsonError('Employee is not on break', 400);
1594            return;
1595        }
1596
1597        // Get active session for shift ID
1598        $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
1599
1600        // Create the break end punch
1601        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
1602        $punchTime = new DateTime('now', $storeTimezone);
1603        $punchTime->setTimezone(new DateTimeZone('UTC')); // Store in UTC
1604
1605        $punch = new TimePunch(
1606            $userId,
1607            TimePunch::TYPE_BREAK_END,
1608            $punchTime,
1609            $userId
1610        );
1611
1612        // Associate with the same shift
1613        if ($activeSession && $activeSession->getShiftId()) {
1614            $punch->setShiftId($activeSession->getShiftId());
1615        }
1616
1617        // Persist the punch
1618        $createdPunch = $this->getTimePunchRepository()->create($punch);
1619
1620        // Log audit trail
1621        $this->getTimePunchAuditRepository()->logCreate(
1622            $createdPunch->getPunchId(),
1623            $userId,
1624            $createdPunch->toDbArray(),
1625            null
1626        );
1627
1628        // Log to workbook punch log table
1629        $this->logPunchAction($employeeId, 'break_end', [
1630            'punchId' => $createdPunch->getPunchId()
1631        ]);
1632
1633        // Invalidate schedule panel cache
1634        self::invalidateSchedulePanelCache($typeNum);
1635
1636        // Broadcast break end event via Ably
1637        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1638        $breakEndTime = $punchTime->format('Y-m-d\TH:i:s\Z');
1639        $this->ably->employeeBreakEnded($employeeId, $employeeName, $breakEndTime);
1640
1641        $this->jsonResponse([
1642            'success' => true,
1643            'message' => 'Break ended',
1644            'data' => [
1645                'provider' => 'buyerkiosk',
1646                'punchId' => $createdPunch->getPunchId(),
1647                'endTime' => $breakEndTime
1648            ]
1649        ]);
1650    }
1651
1652    /**
1653     * End break for WhenIWork provider
1654     *
1655     * @param string $typeNum Store identifier
1656     * @param int $employeeId Employee ID
1657     * @param array $employee Employee data
1658     * @param array $data Request data
1659     */
1660    private function endBreakForWhenIWork(string $typeNum, int $employeeId, array $employee, array $data): void
1661    {
1662        $wiwUserId = $employee['externalId'];
1663        if (!$wiwUserId) {
1664            $this->jsonError('Employee is not linked to WhenIWork', 400);
1665            return;
1666        }
1667
1668        // Get break ID from punch state
1669        $breakId = $data['breakId'] ?? null;
1670        if (!$breakId) {
1671            $punchState = $this->wiw->get('punch/state', [
1672                'userId' => $wiwUserId,
1673                'locationId' => $this->store->getWiwLocationID(),
1674                'deviceType' => 'terminal'
1675            ]);
1676
1677            if ($punchState && isset($punchState->break) && isset($punchState->break->id)) {
1678                $breakId = $punchState->break->id;
1679            }
1680        }
1681
1682        if (!$breakId) {
1683            $this->jsonError('No active break found to end', 400);
1684            return;
1685        }
1686
1687        $endData = [
1688            'end' => (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z')
1689        ];
1690
1691        $this->wiw->setEndpoint('https://api.wheniwork.com');
1692        $result = $this->wiw->update('v3/shift-breaks/' . $breakId, $endData);
1693        $this->wiw->setEndpoint('https://api.wheniwork.com/2');
1694
1695        if (!$result) {
1696            throw new Exception('Failed to end break via WhenIWork');
1697        }
1698
1699        if (isset($result->error) || isset($result->errors)) {
1700            $errorMsg = isset($result->error) ? $result->error : implode(', ', (array)$result->errors);
1701            throw new Exception($errorMsg);
1702        }
1703
1704        $this->logPunchAction($employeeId, 'break_end', $result);
1705
1706        // Invalidate schedule panel cache
1707        self::invalidateSchedulePanelCache($typeNum);
1708
1709        // Broadcast break end event via Ably
1710        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1711        $breakEndTime = $result->data->end ?? null;
1712        $this->ably->employeeBreakEnded($employeeId, $employeeName, $breakEndTime);
1713
1714        $this->jsonResponse([
1715            'success' => true,
1716            'message' => 'Break ended',
1717            'data' => [
1718                'provider' => 'wheniwork',
1719                'breakId' => $breakId,
1720                'endTime' => $breakEndTime,
1721                'duration' => $result->data->length ?? null
1722            ]
1723        ]);
1724    }
1725
1726    /**
1727     * POST /api/:typeNum/workbook/timepunch/override/
1728     * Execute clock action using manager override
1729     *
1730     * Allows a manager to perform clock actions for an employee using the manager's own PIN.
1731     * The manager's PIN validates their authority to perform the override.
1732     *
1733     * Request body:
1734     * - employeeId: int (required) - Target employee to clock
1735     * - action: string (required) - 'clockin'|'clockout'|'breakstart'|'breakend'
1736     * - managerPin: string (required) - Manager's own employee PIN
1737     * - reason: string (optional) - Override reason for audit log
1738     *
1739     * @param string $typeNum Store identifier
1740     */
1741    public function overrideClockAction(string $typeNum): void
1742    {
1743        try {
1744            if (!$this->isSchedulingEnabled()) {
1745                $this->jsonError('Scheduling is not enabled for this store', 400);
1746                return;
1747            }
1748
1749            $data = $this->getRequestBody();
1750            $employeeId = $data['employeeId'] ?? null;
1751            $action = $data['action'] ?? null;
1752            $managerPin = $data['managerPin'] ?? null;
1753            $reason = $data['reason'] ?? null;
1754
1755            // Validate required fields
1756            if (!$employeeId) {
1757                $this->jsonError('Employee ID is required', 400);
1758                return;
1759            }
1760
1761            if (!$action) {
1762                $this->jsonError('Action is required', 400);
1763                return;
1764            }
1765
1766            $validActions = ['clockin', 'clockout', 'breakstart', 'breakend'];
1767            if (!in_array($action, $validActions, true)) {
1768                $this->jsonError('Invalid action. Must be: ' . implode(', ', $validActions), 400);
1769                return;
1770            }
1771
1772            if (!$managerPin) {
1773                $this->jsonError('Manager PIN is required', 400);
1774                return;
1775            }
1776
1777            // Validate manager PIN
1778            $manager = $this->validateManagerOverride($managerPin);
1779            if (!$manager) {
1780                $this->jsonError('Invalid manager PIN or insufficient permissions', 401);
1781                return;
1782            }
1783
1784            // Get target employee
1785            $employee = $this->getEmployeeById((int)$employeeId);
1786            if (!$employee) {
1787                $this->jsonError('Employee not found', 404);
1788                return;
1789            }
1790
1791            // Route to appropriate provider
1792            if ($this->isBuyerKioskProvider()) {
1793                $this->overrideClockActionForBuyerKiosk($typeNum, (int)$employeeId, $employee, $action, $manager, $reason);
1794            } else {
1795                $this->overrideClockActionForWhenIWork($typeNum, (int)$employeeId, $employee, $action, $manager, $reason);
1796            }
1797        } catch (Exception $e) {
1798            error_log("TimePunchController::overrideClockAction error: " . $e->getMessage());
1799            $this->jsonError('Failed to execute override action: ' . $e->getMessage(), 500);
1800        }
1801    }
1802
1803    /**
1804     * Execute manager override clock action for BuyerKiosk provider
1805     *
1806     * @param string $typeNum Store identifier
1807     * @param int $employeeId Target employee ID
1808     * @param array $employee Employee data
1809     * @param string $action Clock action to perform
1810     * @param array $manager Manager employee data
1811     * @param string|null $reason Override reason
1812     */
1813    private function overrideClockActionForBuyerKiosk(
1814        string $typeNum,
1815        int $employeeId,
1816        array $employee,
1817        string $action,
1818        array $manager,
1819        ?string $reason
1820    ): void {
1821        $userId = $this->getUserIdForEmployee($employeeId);
1822        if (!$userId) {
1823            $this->jsonError('Employee is not linked to a user account', 400);
1824            return;
1825        }
1826
1827        $managerId = (int)$manager['employeeID'];
1828        $managerUserId = $this->getUserIdForEmployee($managerId);
1829        $managerName = trim($manager['employeeFirstName'] . ' ' . $manager['employeeLastName']);
1830        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
1831
1832        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
1833        $punchTime = new DateTime('now', $storeTimezone);
1834        $punchTime->setTimezone(new DateTimeZone('UTC'));
1835
1836        $message = '';
1837        $responseData = [];
1838
1839        switch ($action) {
1840            case 'clockin':
1841                // Check if already clocked in
1842                $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
1843                if ($activeSession !== null) {
1844                    $this->jsonError('Employee is already clocked in', 400);
1845                    return;
1846                }
1847
1848                $punch = new TimePunch(
1849                    $userId,
1850                    TimePunch::TYPE_CLOCK_IN,
1851                    $punchTime,
1852                    $managerUserId ?? $userId // Manager is entering the punch
1853                );
1854                $punch->setIsManagerOverride(true);
1855                $punch->setApproval($managerUserId, $reason);
1856
1857                $createdPunch = $this->getTimePunchRepository()->create($punch);
1858
1859                $this->getTimePunchAuditRepository()->logManagerOverride(
1860                    $createdPunch->getPunchId(),
1861                    $managerUserId ?? $userId,
1862                    $createdPunch->toDbArray(),
1863                    'manager_clock_in',
1864                    $reason ?? 'Manager override clock-in'
1865                );
1866
1867                $this->logPunchAction($employeeId, 'clock_in', ['punchId' => $createdPunch->getPunchId()], $managerId, $reason);
1868                self::invalidateSchedulePanelCache($typeNum);
1869                $startTime = $punchTime->format('Y-m-d\TH:i:s\Z');
1870                $this->ably->employeeClockedIn($employeeId, $employeeName, $startTime);
1871
1872                $message = 'Successfully clocked in';
1873                $responseData = [
1874                    'provider' => 'buyerkiosk',
1875                    'punchId' => $createdPunch->getPunchId(),
1876                    'startTime' => $startTime
1877                ];
1878                break;
1879
1880            case 'clockout':
1881                $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
1882                if ($activeSession === null) {
1883                    $this->jsonError('Employee is not clocked in', 400);
1884                    return;
1885                }
1886
1887                if ($this->getTimePunchRepository()->isOnBreak($userId)) {
1888                    $this->jsonError('Please end break before clocking out', 400);
1889                    return;
1890                }
1891
1892                $punch = new TimePunch(
1893                    $userId,
1894                    TimePunch::TYPE_CLOCK_OUT,
1895                    $punchTime,
1896                    $managerUserId ?? $userId
1897                );
1898                $punch->setIsManagerOverride(true);
1899                if ($activeSession->getShiftId()) {
1900                    $punch->setShiftId($activeSession->getShiftId());
1901                }
1902
1903                $createdPunch = $this->getTimePunchRepository()->create($punch);
1904
1905                $this->getTimePunchAuditRepository()->logManagerOverride(
1906                    $createdPunch->getPunchId(),
1907                    $managerUserId ?? $userId,
1908                    $createdPunch->toDbArray(),
1909                    'manager_clock_out',
1910                    $reason ?? 'Manager override clock-out'
1911                );
1912
1913                $this->logPunchAction($employeeId, 'clock_out', ['punchId' => $createdPunch->getPunchId()], $managerId, $reason);
1914                self::invalidateSchedulePanelCache($typeNum);
1915                $endTime = $punchTime->format('Y-m-d\TH:i:s\Z');
1916                $this->ably->employeeClockedOut($employeeId, $employeeName, $endTime);
1917
1918                $message = 'Successfully clocked out';
1919                $responseData = [
1920                    'provider' => 'buyerkiosk',
1921                    'punchId' => $createdPunch->getPunchId(),
1922                    'endTime' => $endTime
1923                ];
1924                break;
1925
1926            case 'breakstart':
1927                $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
1928                if ($activeSession === null) {
1929                    $this->jsonError('Employee must be clocked in to start a break', 400);
1930                    return;
1931                }
1932
1933                if ($this->getTimePunchRepository()->isOnBreak($userId)) {
1934                    $this->jsonError('Employee is already on break', 400);
1935                    return;
1936                }
1937
1938                $punch = new TimePunch(
1939                    $userId,
1940                    TimePunch::TYPE_BREAK_START,
1941                    $punchTime,
1942                    $managerUserId ?? $userId
1943                );
1944                $punch->setBreakType(TimePunch::BREAK_TYPE_UNPAID);
1945                $punch->setIsManagerOverride(true);
1946                if ($activeSession->getShiftId()) {
1947                    $punch->setShiftId($activeSession->getShiftId());
1948                }
1949
1950                $createdPunch = $this->getTimePunchRepository()->create($punch);
1951
1952                $this->getTimePunchAuditRepository()->logManagerOverride(
1953                    $createdPunch->getPunchId(),
1954                    $managerUserId ?? $userId,
1955                    $createdPunch->toDbArray(),
1956                    'manager_break_start',
1957                    $reason ?? 'Manager override break start'
1958                );
1959
1960                $this->logPunchAction($employeeId, 'break_start', ['punchId' => $createdPunch->getPunchId()], $managerId, $reason);
1961                self::invalidateSchedulePanelCache($typeNum);
1962                $breakStartTime = $punchTime->format('Y-m-d\TH:i:s\Z');
1963                $this->ably->employeeBreakStarted($employeeId, $employeeName, $breakStartTime);
1964
1965                $message = 'Break started';
1966                $responseData = [
1967                    'provider' => 'buyerkiosk',
1968                    'punchId' => $createdPunch->getPunchId(),
1969                    'startTime' => $breakStartTime
1970                ];
1971                break;
1972
1973            case 'breakend':
1974                if (!$this->getTimePunchRepository()->isOnBreak($userId)) {
1975                    $this->jsonError('Employee is not on break', 400);
1976                    return;
1977                }
1978
1979                $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
1980
1981                $punch = new TimePunch(
1982                    $userId,
1983                    TimePunch::TYPE_BREAK_END,
1984                    $punchTime,
1985                    $managerUserId ?? $userId
1986                );
1987                $punch->setIsManagerOverride(true);
1988                if ($activeSession && $activeSession->getShiftId()) {
1989                    $punch->setShiftId($activeSession->getShiftId());
1990                }
1991
1992                $createdPunch = $this->getTimePunchRepository()->create($punch);
1993
1994                $this->getTimePunchAuditRepository()->logManagerOverride(
1995                    $createdPunch->getPunchId(),
1996                    $managerUserId ?? $userId,
1997                    $createdPunch->toDbArray(),
1998                    'manager_break_end',
1999                    $reason ?? 'Manager override break end'
2000                );
2001
2002                $this->logPunchAction($employeeId, 'break_end', ['punchId' => $createdPunch->getPunchId()], $managerId, $reason);
2003                self::invalidateSchedulePanelCache($typeNum);
2004                $breakEndTime = $punchTime->format('Y-m-d\TH:i:s\Z');
2005                $this->ably->employeeBreakEnded($employeeId, $employeeName, $breakEndTime);
2006
2007                $message = 'Break ended';
2008                $responseData = [
2009                    'provider' => 'buyerkiosk',
2010                    'punchId' => $createdPunch->getPunchId(),
2011                    'endTime' => $breakEndTime
2012                ];
2013                break;
2014        }
2015
2016        $this->jsonResponse([
2017            'success' => true,
2018            'message' => $message,
2019            'data' => array_merge($responseData, [
2020                'overrideByEmployeeId' => $managerId,
2021                'overrideByName' => $managerName
2022            ])
2023        ]);
2024    }
2025
2026    /**
2027     * Execute manager override clock action for WhenIWork provider
2028     *
2029     * @param string $typeNum Store identifier
2030     * @param int $employeeId Target employee ID
2031     * @param array $employee Employee data
2032     * @param string $action Clock action to perform
2033     * @param array $manager Manager employee data
2034     * @param string|null $reason Override reason
2035     */
2036    private function overrideClockActionForWhenIWork(
2037        string $typeNum,
2038        int $employeeId,
2039        array $employee,
2040        string $action,
2041        array $manager,
2042        ?string $reason
2043    ): void {
2044        $wiwUserId = $employee['externalId'];
2045        if (!$wiwUserId) {
2046            $this->jsonError('Employee is not linked to WhenIWork', 400);
2047            return;
2048        }
2049
2050        $managerId = (int)$manager['employeeID'];
2051        $managerName = trim($manager['employeeFirstName'] . ' ' . $manager['employeeLastName']);
2052        $employeeName = trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']);
2053
2054        // Execute the action
2055        $result = null;
2056        $message = '';
2057        $responseData = [];
2058
2059        switch ($action) {
2060            case 'clockin':
2061                $clockInData = [
2062                    'id' => (int)$wiwUserId,
2063                    'location_id' => $this->store->getWiwLocationID(),
2064                    'terminal' => true
2065                ];
2066                $result = $this->wiw->post('times/clockin', $clockInData);
2067
2068                if ($result && !isset($result->error) && !isset($result->errors)) {
2069                    $this->logPunchAction($employeeId, 'clock_in', $result, $managerId, $reason);
2070                    EmployeesController::invalidateClockedInCache($typeNum);
2071                    self::invalidateSchedulePanelCache($typeNum);
2072                    $startTime = $result->time->start_time ?? null;
2073                    $this->ably->employeeClockedIn($employeeId, $employeeName, $startTime);
2074                    $message = 'Successfully clocked in';
2075                    $responseData = [
2076                        'provider' => 'wheniwork',
2077                        'time' => $result->time ?? null,
2078                        'startTime' => $startTime
2079                    ];
2080                }
2081                break;
2082
2083            case 'clockout':
2084                $clockOutData = [
2085                    'id' => (int)$wiwUserId,
2086                    'terminal' => true
2087                ];
2088                $result = $this->wiw->post('times/clockout', $clockOutData);
2089
2090                if ($result && !isset($result->error) && !isset($result->errors)) {
2091                    $this->logPunchAction($employeeId, 'clock_out', $result, $managerId, $reason);
2092                    EmployeesController::invalidateClockedInCache($typeNum);
2093                    self::invalidateSchedulePanelCache($typeNum);
2094                    $endTime = $result->time->end_time ?? null;
2095                    $this->ably->employeeClockedOut($employeeId, $employeeName, $endTime);
2096                    $message = 'Successfully clocked out';
2097                    $responseData = [
2098                        'provider' => 'wheniwork',
2099                        'time' => $result->time ?? null,
2100                        'endTime' => $endTime
2101                    ];
2102                }
2103                break;
2104
2105            case 'breakstart':
2106                // Get punch state to find the current time ID
2107                $punchState = $this->wiw->get('punch/state', [
2108                    'userId' => $wiwUserId,
2109                    'locationId' => $this->store->getWiwLocationID(),
2110                    'deviceType' => 'terminal'
2111                ]);
2112
2113                if (!$punchState || !isset($punchState->punchTimeId)) {
2114                    $this->jsonError('Employee must be clocked in to start a break', 400);
2115                    return;
2116                }
2117
2118                $breakData = [
2119                    'timeId' => $punchState->punchTimeId,
2120                    'start' => (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z'),
2121                    'type' => 2 // Default unpaid
2122                ];
2123
2124                $this->wiw->setEndpoint('https://api.wheniwork.com');
2125                $result = $this->wiw->post('v3/shift-breaks', $breakData);
2126                $this->wiw->setEndpoint('https://api.wheniwork.com/2');
2127
2128                if ($result && !isset($result->error) && !isset($result->errors)) {
2129                    $this->logPunchAction($employeeId, 'break_start', $result, $managerId, $reason);
2130                    self::invalidateSchedulePanelCache($typeNum);
2131                    $breakStartTime = $result->data->start ?? null;
2132                    $this->ably->employeeBreakStarted($employeeId, $employeeName, $breakStartTime);
2133                    $message = 'Break started';
2134                    $responseData = [
2135                        'provider' => 'wheniwork',
2136                        'breakId' => $result->data->id ?? null,
2137                        'startTime' => $breakStartTime
2138                    ];
2139                }
2140                break;
2141
2142            case 'breakend':
2143                // Get break ID from punch state
2144                $punchState = $this->wiw->get('punch/state', [
2145                    'userId' => $wiwUserId,
2146                    'locationId' => $this->store->getWiwLocationID(),
2147                    'deviceType' => 'terminal'
2148                ]);
2149
2150                if (!$punchState || !isset($punchState->break) || !isset($punchState->break->id)) {
2151                    $this->jsonError('No active break found to end', 400);
2152                    return;
2153                }
2154
2155                $breakId = $punchState->break->id;
2156                $endData = [
2157                    'end' => (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d\TH:i:s\Z')
2158                ];
2159
2160                $this->wiw->setEndpoint('https://api.wheniwork.com');
2161                $result = $this->wiw->update('v3/shift-breaks/' . $breakId, $endData);
2162                $this->wiw->setEndpoint('https://api.wheniwork.com/2');
2163
2164                if ($result && !isset($result->error) && !isset($result->errors)) {
2165                    $this->logPunchAction($employeeId, 'break_end', $result, $managerId, $reason);
2166                    self::invalidateSchedulePanelCache($typeNum);
2167                    $breakEndTime = $result->data->end ?? null;
2168                    $this->ably->employeeBreakEnded($employeeId, $employeeName, $breakEndTime);
2169                    $message = 'Break ended';
2170                    $responseData = [
2171                        'provider' => 'wheniwork',
2172                        'breakId' => $breakId,
2173                        'endTime' => $breakEndTime
2174                    ];
2175                }
2176                break;
2177        }
2178
2179        // Check for errors in WhenIWork response
2180        if (!$result) {
2181            throw new Exception('Failed to execute action via WhenIWork');
2182        }
2183
2184        if (isset($result->error) || isset($result->errors)) {
2185            $errorMsg = isset($result->error) ? $result->error : implode(', ', (array)$result->errors);
2186            throw new Exception($errorMsg);
2187        }
2188
2189        $this->jsonResponse([
2190            'success' => true,
2191            'message' => $message,
2192            'data' => array_merge($responseData, [
2193                'overrideByEmployeeId' => $managerId,
2194                'overrideByName' => $managerName
2195            ])
2196        ]);
2197    }
2198
2199    /**
2200     * GET /api/:typeNum/workbook/schedule-panel/
2201     * Get schedule panel data for today
2202     *
2203     * Returns today's scheduled employees with current clock status for the schedule panel.
2204     * Combines schedule data from the appropriate provider with real-time clock status.
2205     *
2206     * Caching Strategy:
2207     * - Data is cached in Redis with 5-minute TTL
2208     * - Cache is immediately invalidated when any clock event occurs (clock in/out, break start/end)
2209     * - This prevents N+1 API calls for punch state on every page load
2210     *
2211     * @param string $typeNum Store identifier
2212     */
2213    public function getSchedulePanelData(string $typeNum): void
2214    {
2215        try {
2216            if (!$this->isSchedulingEnabled()) {
2217                $this->jsonResponse([
2218                    'success' => true,
2219                    'data' => [
2220                        'enabled' => false,
2221                        'provider' => 'none',
2222                        'message' => 'Scheduling is not enabled for this store',
2223                        'employees' => []
2224                    ]
2225                ]);
2226                return;
2227            }
2228
2229            // Check for force refresh query parameter
2230            $forceRefresh = $this->app->request->get('refresh') === '1';
2231
2232            // If force refresh requested, invalidate cache first
2233            if ($forceRefresh) {
2234                self::invalidateSchedulePanelCache($typeNum);
2235            }
2236
2237            // Check Redis cache first (unless force refresh)
2238            if (!$forceRefresh) {
2239                $cachedData = $this->getSchedulePanelFromCache();
2240                if ($cachedData !== null) {
2241                    $this->jsonResponse([
2242                        'success' => true,
2243                        'data' => [
2244                            'enabled' => true,
2245                            'provider' => $this->schedulingProvider,
2246                            'employees' => $cachedData['employees'],
2247                            'lastUpdated' => $cachedData['lastUpdated'],
2248                            'cached' => true
2249                        ]
2250                    ]);
2251                    return;
2252                }
2253            }
2254
2255            // Cache miss - fetch fresh data from the appropriate provider
2256            if ($this->isBuyerKioskProvider()) {
2257                $employees = $this->getSchedulePanelDataForBuyerKiosk();
2258            } else {
2259                $employees = $this->getSchedulePanelDataForWhenIWork();
2260            }
2261
2262            // Sort employees: clocked_in -> on_break -> scheduled -> clocked_out
2263            usort($employees, function ($a, $b) {
2264                $statusOrder = [
2265                    'clocked_in' => 0,
2266                    'on_break' => 1,
2267                    'scheduled' => 2,
2268                    'clocked_out' => 3
2269                ];
2270                $orderA = $statusOrder[$a['status']] ?? 4;
2271                $orderB = $statusOrder[$b['status']] ?? 4;
2272
2273                if ($orderA !== $orderB) {
2274                    return $orderA - $orderB;
2275                }
2276
2277                // Secondary sort by shift start time
2278                if ($a['shiftStart'] && $b['shiftStart']) {
2279                    return strcmp($a['shiftStart'], $b['shiftStart']);
2280                }
2281
2282                // Tertiary sort by name
2283                return strcmp($a['fullName'], $b['fullName']);
2284            });
2285
2286            $lastUpdated = (new DateTime('now', new DateTimeZone('UTC')))->format('c');
2287
2288            // Cache the response data for 5 minutes
2289            $cacheData = [
2290                'employees' => $employees,
2291                'lastUpdated' => $lastUpdated
2292            ];
2293            $this->saveSchedulePanelToCache($cacheData);
2294
2295            $this->jsonResponse([
2296                'success' => true,
2297                'data' => [
2298                    'enabled' => true,
2299                    'provider' => $this->schedulingProvider,
2300                    'employees' => $employees,
2301                    'lastUpdated' => $lastUpdated,
2302                    'cached' => false
2303                ]
2304            ]);
2305        } catch (Exception $e) {
2306            error_log("TimePunchController::getSchedulePanelData error: " . $e->getMessage());
2307            $this->jsonError('Failed to get schedule panel data: ' . $e->getMessage(), 500);
2308        }
2309    }
2310
2311    /**
2312     * Get schedule panel data for BuyerKiosk native provider
2313     *
2314     * @return array Employee list with status
2315     */
2316    private function getSchedulePanelDataForBuyerKiosk(): array
2317    {
2318        // Get today's date in store timezone
2319        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
2320        $today = new DateTime('now', $storeTimezone);
2321
2322        // Get today's schedule from BuyerKiosk native provider
2323        $scheduleProvider = new BuyerKioskSchedule($this->store);
2324        $schedule = $scheduleProvider->getScheduleForDate($today);
2325
2326        // Group shifts by scheduled user ID (scheduleShifts.employeeId = kiosk_users.users.id)
2327        $shiftsByUserId = [];
2328        foreach ($schedule as $shift) {
2329            $userId = (int)$shift['employeeId'];
2330            if (!isset($shiftsByUserId[$userId])) {
2331                $shiftsByUserId[$userId] = [];
2332            }
2333            $shiftsByUserId[$userId][] = $shift;
2334        }
2335
2336        // Build employee list with status
2337        $employees = [];
2338        $processedEmployeeIds = [];
2339
2340        // Process scheduled employees
2341        foreach ($shiftsByUserId as $userId => $shifts) {
2342            // Workbook actions require the local store employee record (employees.employeeID)
2343            $employeeId = $this->getEmployeeIdForUser((int)$userId);
2344            if (!$employeeId) {
2345                error_log("TimePunchController::getSchedulePanelDataForBuyerKiosk: user {$userId} has a scheduled shift but no linked employee record");
2346                continue;
2347            }
2348
2349            $processedEmployeeIds[] = $employeeId;
2350
2351            // Get employee record for additional data
2352            $employee = $this->getEmployeeById($employeeId);
2353            if (!$employee) {
2354                continue;
2355            }
2356
2357            // Select the most relevant shift
2358            $selectedShift = $this->selectMostRelevantShift($shifts, $today);
2359
2360            // Get current punch state from native system
2361            $status = 'scheduled';
2362            $clockedInAt = null;
2363            $breakStartedAt = null;
2364
2365            $activeSession = $this->getTimePunchRepository()->getActiveSession((int)$userId);
2366            $isOnBreak = $this->getTimePunchRepository()->isOnBreak((int)$userId);
2367
2368            if ($isOnBreak) {
2369                $status = 'on_break';
2370                $clockedInAt = $activeSession?->getPunchTime()->format('Y-m-d\TH:i:s\Z');
2371                // Get break start time from the most recent break start punch
2372                $breakStart = $this->getTimePunchRepository()->getActiveBreakStartPunch((int)$userId);
2373                $breakStartedAt = $breakStart?->getPunchTime()->format('Y-m-d\TH:i:s\Z');
2374            } elseif ($activeSession !== null) {
2375                $status = 'clocked_in';
2376                $clockedInAt = $activeSession->getPunchTime()->format('Y-m-d\TH:i:s\Z');
2377            }
2378
2379            $employees[] = [
2380                'id' => $employeeId,
2381                'firstName' => $employee['employeeFirstName'],
2382                'lastName' => $employee['employeeLastName'],
2383                'fullName' => trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']),
2384                'photoUrl' => $employee['photoUrl'] ?? null,
2385                'position' => $selectedShift['position'] ?? $employee['position'] ?? null,
2386                'mappedRole' => $employee['mappedRole'] ?? null,
2387                'roleColor' => $this->getRoleColor($employee['mappedRole'] ?? null, $employee['roleColor'] ?? null),
2388                'hasPin' => !empty($employee['clockPin']),
2389                'shiftStart' => $selectedShift['startTime'] ?? null,
2390                'shiftEnd' => $selectedShift['endTime'] ?? null,
2391                'shiftNotes' => $selectedShift['notes'] ?? null,
2392                'status' => $status,
2393                'clockedInAt' => $clockedInAt,
2394                'breakStartedAt' => $breakStartedAt
2395            ];
2396        }
2397
2398        // Check for employees who are clocked in but NOT scheduled today
2399        $clockedInNotScheduled = $this->getClockedInEmployeesNotScheduledBuyerKiosk($processedEmployeeIds);
2400        foreach ($clockedInNotScheduled as $emp) {
2401            $employees[] = $emp;
2402        }
2403
2404        return $employees;
2405    }
2406
2407    /**
2408     * Get schedule panel data for WhenIWork provider
2409     *
2410     * @return array Employee list with status
2411     */
2412    private function getSchedulePanelDataForWhenIWork(): array
2413    {
2414        // Get today's date in store timezone
2415        $storeTimezone = new DateTimeZone($this->store->getTimezone() ?: 'America/Chicago');
2416        $today = new DateTime('now', $storeTimezone);
2417
2418        // Get today's schedule from WhenIWork
2419        $scheduleProvider = new WhenIWorkSchedule($this->store);
2420        $schedule = $scheduleProvider->getScheduleForDate($today);
2421
2422        // Group shifts by employee ID to handle employees with multiple shifts
2423        $shiftsByEmployee = [];
2424        foreach ($schedule as $shift) {
2425            $employeeId = (int)$shift['employeeId'];
2426            if (!isset($shiftsByEmployee[$employeeId])) {
2427                $shiftsByEmployee[$employeeId] = [];
2428            }
2429            $shiftsByEmployee[$employeeId][] = $shift;
2430        }
2431
2432        // Build employee list with status
2433        $employees = [];
2434        $processedEmployeeIds = [];
2435
2436        // Process scheduled employees (one entry per employee)
2437        foreach ($shiftsByEmployee as $employeeId => $shifts) {
2438            $processedEmployeeIds[] = $employeeId;
2439
2440            // Get employee record for additional data
2441            $employee = $this->getEmployeeById($employeeId);
2442            if (!$employee) {
2443                continue;
2444            }
2445
2446            // Select the most relevant shift (current or next)
2447            $selectedShift = $this->selectMostRelevantShift($shifts, $today);
2448
2449            // Get current punch state from WhenIWork
2450            $status = 'scheduled';
2451            $clockedInAt = null;
2452            $breakStartedAt = null;
2453
2454            $wiwUserId = $employee['externalId'] ?? null;
2455            if ($wiwUserId) {
2456                $punchState = $this->getPunchStateFromWiw($wiwUserId);
2457                if ($punchState) {
2458                    if (isset($punchState->break) && $punchState->break) {
2459                        $status = 'on_break';
2460                        $breakStartedAt = $punchState->break->start ?? null;
2461                        $clockedInAt = $punchState->punchStartTime ?? null;
2462                    } elseif ($punchState->canClockOut ?? false) {
2463                        $status = 'clocked_in';
2464                        $clockedInAt = $punchState->punchStartTime ?? null;
2465                    } elseif (!($punchState->canClockIn ?? true)) {
2466                        // Cannot clock in and cannot clock out = likely clocked out
2467                        $status = 'clocked_out';
2468                    }
2469                }
2470            }
2471
2472            $employees[] = [
2473                'id' => $employeeId,
2474                'firstName' => $employee['employeeFirstName'],
2475                'lastName' => $employee['employeeLastName'],
2476                'fullName' => trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']),
2477                'photoUrl' => $employee['photoUrl'] ?? null,
2478                'position' => $selectedShift['position'] ?? $employee['position'] ?? null,
2479                'mappedRole' => $employee['mappedRole'] ?? null,
2480                'roleColor' => $this->getRoleColor($employee['mappedRole'] ?? null, $employee['roleColor'] ?? null),
2481                'hasPin' => !empty($employee['clockPin']),
2482                'shiftStart' => $selectedShift['startTime'] ?? null,
2483                'shiftEnd' => $selectedShift['endTime'] ?? null,
2484                'shiftNotes' => $selectedShift['notes'] ?? null,
2485                'status' => $status,
2486                'clockedInAt' => $clockedInAt,
2487                'breakStartedAt' => $breakStartedAt
2488            ];
2489        }
2490
2491        // Check for employees who are clocked in but NOT scheduled today (edge case)
2492        $clockedInNotScheduled = $this->getClockedInEmployeesNotScheduled($processedEmployeeIds);
2493        foreach ($clockedInNotScheduled as $emp) {
2494            $employees[] = $emp;
2495        }
2496
2497        return $employees;
2498    }
2499
2500    /**
2501     * Get employees clocked in via BuyerKiosk native provider but not scheduled today
2502     *
2503     * @param array $excludeEmployeeIds Employee IDs already processed (scheduled)
2504     * @return array Clocked-in employees not in today's schedule
2505     */
2506    private function getClockedInEmployeesNotScheduledBuyerKiosk(array $excludeEmployeeIds): array
2507    {
2508        $employees = [];
2509
2510        // Get all currently clocked-in users from native punch system
2511        $clockedInUsers = $this->getTimePunchRepository()->getAllClockedInUsers();
2512
2513        foreach ($clockedInUsers as $userId) {
2514            $employeeId = $this->getEmployeeIdForUser($userId);
2515            if (!$employeeId || in_array($employeeId, $excludeEmployeeIds, true)) {
2516                continue;
2517            }
2518
2519            $employee = $this->getEmployeeById($employeeId);
2520            if (!$employee) {
2521                continue;
2522            }
2523
2524            $activeSession = $this->getTimePunchRepository()->getActiveSession($userId);
2525            $isOnBreak = $this->getTimePunchRepository()->isOnBreak($userId);
2526
2527            $status = $isOnBreak ? 'on_break' : 'clocked_in';
2528            $clockedInAt = $activeSession?->getPunchTime()->format('Y-m-d\TH:i:s\Z');
2529
2530            $breakStartedAt = null;
2531            if ($isOnBreak) {
2532                $breakStart = $this->getTimePunchRepository()->getActiveBreakStartPunch($userId);
2533                $breakStartedAt = $breakStart?->getPunchTime()->format('Y-m-d\TH:i:s\Z');
2534            }
2535
2536            $employees[] = [
2537                'id' => $employeeId,
2538                'firstName' => $employee['employeeFirstName'],
2539                'lastName' => $employee['employeeLastName'],
2540                'fullName' => trim($employee['employeeFirstName'] . ' ' . $employee['employeeLastName']),
2541                'photoUrl' => $employee['photoUrl'] ?? null,
2542                'position' => $employee['position'] ?? null,
2543                'mappedRole' => $employee['mappedRole'] ?? null,
2544                'roleColor' => $this->getRoleColor($employee['mappedRole'] ?? null, $employee['roleColor'] ?? null),
2545                'hasPin' => !empty($employee['clockPin']),
2546                'shiftStart' => null,
2547                'shiftEnd' => null,
2548                'shiftNotes' => null,
2549                'status' => $status,
2550                'clockedInAt' => $clockedInAt,
2551                'breakStartedAt' => $breakStartedAt
2552            ];
2553        }
2554
2555        return $employees;
2556    }
2557
2558    /**
2559     * Select the most relevant shift from multiple shifts for an employee
2560     *
2561     * When an employee has multiple shifts on the same day, this selects:
2562     * 1. The shift currently in progress (now is between start and end)
2563     * 2. If no shift in progress, the next upcoming shift
2564     * 3. If all shifts are past, the most recent shift
2565     *
2566     * @param array<array<string, mixed>> $shifts Array of shift data
2567     * @param \DateTime $now Current datetime in store timezone
2568     * @return array<string, mixed> The selected shift
2569     */
2570    private function selectMostRelevantShift(array $shifts, \DateTime $now): array
2571    {
2572        if (count($shifts) === 1) {
2573            return $shifts[0];
2574        }
2575
2576        $nowTimestamp = $now->getTimestamp();
2577        $currentShift = null;
2578        $nextShift = null;
2579        $mostRecentPastShift = null;
2580
2581        foreach ($shifts as $shift) {
2582            $startTime = isset($shift['startTime']) ? strtotime($shift['startTime']) : null;
2583            $endTime = isset($shift['endTime']) ? strtotime($shift['endTime']) : null;
2584
2585            if (!$startTime || !$endTime) {
2586                continue;
2587            }
2588
2589            // Check if shift is currently in progress
2590            if ($startTime <= $nowTimestamp && $endTime >= $nowTimestamp) {
2591                $currentShift = $shift;
2592                break; // Current shift takes priority
2593            }
2594
2595            // Track next upcoming shift
2596            if ($startTime > $nowTimestamp) {
2597                if ($nextShift === null || $startTime < strtotime($nextShift['startTime'])) {
2598                    $nextShift = $shift;
2599                }
2600            }
2601
2602            // Track most recent past shift
2603            if ($endTime < $nowTimestamp) {
2604                if ($mostRecentPastShift === null || $endTime > strtotime($mostRecentPastShift['endTime'])) {
2605                    $mostRecentPastShift = $shift;
2606                }
2607            }
2608        }
2609
2610        // Priority: current > next > most recent past > first shift
2611        return $currentShift ?? $nextShift ?? $mostRecentPastShift ?? $shifts[0];
2612    }
2613
2614    /**
2615     * Get punch state from WhenIWork API
2616     *
2617     * Caches the result per WhenIWork user ID to avoid N+1 API calls
2618     * when processing multiple shifts or checking multiple employees.
2619     *
2620     * @param string $wiwUserId WhenIWork user ID
2621     * @return object|null Punch state response or null on error
2622     */
2623    private function getPunchStateFromWiw(string $wiwUserId): ?object
2624    {
2625        // Check cache first
2626        if (array_key_exists($wiwUserId, $this->punchStateCache)) {
2627            return $this->punchStateCache[$wiwUserId];
2628        }
2629
2630        try {
2631            $result = $this->wiw->get('punch/state', [
2632                'userId' => $wiwUserId,
2633                'locationId' => $this->store->getWiwLocationID(),
2634                'deviceType' => 'terminal'
2635            ]);
2636            $this->punchStateCache[$wiwUserId] = $result ?: null;
2637            return $this->punchStateCache[$wiwUserId];
2638        } catch (Exception $e) {
2639            error_log("TimePunchController::getPunchStateFromWiw error for user {$wiwUserId}" . $e->getMessage());
2640            $this->punchStateCache[$wiwUserId] = null;
2641            return null;
2642        }
2643    }
2644
2645    /**
2646     * Get employees who are clocked in but not scheduled today
2647     *
2648     * This handles the edge case where an employee clocks in unscheduled
2649     * and should still appear in the schedule panel.
2650     *
2651     * @param array<int> $excludeEmployeeIds Employee IDs already processed from schedule
2652     * @return array<array<string, mixed>> Employee data for unscheduled clocked-in employees
2653     */
2654    private function getClockedInEmployeesNotScheduled(array $excludeEmployeeIds): array
2655    {
2656        $employees = [];
2657
2658        // Get all active WhenIWork employees not in the exclude list
2659        $placeholders = !empty($excludeEmployeeIds)
2660            ? 'AND employeeID NOT IN (' . implode(',', array_map('intval', $excludeEmployeeIds)) . ')'
2661            : '';
2662
2663        $stmt = $this->db->prepare("
2664            SELECT employeeID, employeeFirstName, employeeLastName, photoUrl, position, mappedRole, roleColor, externalId, clockPin
2665            FROM employees
2666            WHERE active = 1
2667            AND source = 'wheniwork'
2668            AND externalId IS NOT NULL
2669            AND externalId != ''
2670            {$placeholders}
2671        ");
2672        $stmt->execute();
2673        $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
2674
2675        foreach ($rows as $row) {
2676            $wiwUserId = $row['externalId'];
2677            $punchState = $this->getPunchStateFromWiw($wiwUserId);
2678
2679            // Only include if currently clocked in or on break
2680            if ($punchState && ($punchState->canClockOut ?? false)) {
2681                $status = 'clocked_in';
2682                $breakStartedAt = null;
2683
2684                if (isset($punchState->break) && $punchState->break) {
2685                    $status = 'on_break';
2686                    $breakStartedAt = $punchState->break->start ?? null;
2687                }
2688
2689                $employees[] = [
2690                    'id' => (int)$row['employeeID'],
2691                    'firstName' => $row['employeeFirstName'],
2692                    'lastName' => $row['employeeLastName'],
2693                    'fullName' => trim($row['employeeFirstName'] . ' ' . $row['employeeLastName']),
2694                    'photoUrl' => $row['photoUrl'],
2695                    'position' => $row['position'],
2696                    'mappedRole' => $row['mappedRole'] ?? null,
2697                    'roleColor' => $this->getRoleColor($row['mappedRole'] ?? null, $row['roleColor'] ?? null),
2698                    'hasPin' => !empty($row['clockPin']),
2699                    'shiftStart' => null, // No scheduled shift
2700                    'shiftEnd' => null,
2701                    'shiftNotes' => null,
2702                    'status' => $status,
2703                    'clockedInAt' => $punchState->punchStartTime ?? null,
2704                    'breakStartedAt' => $breakStartedAt
2705                ];
2706            }
2707        }
2708
2709        return $employees;
2710    }
2711
2712    /**
2713     * Get employee by ID from local database
2714     */
2715    private function getEmployeeById(int $employeeId): ?array
2716    {
2717        $stmt = $this->db->prepare("
2718            SELECT * FROM employees
2719            WHERE employeeID = :id
2720            AND active = 1
2721        ");
2722        $stmt->execute([':id' => $employeeId]);
2723        return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
2724    }
2725
2726    /**
2727     * Validate manager override PIN
2728     *
2729     * Checks if PIN belongs to a manager who can perform override.
2730     * This is PIN-based auth, not session-user auth.
2731     * The workbook is a shared kiosk - any employee can use it.
2732     * The PIN identifies WHO is performing the action.
2733     *
2734     * Priority:
2735     * 1. Check if employee has linked user account with uri_manager_actions permission
2736     * 2. Fall back to role-based check (role <= 2) if no user account linked
2737     *
2738     * @param string $managerPin Manager's employee PIN
2739     * @return array|null Manager employee data if valid, null otherwise
2740     */
2741    private function validateManagerOverride(string $managerPin): ?array
2742    {
2743        // Look up employee by PIN
2744        $stmt = $this->db->prepare("
2745            SELECT employeeID, employeeFirstName, employeeLastName, role
2746            FROM employees
2747            WHERE clockPin = :pin
2748            AND active = 1
2749            LIMIT 1
2750        ");
2751        $stmt->execute([':pin' => $managerPin]);
2752        $manager = $stmt->fetch(\PDO::FETCH_ASSOC);
2753
2754        if (!$manager) {
2755            return null; // Invalid PIN
2756        }
2757
2758        // Try to find linked user account and check permission
2759        $hasPermission = $this->checkManagerPermission((int)$manager['employeeID']);
2760
2761        if (!$hasPermission) {
2762            return null; // Not authorized for manager actions
2763        }
2764
2765        return $manager;
2766    }
2767
2768    /**
2769     * Check if employee has manager permission via linked user account or role
2770     *
2771     * Permission check order (per SDD):
2772     * 1. If employee has linked user account via user_employee_links:
2773     *    - Check uri_manager_actions permission on that user
2774     *    - If user exists but lacks permission â†’ DENIED (no fallback)
2775     * 2. If no linked user account:
2776     *    - Fall back to role-based check (role <= 2 = admin/manager)
2777     *
2778     * This two-tiered approach allows:
2779     * - Proper permission enforcement for employees with app logins
2780     * - Backward compatibility for employees without linked accounts
2781     *
2782     * @param int $employeeId Employee ID to check
2783     * @return bool True if employee has manager permission, false otherwise
2784     */
2785    private function checkManagerPermission(int $employeeId): bool
2786    {
2787        // Get employee record with role for fallback
2788        $stmt = $this->db->prepare("
2789            SELECT role
2790            FROM employees
2791            WHERE employeeID = :employeeId
2792            AND active = 1
2793            LIMIT 1
2794        ");
2795        $stmt->execute([':employeeId' => $employeeId]);
2796        $employee = $stmt->fetch(\PDO::FETCH_ASSOC);
2797
2798        if (!$employee) {
2799            return false;
2800        }
2801
2802        // Try to find linked user account
2803        try {
2804            $userDb = dbConnectByName('kiosk_users');
2805            if (!$userDb) {
2806                // Can't access user database, fall back to role check
2807                return $this->checkManagerRole((int)$employee['role']);
2808            }
2809
2810            // Look up user account linked to this employee
2811            $stmt = $userDb->prepare("
2812                SELECT userId
2813                FROM user_employee_links
2814                WHERE employeeId = :employeeId
2815                AND typeNum = :typeNum
2816                LIMIT 1
2817            ");
2818            $stmt->execute([
2819                ':employeeId' => $employeeId,
2820                ':typeNum' => $this->store->getTypeNum()
2821            ]);
2822            $link = $stmt->fetch(\PDO::FETCH_ASSOC);
2823
2824            if (!$link || !isset($link['userId'])) {
2825                // No user account linked, fall back to role check
2826                return $this->checkManagerRole((int)$employee['role']);
2827            }
2828
2829            // Load user and check permission
2830            $userId = (int)$link['userId'];
2831            $user = \UserFrosting\UserLoader::fetch($userId);
2832
2833            if (!$user) {
2834                // User not found, fall back to role check
2835                return $this->checkManagerRole((int)$employee['role']);
2836            }
2837
2838            // Check for uri_manager_actions permission
2839            if ($user->checkAccess('uri_manager_actions')) {
2840                return true;
2841            }
2842
2843            // User exists but doesn't have permission, return false
2844            // (don't fall back to role in this case - permission system takes precedence)
2845            return false;
2846
2847        } catch (\Exception $e) {
2848            // Log error and fall back to role check
2849            error_log("TimePunchController::checkManagerPermission error: " . $e->getMessage());
2850            return $this->checkManagerRole((int)$employee['role']);
2851        }
2852    }
2853
2854    /**
2855     * Check if employee role indicates manager permission
2856     *
2857     * Fallback check when no user account is linked.
2858     * Role levels: 1=admin, 2=manager, 3=buyer, 4=employee, 5=sorter
2859     *
2860     * @param int $role Employee role value
2861     * @return bool True if role <= 2 (admin or manager), false otherwise
2862     */
2863    private function checkManagerRole(int $role): bool
2864    {
2865        // Managers and admins have role <= 2
2866        return $role <= 2;
2867    }
2868
2869    /**
2870     * Calculate duration from start time
2871     */
2872    private function calculateDuration(string $startTime): array
2873    {
2874        try {
2875            $start = new \DateTime($startTime);
2876            $now = new \DateTime();
2877            $diff = $start->diff($now);
2878
2879            $totalMinutes = ($diff->days * 24 * 60) + ($diff->h * 60) + $diff->i;
2880
2881            return [
2882                'hours' => $diff->h + ($diff->days * 24),
2883                'minutes' => $diff->i,
2884                'totalMinutes' => $totalMinutes,
2885                'formatted' => sprintf('%d:%02d', $diff->h + ($diff->days * 24), $diff->i)
2886            ];
2887        } catch (Exception $e) {
2888            return ['hours' => 0, 'minutes' => 0, 'totalMinutes' => 0, 'formatted' => '0:00'];
2889        }
2890    }
2891
2892    /**
2893     * Log punch action to database
2894     *
2895     * @param int $employeeId Employee ID for the clock action
2896     * @param string $action Action type (clock_in, clock_out, break_start, break_end)
2897     * @param mixed $response WhenIWork API response
2898     * @param int|null $overrideByEmployeeId Manager's employeeID if override was used
2899     * @param string|null $overrideReason Optional reason for manager override
2900     */
2901    private function logPunchAction(
2902        int $employeeId,
2903        string $action,
2904        $response,
2905        ?int $overrideByEmployeeId = null,
2906        ?string $overrideReason = null
2907    ): void {
2908        try {
2909            $this->ensurePunchLogTable();
2910
2911            $stmt = $this->db->prepare("
2912                INSERT INTO workbook_punch_log
2913                (employeeId, action, responseData, overrideByEmployeeId, overrideReason, createdAt)
2914                VALUES (:employeeId, :action, :responseData, :overrideByEmployeeId, :overrideReason, NOW())
2915            ");
2916            $stmt->execute([
2917                ':employeeId' => $employeeId,
2918                ':action' => $action,
2919                ':responseData' => json_encode($response),
2920                ':overrideByEmployeeId' => $overrideByEmployeeId,
2921                ':overrideReason' => $overrideReason
2922            ]);
2923        } catch (Exception $e) {
2924            error_log("TimePunchController::logPunchAction error: " . $e->getMessage());
2925        }
2926    }
2927
2928    /**
2929     * Ensure punch log table exists with current schema
2930     */
2931    private function ensurePunchLogTable(): void
2932    {
2933        $this->db->exec("
2934            CREATE TABLE IF NOT EXISTS `workbook_punch_log` (
2935                `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
2936                `employeeId` int(10) unsigned NOT NULL,
2937                `action` varchar(50) NOT NULL,
2938                `responseData` text,
2939                `overrideByEmployeeId` int(10) unsigned NULL COMMENT 'Manager employeeID if override was used',
2940                `overrideReason` varchar(255) NULL COMMENT 'Optional reason for manager override',
2941                `createdAt` timestamp DEFAULT CURRENT_TIMESTAMP,
2942                PRIMARY KEY (`id`),
2943                KEY `employeeId` (`employeeId`),
2944                KEY `action` (`action`),
2945                KEY `createdAt` (`createdAt`)
2946            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
2947        ");
2948    }
2949
2950    /**
2951     * Get role color from RoleConfigService or fall back to defaults
2952     *
2953     * @param string|null $mappedRole The mapped role (buyer, sorter, manager, shift_lead, associate)
2954     * @param string|null $explicitColor Optional explicit color override
2955     * @param int|null $roleId Optional numeric role ID (preferred if available)
2956     * @return string|null Hex color code or null
2957     */
2958    private function getRoleColor(?string $mappedRole, ?string $explicitColor, ?int $roleId = null): ?string
2959    {
2960        // Use explicit color if provided
2961        if ($explicitColor) {
2962            return $explicitColor;
2963        }
2964
2965        // Map legacy string roles to numeric IDs
2966        $roleNameToId = [
2967            'buyer' => 4,
2968            'sorter' => 0,      // Maps to Employee
2969            'manager' => 2,
2970            'shift_lead' => 3,
2971            'associate' => 0,   // Maps to Employee
2972            'cashier' => 5,
2973            'owner' => 1,
2974        ];
2975
2976        // Determine the role ID to use
2977        $resolvedRoleId = $roleId;
2978        if ($resolvedRoleId === null && $mappedRole) {
2979            $resolvedRoleId = $roleNameToId[strtolower($mappedRole)] ?? null;
2980        }
2981
2982        // Use RoleConfigService if available
2983        if ($this->roleConfigService && $resolvedRoleId !== null) {
2984            return $this->roleConfigService->getRoleColor($resolvedRoleId);
2985        }
2986
2987        // Fallback to default colors based on mapped role
2988        $defaultColors = [
2989            'buyer' => '#ffc107',      // yellow (matches RoleConfigService)
2990            'sorter' => '#6c757d',     // gray
2991            'manager' => '#dc3545',    // red
2992            'shift_lead' => '#0d6efd', // blue
2993            'associate' => '#6c757d',  // gray
2994            'cashier' => '#0dcaf0',    // cyan
2995            'owner' => '#212529',      // dark
2996        ];
2997
2998        return $mappedRole ? ($defaultColors[strtolower($mappedRole)] ?? null) : null;
2999    }
3000
3001    private function getRequestBody(): array
3002    {
3003        $body = $this->app->request->getBody();
3004        return json_decode($body, true) ?: [];
3005    }
3006
3007    private function jsonResponse(array $data, int $status = 200): void
3008    {
3009        $this->app->response->setStatus($status);
3010        $this->app->response->headers->set('Content-Type', 'application/json');
3011        echo json_encode($data);
3012    }
3013
3014    private function jsonError(string $message, int $status = 400): void
3015    {
3016        $this->app->response->setStatus($status);
3017        $this->app->response->headers->set('Content-Type', 'application/json');
3018        echo json_encode([
3019            'success' => false,
3020            'error' => $message
3021        ]);
3022    }
3023}