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