Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
13.04% covered (danger)
13.04%
88 / 675
19.05% covered (danger)
19.05%
8 / 42
CRAP
0.00% covered (danger)
0.00%
0 / 1
SchedulingController
13.04% covered (danger)
13.04%
88 / 675
19.05% covered (danger)
19.05%
8 / 42
19628.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getDb
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getCentralDb
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getBuykioskDb
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getShiftRepository
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getPositionRepository
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getShiftAuditRepository
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getOvertimeCalculator
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getLaborCostCalculator
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 checkReadAuth
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 checkWriteAuth
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 checkConfigAuth
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 sendJsonResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 sendErrorResponse
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getCurrentUserId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStoreTimezone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 parseInstantAsUtc
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 parseWeekStartParamAsWeekStartUtc
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 getWeekStartUtcForInstant
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 formatLaborCostResultForApi
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 getEffectivePayRatesForUsers
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
132
 isUserAssignedToStore
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getShifts
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
6.92
 createShift
4.00% covered (danger)
4.00%
2 / 50
0.00% covered (danger)
0.00%
0 / 1
139.40
 updateShift
3.33% covered (danger)
3.33%
2 / 60
0.00% covered (danger)
0.00%
0 / 1
247.24
 deleteShift
11.76% covered (danger)
11.76%
2 / 17
0.00% covered (danger)
0.00%
0 / 1
22.17
 getCopyPreview
11.76% covered (danger)
11.76%
2 / 17
0.00% covered (danger)
0.00%
0 / 1
22.17
 copyWeek
8.70% covered (danger)
8.70%
2 / 23
0.00% covered (danger)
0.00%
0 / 1
33.40
 getEmployees
6.06% covered (danger)
6.06%
2 / 33
0.00% covered (danger)
0.00%
0 / 1
61.05
 getPositions
12.50% covered (danger)
12.50%
2 / 16
0.00% covered (danger)
0.00%
0 / 1
14.72
 getLaborCost
8.00% covered (danger)
8.00%
2 / 25
0.00% covered (danger)
0.00%
0 / 1
16.46
 getConfig
7.41% covered (danger)
7.41%
2 / 27
0.00% covered (danger)
0.00%
0 / 1
34.58
 updateConfig
4.65% covered (danger)
4.65%
2 / 43
0.00% covered (danger)
0.00%
0 / 1
96.69
 getOvertimeConfig
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
8.86
 updateOvertimeConfig
5.56% covered (danger)
5.56%
2 / 36
0.00% covered (danger)
0.00%
0 / 1
61.91
 getMySchedule
6.98% covered (danger)
6.98%
3 / 43
0.00% covered (danger)
0.00%
0 / 1
34.98
 getEmployeeIdForCurrentUser
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getEmployeeInfo
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
12
 groupShiftsByWeek
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
72
 formatWeekLabel
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 formatShiftForApi
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
 getWeekStart
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\Scheduling\Controllers;
4
5use BuyerKiosk\Scheduling\Models\Shift;
6use BuyerKiosk\Scheduling\Repositories\ShiftRepository;
7use BuyerKiosk\Scheduling\Repositories\PositionRepository;
8use BuyerKiosk\Scheduling\Repositories\ShiftAuditRepository;
9use BuyerKiosk\Scheduling\Services\LaborCostCalculator;
10use BuyerKiosk\Scheduling\Services\LaborCostResult;
11use BuyerKiosk\Scheduling\Services\OvertimeCalculator;
12use BuyerKiosk\Scheduling\Repositories\TimePunchRepository;
13use DateTime;
14use DateTimeZone;
15use Exception;
16use PDO;
17
18/**
19 * SchedulingController - REST API for Employee Scheduling
20 *
21 * Provides endpoints for managing employee schedules using the BuyerKiosk native provider.
22 *
23 * Shift Management:
24 * 1. GET    /api/:typeNum/schedule/shifts                 - Get shifts for date range
25 * 2. POST   /api/:typeNum/schedule/shifts                 - Create shift
26 * 3. PUT    /api/:typeNum/schedule/shifts/:shiftId        - Update shift
27 * 4. DELETE /api/:typeNum/schedule/shifts/:shiftId        - Soft delete shift
28 * 5. GET    /api/:typeNum/schedule/shifts/copy-preview    - Preview copy conflicts
29 * 6. POST   /api/:typeNum/schedule/shifts/copy            - Copy previous week
30 *
31 * Resources:
32 * 7. GET    /api/:typeNum/schedule/employees              - Get employees for scheduling
33 * 8. GET    /api/:typeNum/schedule/positions              - Get store positions
34 *
35 * Labor Cost:
36 * 9. GET    /api/:typeNum/schedule/labor-cost             - Get labor cost summary
37 *
38 * Configuration:
39 * 10. GET   /api/:typeNum/schedule/config                 - Get store scheduling config
40 * 11. PUT   /api/:typeNum/schedule/config                 - Update scheduling config
41 *
42 * Overtime:
43 * 12. GET   /api/:typeNum/schedule/overtime/config        - Get overtime rules
44 * 13. PUT   /api/:typeNum/schedule/overtime/config        - Update overtime rules
45 *
46 * Authentication: All endpoints require session authentication + uri_schedule permission
47 *
48 * @package BuyerKiosk\Scheduling\Controllers
49 * @see docs/specs/013-employee-scheduling/solution-design.md Lines 831-1021
50 */
51class SchedulingController
52{
53    /**
54     * @var \Slim\Slim Slim application instance
55     */
56    private $app;
57
58    /**
59     * @var \Store Store object
60     */
61    private $store;
62
63    /**
64     * @var string Store type number
65     */
66    private string $typeNum;
67
68    /**
69     * @var PDO|null Store database connection
70     */
71    private ?PDO $db = null;
72
73    /**
74     * @var PDO|null Central database connection
75     */
76    private ?PDO $centralDb = null;
77
78    /**
79     * @var ShiftRepository|null
80     */
81    private ?ShiftRepository $shiftRepository = null;
82
83    /**
84     * @var PositionRepository|null
85     */
86    private ?PositionRepository $positionRepository = null;
87
88    /**
89     * @var ShiftAuditRepository|null
90     */
91    private ?ShiftAuditRepository $shiftAuditRepository = null;
92
93    /**
94     * @var LaborCostCalculator|null
95     */
96    private ?LaborCostCalculator $laborCostCalculator = null;
97
98    /**
99     * @var OvertimeCalculator|null
100     */
101    private ?OvertimeCalculator $overtimeCalculator = null;
102
103    /**
104     * Constructor
105     *
106     * @param \Slim\Slim $app Slim application instance
107     * @param \Store $store Store object (validated)
108     */
109    public function __construct($app, \Store $store)
110    {
111        $this->app = $app;
112        $this->store = $store;
113        $this->typeNum = $store->getTypeNum();
114    }
115
116    // =========================================================================
117    // LAZY INITIALIZATION
118    // =========================================================================
119
120    /**
121     * Get store database connection
122     */
123    private function getDb(): PDO
124    {
125        if ($this->db === null) {
126            $this->db = dbConnectByName($this->store->getDbName());
127        }
128        return $this->db;
129    }
130
131    /**
132     * Get central database connection (kiosk_users for user-related tables)
133     */
134    private function getCentralDb(): PDO
135    {
136        if ($this->centralDb === null) {
137            $this->centralDb = dbConnectByName('kiosk_users');
138        }
139        return $this->centralDb;
140    }
141
142    /**
143     * @var PDO|null Buykiosk database connection (for stores table)
144     */
145    private ?PDO $buykioskDb = null;
146
147    /**
148     * Get buykiosk database connection (kiosk_buykiosk for stores table)
149     */
150    private function getBuykioskDb(): PDO
151    {
152        if ($this->buykioskDb === null) {
153            $this->buykioskDb = dbConnectByName('kiosk_buykiosk');
154        }
155        return $this->buykioskDb;
156    }
157
158    /**
159     * Get ShiftRepository instance
160     */
161    private function getShiftRepository(): ShiftRepository
162    {
163        if ($this->shiftRepository === null) {
164            $this->shiftRepository = new ShiftRepository($this->getDb());
165        }
166        return $this->shiftRepository;
167    }
168
169    /**
170     * Get PositionRepository instance
171     */
172    private function getPositionRepository(): PositionRepository
173    {
174        if ($this->positionRepository === null) {
175            $this->positionRepository = new PositionRepository($this->getDb());
176        }
177        return $this->positionRepository;
178    }
179
180    /**
181     * Get ShiftAuditRepository instance
182     */
183    private function getShiftAuditRepository(): ShiftAuditRepository
184    {
185        if ($this->shiftAuditRepository === null) {
186            $this->shiftAuditRepository = new ShiftAuditRepository($this->getDb());
187        }
188        return $this->shiftAuditRepository;
189    }
190
191    /**
192     * Get OvertimeCalculator instance
193     */
194    private function getOvertimeCalculator(): OvertimeCalculator
195    {
196        if ($this->overtimeCalculator === null) {
197            $punchRepository = new TimePunchRepository($this->getDb());
198            $this->overtimeCalculator = new OvertimeCalculator($this->getCentralDb(), $punchRepository);
199        }
200        return $this->overtimeCalculator;
201    }
202
203    /**
204     * Get LaborCostCalculator instance
205     */
206    private function getLaborCostCalculator(): LaborCostCalculator
207    {
208        if ($this->laborCostCalculator === null) {
209            $punchRepository = new TimePunchRepository($this->getDb());
210            $this->laborCostCalculator = new LaborCostCalculator(
211                $this->getCentralDb(),
212                $this->getShiftRepository(),
213                $punchRepository,
214                $this->getOvertimeCalculator()
215            );
216        }
217        return $this->laborCostCalculator;
218    }
219
220    // =========================================================================
221    // AUTHENTICATION & PERMISSION CHECKS
222    // =========================================================================
223
224    /**
225     * Check session authentication and uri_schedule permission (read access)
226     *
227     * @return bool True if authorized
228     */
229    private function checkReadAuth(): bool
230    {
231        if (!isset($this->app->user) || !$this->app->user) {
232            $this->sendErrorResponse('Authentication required', 401, 'UNAUTHORIZED');
233            return false;
234        }
235
236        if (!$this->app->user->checkAccess('uri_schedule')) {
237            $this->sendErrorResponse('Access denied. Requires uri_schedule permission', 403, 'FORBIDDEN');
238            return false;
239        }
240
241        return true;
242    }
243
244    /**
245     * Check session authentication and uri_schedule_manage permission (write access)
246     *
247     * @return bool True if authorized
248     */
249    private function checkWriteAuth(): bool
250    {
251        if (!isset($this->app->user) || !$this->app->user) {
252            $this->sendErrorResponse('Authentication required', 401, 'UNAUTHORIZED');
253            return false;
254        }
255
256        if (!$this->app->user->checkAccess('uri_schedule_manage')) {
257            $this->sendErrorResponse('Access denied. Requires uri_schedule_manage permission', 403, 'FORBIDDEN');
258            return false;
259        }
260
261        return true;
262    }
263
264    /**
265     * Check session authentication and uri_schedule_config permission (config access)
266     *
267     * @return bool True if authorized
268     */
269    private function checkConfigAuth(): bool
270    {
271        if (!isset($this->app->user) || !$this->app->user) {
272            $this->sendErrorResponse('Authentication required', 401, 'UNAUTHORIZED');
273            return false;
274        }
275
276        if (!$this->app->user->checkAccess('uri_schedule_config')) {
277            $this->sendErrorResponse('Access denied. Requires uri_schedule_config permission', 403, 'FORBIDDEN');
278            return false;
279        }
280
281        return true;
282    }
283
284    // =========================================================================
285    // RESPONSE HELPERS
286    // =========================================================================
287
288    /**
289     * Send JSON success response
290     *
291     * @param mixed $data Response data
292     * @param int $status HTTP status code
293     */
294    private function sendJsonResponse($data, int $status = 200): void
295    {
296        $this->app->response->headers->set('Content-Type', 'application/json');
297        $this->app->response->setStatus($status);
298        $this->app->response->setBody(json_encode($data));
299    }
300
301    /**
302     * Send JSON error response
303     *
304     * @param string $message Error message
305     * @param int $status HTTP status code
306     * @param string $code Error code
307     */
308    private function sendErrorResponse(string $message, int $status = 400, string $code = 'ERROR'): void
309    {
310        $this->app->response->headers->set('Content-Type', 'application/json');
311        $this->app->response->setStatus($status);
312        $this->app->response->setBody(json_encode([
313            'success' => false,
314            'error' => $message,
315            'code' => $code
316        ]));
317    }
318
319    /**
320     * Get current user ID
321     */
322    private function getCurrentUserId(): int
323    {
324        return (int)($this->app->user->id ?? 0);
325    }
326
327    /**
328     * Get store timezone
329     */
330    private function getStoreTimezone(): string
331    {
332        return $this->store->getTimeZone() ?: 'America/Los_Angeles';
333    }
334
335    /**
336     * Parse a request datetime as an instant, interpreting values without a timezone as store-local,
337     * and return it in UTC.
338     */
339    private function parseInstantAsUtc(string $value): DateTime
340    {
341        $storeTz = new DateTimeZone($this->getStoreTimezone());
342        $dt = new DateTime($value, $storeTz);
343        $dt->setTimezone(new DateTimeZone('UTC'));
344        return $dt;
345    }
346
347    /**
348     * Parse a weekStart parameter (date-only or datetime) and return the store week start instant in UTC.
349     *
350     * - If passed YYYY-MM-DD, interpret it as store-local week start date.
351     * - If passed a datetime, interpret it as an instant, convert to store-local, then compute the store week start.
352     */
353    private function parseWeekStartParamAsWeekStartUtc(string $value): DateTime
354    {
355        $storeTz = new DateTimeZone($this->getStoreTimezone());
356
357        if (preg_match('/^\\d{4}-\\d{2}-\\d{2}$/', $value) === 1) {
358            $dayLocal = new DateTime($value, $storeTz);
359            $weekStartLocal = $this->getWeekStart($dayLocal);
360        } else {
361            $localInstant = new DateTime($value, $storeTz);
362            $localInstant->setTimezone($storeTz);
363            $weekStartLocal = $this->getWeekStart($localInstant);
364        }
365
366        $weekStartUtc = clone $weekStartLocal;
367        $weekStartUtc->setTimezone(new DateTimeZone('UTC'));
368        return $weekStartUtc;
369    }
370
371    /**
372     * Calculate the store-local week start for a given instant, returned as UTC.
373     *
374     * The UI and DB store timestamps in UTC, but week boundaries are defined by store-local settings.
375     */
376    private function getWeekStartUtcForInstant(DateTime $instantUtc): DateTime
377    {
378        $storeTz = new DateTimeZone($this->getStoreTimezone());
379        $utc = new DateTimeZone('UTC');
380
381        $localInstant = clone $instantUtc;
382        $localInstant->setTimezone($storeTz);
383
384        $weekStartLocal = $this->getWeekStart($localInstant);
385        $weekStartLocal->setTimezone($utc);
386
387        return $weekStartLocal;
388    }
389
390    /**
391     * Format a LaborCostResult for API response.
392     */
393    private function formatLaborCostResultForApi(LaborCostResult $result): array
394    {
395        return [
396            'regularHours' => $result->getRegularHours(),
397            'overtimeHours' => $result->getOvertimeHours(),
398            'doubletimeHours' => $result->getDoubletimeHours(),
399            'regularCost' => $result->getRegularCost(),
400            'overtimeCost' => $result->getOvertimeCost(),
401            'doubletimeCost' => $result->getDoubletimeCost(),
402            'totalCost' => $result->getTotalCost(),
403            'employeeBreakdown' => $result->getEmployeeBreakdown(),
404        ];
405    }
406
407    /**
408     * @param int[] $userIds Central DB user IDs
409     * @return array<int, float> userId => hourlyRate
410     */
411    private function getEffectivePayRatesForUsers(array $userIds, DateTime $asOfUtc): array
412    {
413        if (empty($userIds)) {
414            return [];
415        }
416
417        $placeholders = [];
418        foreach ($userIds as $i => $userId) {
419            $placeholders[] = ":uid{$i}";
420        }
421
422        $asOf = $asOfUtc->format('Y-m-d H:i:s');
423
424        // Prefer store-specific rates
425        $stmt = $this->getCentralDb()->prepare("
426            SELECT pr.userId, pr.hourlyRate
427            FROM userPayRates pr
428            INNER JOIN (
429                SELECT userId, MAX(effectiveAt) AS maxEffectiveAt
430                FROM userPayRates
431                WHERE typeNum = :typeNum
432                  AND effectiveAt <= :asOf
433                  AND userId IN (" . implode(', ', $placeholders) . ")
434                GROUP BY userId
435            ) latest ON latest.userId = pr.userId AND latest.maxEffectiveAt = pr.effectiveAt
436            WHERE pr.typeNum = :typeNum
437        ");
438        $stmt->bindValue(':typeNum', $this->typeNum);
439        $stmt->bindValue(':asOf', $asOf);
440        foreach ($userIds as $i => $userId) {
441            $stmt->bindValue(":uid{$i}", (int)$userId, PDO::PARAM_INT);
442        }
443        $stmt->execute();
444
445        $rates = [];
446        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
447            $rates[(int)$row['userId']] = (float)$row['hourlyRate'];
448        }
449
450        // Fallback to global (typeNum IS NULL) for any missing users
451        $missing = [];
452        foreach ($userIds as $userId) {
453            if (!array_key_exists((int)$userId, $rates)) {
454                $missing[] = (int)$userId;
455            }
456        }
457        if (empty($missing)) {
458            return $rates;
459        }
460
461        $placeholders = [];
462        foreach ($missing as $i => $userId) {
463            $placeholders[] = ":gid{$i}";
464        }
465
466        $stmt = $this->getCentralDb()->prepare("
467            SELECT pr.userId, pr.hourlyRate
468            FROM userPayRates pr
469            INNER JOIN (
470                SELECT userId, MAX(effectiveAt) AS maxEffectiveAt
471                FROM userPayRates
472                WHERE typeNum IS NULL
473                  AND effectiveAt <= :asOf
474                  AND userId IN (" . implode(', ', $placeholders) . ")
475                GROUP BY userId
476            ) latest ON latest.userId = pr.userId AND latest.maxEffectiveAt = pr.effectiveAt
477            WHERE pr.typeNum IS NULL
478        ");
479        $stmt->bindValue(':asOf', $asOf);
480        foreach ($missing as $i => $userId) {
481            $stmt->bindValue(":gid{$i}", (int)$userId, PDO::PARAM_INT);
482        }
483        $stmt->execute();
484
485        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
486            $rates[(int)$row['userId']] = (float)$row['hourlyRate'];
487        }
488
489        return $rates;
490    }
491
492    private function isUserAssignedToStore(int $userId): bool
493    {
494        $stmt = $this->getCentralDb()->prepare("
495            SELECT 1
496            FROM userStoreAssignments usa
497            WHERE usa.userId = :userId
498              AND usa.typeNum = :typeNum
499              AND usa.isActive = 1
500            LIMIT 1
501        ");
502        $stmt->bindValue(':userId', $userId, PDO::PARAM_INT);
503        $stmt->bindValue(':typeNum', $this->typeNum);
504        $stmt->execute();
505
506        return (bool)$stmt->fetchColumn();
507    }
508
509    // =========================================================================
510    // SHIFT MANAGEMENT ENDPOINTS
511    // =========================================================================
512
513    /**
514     * GET /api/:typeNum/schedule/shifts
515     *
516     * Get shifts for a date range.
517     *
518     * Query Parameters:
519     * - start: ISO date (required) - Range start
520     * - end: ISO date (required) - Range end
521     *
522     * @see SDD Lines 836-852
523     */
524    public function getShifts(): void
525    {
526        if (!$this->checkReadAuth()) {
527            return;
528        }
529
530        $start = $this->app->request->get('start');
531        $end = $this->app->request->get('end');
532
533        if (!$start || !$end) {
534            $this->sendErrorResponse('start and end parameters are required', 400, 'MISSING_PARAMS');
535            return;
536        }
537
538        try {
539            $startDateUtc = $this->parseInstantAsUtc($start);
540            $endDateUtc = $this->parseInstantAsUtc($end);
541
542            $shifts = $this->getShiftRepository()->findByDateRange($startDateUtc, $endDateUtc);
543
544            $response = [];
545            foreach ($shifts as $shift) {
546                $response[] = $this->formatShiftForApi($shift);
547            }
548
549            $this->sendJsonResponse($response);
550        } catch (Exception $e) {
551            error_log("SchedulingController::getShifts error: " . $e->getMessage());
552            $this->sendErrorResponse('Failed to retrieve shifts', 500, 'SERVER_ERROR');
553        }
554    }
555
556    /**
557     * POST /api/:typeNum/schedule/shifts
558     *
559     * Create a new shift.
560     *
561     * @see SDD Lines 854-868
562     */
563    public function createShift(): void
564    {
565        if (!$this->checkWriteAuth()) {
566            return;
567        }
568
569        $data = json_decode($this->app->request->getBody(), true);
570
571        if (!$data || !isset($data['employeeId']) || !isset($data['shiftStart']) || !isset($data['shiftEnd'])) {
572            $this->sendErrorResponse('employeeId, shiftStart, and shiftEnd are required', 400, 'MISSING_PARAMS');
573            return;
574        }
575
576        try {
577            $tz = new DateTimeZone($this->getStoreTimezone());
578            $shiftStart = new DateTime($data['shiftStart'], $tz);
579            $shiftEnd = new DateTime($data['shiftEnd'], $tz);
580            $shiftStart->setTimezone(new DateTimeZone('UTC'));
581            $shiftEnd->setTimezone(new DateTimeZone('UTC'));
582            $employeeId = (int)$data['employeeId'];
583
584            if ($employeeId <= 0) {
585                $this->sendErrorResponse('employeeId must be a positive integer', 400, 'INVALID_EMPLOYEE');
586                return;
587            }
588            if (!$this->isUserAssignedToStore($employeeId)) {
589                $this->sendErrorResponse('Employee is not assigned to this store', 404, 'EMPLOYEE_NOT_FOUND');
590                return;
591            }
592
593            $shift = new Shift(
594                $employeeId,
595                $shiftStart,
596                $shiftEnd,
597                $this->getCurrentUserId()
598            );
599
600            if (isset($data['positionId'])) {
601                $shift->setPositionId((int)$data['positionId']);
602            }
603
604            if (isset($data['notes'])) {
605                $shift->setNotes($data['notes']);
606            }
607
608            $createdShift = $this->getShiftRepository()->create($shift);
609
610            // Write audit log
611            $this->getShiftAuditRepository()->logCreate(
612                $createdShift->getShiftId(),
613                $this->getCurrentUserId(),
614                $createdShift->toDbArray()
615            );
616
617            // Calculate labor cost impact
618            $weekStartUtc = $this->getWeekStartUtcForInstant($shiftStart);
619            $laborCost = $this->getLaborCostCalculator()->calculateWeekCost(
620                $this->typeNum,
621                $weekStartUtc,
622                $this->getStoreTimezone()
623            );
624
625            $this->sendJsonResponse([
626                'success' => true,
627                'shiftId' => $createdShift->getShiftId(),
628                'laborCost' => $this->formatLaborCostResultForApi($laborCost),
629            ], 201);
630        } catch (Exception $e) {
631            if (strpos($e->getMessage(), 'OVERLAP') !== false) {
632                $this->sendErrorResponse('Employee already has a shift during this time', 409, 'OVERLAP');
633            } else {
634                error_log("SchedulingController::createShift error: " . $e->getMessage());
635                $this->sendErrorResponse('Failed to create shift', 500, 'SERVER_ERROR');
636            }
637        }
638    }
639
640    /**
641     * PUT /api/:typeNum/schedule/shifts/:shiftId
642     *
643     * Update an existing shift with optimistic concurrency.
644     *
645     * @param int $shiftId Shift ID to update
646     * @see SDD Lines 870-884
647     */
648    public function updateShift(int $shiftId): void
649    {
650        if (!$this->checkWriteAuth()) {
651            return;
652        }
653
654        $data = json_decode($this->app->request->getBody(), true);
655
656        if (!$data) {
657            $this->sendErrorResponse('Request body is required', 400, 'MISSING_BODY');
658            return;
659        }
660
661        try {
662            $existingShift = $this->getShiftRepository()->findById($shiftId);
663
664            if (!$existingShift) {
665                $this->sendErrorResponse('Shift not found', 404, 'NOT_FOUND');
666                return;
667            }
668
669            // Capture old values for audit log
670            $oldValues = $existingShift->toDbArray();
671
672            $tz = new DateTimeZone($this->getStoreTimezone());
673
674            // Apply updates
675            if (isset($data['employeeId'])) {
676                $employeeId = (int)$data['employeeId'];
677                if ($employeeId <= 0) {
678                    $this->sendErrorResponse('employeeId must be a positive integer', 400, 'INVALID_EMPLOYEE');
679                    return;
680                }
681                if (!$this->isUserAssignedToStore($employeeId)) {
682                    $this->sendErrorResponse('Employee is not assigned to this store', 404, 'EMPLOYEE_NOT_FOUND');
683                    return;
684                }
685                $existingShift->setEmployeeId($employeeId);
686            }
687            if (isset($data['shiftStart'])) {
688                $dt = new DateTime($data['shiftStart'], $tz);
689                $dt->setTimezone(new DateTimeZone('UTC'));
690                $existingShift->setShiftStart($dt);
691            }
692            if (isset($data['shiftEnd'])) {
693                $dt = new DateTime($data['shiftEnd'], $tz);
694                $dt->setTimezone(new DateTimeZone('UTC'));
695                $existingShift->setShiftEnd($dt);
696            }
697            if (array_key_exists('positionId', $data)) {
698                $existingShift->setPositionId($data['positionId'] ? (int)$data['positionId'] : null);
699            }
700            if (isset($data['notes'])) {
701                $existingShift->setNotes($data['notes']);
702            }
703
704            // Optimistic concurrency check
705            $expectedUpdatedAt = isset($data['updatedAt'])
706                ? new DateTime($data['updatedAt'], new DateTimeZone('UTC'))
707                : null;
708
709            $updatedShift = $this->getShiftRepository()->update($existingShift, $expectedUpdatedAt);
710
711            // Write audit log
712            $this->getShiftAuditRepository()->logUpdate(
713                $updatedShift->getShiftId(),
714                $this->getCurrentUserId(),
715                $oldValues,
716                $updatedShift->toDbArray()
717            );
718
719            // Calculate labor cost impact
720            $weekStartUtc = $this->getWeekStartUtcForInstant($updatedShift->getShiftStart());
721            $laborCost = $this->getLaborCostCalculator()->calculateWeekCost(
722                $this->typeNum,
723                $weekStartUtc,
724                $this->getStoreTimezone()
725            );
726
727            $this->sendJsonResponse([
728                'success' => true,
729                'laborCost' => $this->formatLaborCostResultForApi($laborCost),
730            ]);
731        } catch (Exception $e) {
732            if (strpos($e->getMessage(), 'OVERLAP') !== false) {
733                $this->sendErrorResponse('Employee already has a shift during this time', 409, 'OVERLAP');
734            } elseif (strpos($e->getMessage(), 'STALE_WRITE') !== false) {
735                $this->sendErrorResponse('Shift changed since you loaded it. Please reload and try again.', 409, 'STALE_WRITE');
736            } else {
737                error_log("SchedulingController::updateShift error: " . $e->getMessage());
738                $this->sendErrorResponse('Failed to update shift', 500, 'SERVER_ERROR');
739            }
740        }
741    }
742
743    /**
744     * DELETE /api/:typeNum/schedule/shifts/:shiftId
745     *
746     * Soft delete a shift.
747     *
748     * @param int $shiftId Shift ID to delete
749     * @see SDD Lines 886-888
750     */
751    public function deleteShift(int $shiftId): void
752    {
753        if (!$this->checkWriteAuth()) {
754            return;
755        }
756
757        try {
758            $shift = $this->getShiftRepository()->findById($shiftId);
759
760            if (!$shift) {
761                $this->sendErrorResponse('Shift not found', 404, 'NOT_FOUND');
762                return;
763            }
764
765            $deleted = $this->getShiftRepository()->softDelete($shiftId);
766
767            if ($deleted) {
768                // Write audit log
769                $this->getShiftAuditRepository()->logDelete(
770                    $shift->getShiftId(),
771                    $this->getCurrentUserId(),
772                    $shift->toDbArray()
773                );
774            }
775
776            $this->sendJsonResponse(['success' => $deleted]);
777        } catch (Exception $e) {
778            error_log("SchedulingController::deleteShift error: " . $e->getMessage());
779            $this->sendErrorResponse('Failed to delete shift', 500, 'SERVER_ERROR');
780        }
781    }
782
783    /**
784     * GET /api/:typeNum/schedule/shifts/copy-preview
785     *
786     * Preview what would be copied and identify conflicts.
787     *
788     * Query Parameters:
789     * - sourceStart: ISO date (required) - Source week start
790     * - targetStart: ISO date (required) - Target week start
791     *
792     * @see SDD Lines 890-896
793     */
794    public function getCopyPreview(): void
795    {
796        if (!$this->checkReadAuth()) {
797            return;
798        }
799
800        $sourceStart = $this->app->request->get('sourceStart');
801        $targetStart = $this->app->request->get('targetStart');
802
803        if (!$sourceStart || !$targetStart) {
804            $this->sendErrorResponse('sourceStart and targetStart parameters are required', 400, 'MISSING_PARAMS');
805            return;
806        }
807
808        try {
809            $sourceStartDate = $this->parseInstantAsUtc($sourceStart);
810            $targetStartDate = $this->parseInstantAsUtc($targetStart);
811
812            $preview = $this->getShiftRepository()->getCopyPreview($sourceStartDate, $targetStartDate);
813
814            $this->sendJsonResponse([
815                'shiftCount' => $preview['shiftCount'],
816                'conflicts' => $preview['conflicts']
817            ]);
818        } catch (Exception $e) {
819            error_log("SchedulingController::getCopyPreview error: " . $e->getMessage());
820            $this->sendErrorResponse('Failed to generate copy preview', 500, 'SERVER_ERROR');
821        }
822    }
823
824    /**
825     * POST /api/:typeNum/schedule/shifts/copy
826     *
827     * Copy shifts from one week to another.
828     *
829     * @see SDD Lines 898-907
830     */
831    public function copyWeek(): void
832    {
833        if (!$this->checkWriteAuth()) {
834            return;
835        }
836
837        $data = json_decode($this->app->request->getBody(), true);
838
839        if (!$data || !isset($data['sourceWeekStart']) || !isset($data['targetWeekStart'])) {
840            $this->sendErrorResponse('sourceWeekStart and targetWeekStart are required', 400, 'MISSING_PARAMS');
841            return;
842        }
843
844        try {
845            $sourceStart = $this->parseInstantAsUtc($data['sourceWeekStart']);
846            $targetStart = $this->parseInstantAsUtc($data['targetWeekStart']);
847            $overwrite = $data['overwriteConflicts'] ?? false;
848
849            $result = $this->getShiftRepository()->copyWeek(
850                $sourceStart,
851                $targetStart,
852                $overwrite,
853                $this->getCurrentUserId()
854            );
855
856            $this->sendJsonResponse([
857                'success' => true,
858                'copiedCount' => $result['copied'],
859                'skippedCount' => $result['skipped']
860            ]);
861        } catch (Exception $e) {
862            error_log("SchedulingController::copyWeek error: " . $e->getMessage());
863            $this->sendErrorResponse('Failed to copy week', 500, 'SERVER_ERROR');
864        }
865    }
866
867    // =========================================================================
868    // RESOURCE ENDPOINTS
869    // =========================================================================
870
871    /**
872     * GET /api/:typeNum/schedule/employees
873     *
874     * Get employees available for scheduling.
875     *
876     * @see SDD Lines 909-921
877     */
878    public function getEmployees(): void
879    {
880        if (!$this->checkReadAuth()) {
881            return;
882        }
883
884        try {
885            // Optional: allow UI to request hours for a specific week
886            $weekStartParam = $this->app->request->get('weekStart');
887
888            $storeTz = new DateTimeZone($this->getStoreTimezone());
889            if ($weekStartParam) {
890                $weekStartUtc = $this->parseWeekStartParamAsWeekStartUtc($weekStartParam);
891            } else {
892                $weekStartLocal = $this->getWeekStart(new DateTime('now', $storeTz));
893                $weekStartUtc = clone $weekStartLocal;
894                $weekStartUtc->setTimezone(new DateTimeZone('UTC'));
895            }
896
897            $weekEndUtc = (clone $weekStartUtc)->modify('+7 days');
898
899            // Query active employees from central users table + weekly scheduled hours
900            // Uses kiosk_users.userStoreAssignments to find users associated with this store
901            $stmt = $this->getDb()->prepare("
902                SELECT
903                    u.id,
904                    CONCAT(u.firstName, ' ', u.lastName) as name,
905                    u.firstName,
906                    u.lastName,
907                    u.photoUrl,
908                    usa.role,
909                    NULL as position,
910                    NULL as positionColor,
911                    COALESCE(wh.weeklyHours, 0) as weeklyHours
912                FROM kiosk_users.users u
913                INNER JOIN kiosk_users.userStoreAssignments usa ON u.id = usa.userId
914                LEFT JOIN (
915                    SELECT
916                        employeeId,
917                        SUM(TIMESTAMPDIFF(MINUTE, shiftStart, shiftEnd)) / 60 as weeklyHours
918                    FROM scheduleShifts
919                    WHERE shiftStart >= :weekStart
920                      AND shiftStart < :weekEnd
921                      AND deleted_at IS NULL
922                    GROUP BY employeeId
923                ) wh ON wh.employeeId = u.id
924                WHERE usa.typeNum = :typeNum
925                  AND usa.isActive = 1
926                  AND u.enabled = 1
927                ORDER BY u.lastName, u.firstName
928            ");
929
930            $stmt->bindValue(':weekStart', $weekStartUtc->format('Y-m-d H:i:s'));
931            $stmt->bindValue(':weekEnd', $weekEndUtc->format('Y-m-d H:i:s'));
932            $stmt->bindValue(':typeNum', $this->typeNum);
933            $stmt->execute();
934
935            $employees = $stmt->fetchAll(PDO::FETCH_ASSOC);
936
937            $userIds = [];
938            foreach ($employees as &$emp) {
939                $emp['id'] = (int)$emp['id'];
940                $emp['role'] = isset($emp['role']) ? (int)$emp['role'] : 0;
941                $emp['weeklyHours'] = round((float)$emp['weeklyHours'], 2);
942                $userIds[] = $emp['id'];
943            }
944
945            // Add effective hourlyRate (if available)
946            // Note: id IS the userId now (no more mapping needed)
947            $payRatesByUserId = $this->getEffectivePayRatesForUsers($userIds, $weekStartUtc);
948
949            foreach ($employees as &$emp) {
950                $emp['userId'] = $emp['id']; // userId === id (central user ID)
951                $emp['hourlyRate'] = isset($payRatesByUserId[$emp['id']])
952                    ? (float)$payRatesByUserId[$emp['id']]
953                    : null;
954            }
955
956            $this->sendJsonResponse($employees);
957        } catch (Exception $e) {
958            error_log("SchedulingController::getEmployees error: " . $e->getMessage());
959            $this->sendErrorResponse('Failed to retrieve employees', 500, 'SERVER_ERROR');
960        }
961    }
962
963    /**
964     * GET /api/:typeNum/schedule/positions
965     *
966     * Get store positions for shift assignment.
967     *
968     * @see SDD Lines 923-933
969     */
970    public function getPositions(): void
971    {
972        if (!$this->checkReadAuth()) {
973            return;
974        }
975
976        try {
977            $positions = $this->getPositionRepository()->findAll();
978
979            $response = [];
980            foreach ($positions as $position) {
981                $response[] = [
982                    'positionId' => $position->getPositionId(),
983                    'name' => $position->getName(),
984                    'color' => $position->getColor(),
985                    'isActive' => $position->isActive(),
986                    'sortOrder' => $position->getSortOrder()
987                ];
988            }
989
990            $this->sendJsonResponse($response);
991        } catch (Exception $e) {
992            error_log("SchedulingController::getPositions error: " . $e->getMessage());
993            $this->sendErrorResponse('Failed to retrieve positions', 500, 'SERVER_ERROR');
994        }
995    }
996
997    // =========================================================================
998    // LABOR COST ENDPOINT
999    // =========================================================================
1000
1001    /**
1002     * GET /api/:typeNum/schedule/labor-cost
1003     *
1004     * Get labor cost summary for a week.
1005     *
1006     * Query Parameters:
1007     * - weekStart: ISO date (required) - Start of week
1008     *
1009     * @see SDD Lines 935-958
1010     */
1011    public function getLaborCost(): void
1012    {
1013        if (!$this->checkReadAuth()) {
1014            return;
1015        }
1016
1017        $weekStart = $this->app->request->get('weekStart');
1018
1019        if (!$weekStart) {
1020            $this->sendErrorResponse('weekStart parameter is required', 400, 'MISSING_PARAMS');
1021            return;
1022        }
1023
1024        try {
1025            $weekStartUtc = $this->parseWeekStartParamAsWeekStartUtc($weekStart);
1026
1027            $result = $this->getLaborCostCalculator()->calculateWeekCost(
1028                $this->typeNum,
1029                $weekStartUtc,
1030                $this->getStoreTimezone()
1031            );
1032
1033            $this->sendJsonResponse([
1034                'regularHours' => $result->getRegularHours(),
1035                'overtimeHours' => $result->getOvertimeHours(),
1036                'doubletimeHours' => $result->getDoubletimeHours(),
1037                'regularCost' => $result->getRegularCost(),
1038                'overtimeCost' => $result->getOvertimeCost(),
1039                'doubletimeCost' => $result->getDoubletimeCost(),
1040                'totalCost' => $result->getTotalCost(),
1041                'employeeBreakdown' => $result->getEmployeeBreakdown()
1042            ]);
1043        } catch (Exception $e) {
1044            error_log("SchedulingController::getLaborCost error: " . $e->getMessage());
1045            $this->sendErrorResponse('Failed to calculate labor cost', 500, 'SERVER_ERROR');
1046        }
1047    }
1048
1049    // =========================================================================
1050    // CONFIGURATION ENDPOINTS
1051    // =========================================================================
1052
1053    /**
1054     * GET /api/:typeNum/schedule/config
1055     *
1056     * Get store scheduling configuration.
1057     *
1058     * @see SDD Lines 967-989
1059     */
1060    public function getConfig(): void
1061    {
1062        if (!$this->checkReadAuth()) {
1063            return;
1064        }
1065
1066        try {
1067            // Stores table is in kiosk_buykiosk database
1068            $stmt = $this->getBuykioskDb()->prepare("
1069                SELECT
1070                    schedulingProvider,
1071                    clockInEarlyMinutes,
1072                    clockInLateMinutes,
1073                    clockOutLateMinutes,
1074                    requireManagerOverrideOutsideClockWindow,
1075                    requireManagerOverrideForUnscheduledClockIn,
1076                    payrollRoundingIncrementMinutes,
1077                    payrollRoundingMode,
1078                    workWeekStartDay,
1079                    workWeekStartTimeLocal,
1080                    statutoryHolidaysMode,
1081                    statutoryHolidayDates,
1082                    schedulePermissions,
1083                    externalProviderManualPunchFallbackEnabled
1084                FROM stores
1085                WHERE typeNum = :typeNum
1086            ");
1087            $stmt->bindValue(':typeNum', $this->typeNum);
1088            $stmt->execute();
1089
1090            $config = $stmt->fetch(PDO::FETCH_ASSOC);
1091
1092            if (!$config) {
1093                $this->sendErrorResponse('Store not found', 404, 'NOT_FOUND');
1094                return;
1095            }
1096
1097            // Parse JSON fields
1098            $config['statutoryHolidayDates'] = $config['statutoryHolidayDates']
1099                ? json_decode($config['statutoryHolidayDates'], true)
1100                : [];
1101            $config['schedulePermissions'] = $config['schedulePermissions']
1102                ? json_decode($config['schedulePermissions'], true)
1103                : null;
1104
1105            // Convert boolean fields
1106            $config['requireManagerOverrideOutsideClockWindow'] = (bool)$config['requireManagerOverrideOutsideClockWindow'];
1107            $config['requireManagerOverrideForUnscheduledClockIn'] = (bool)$config['requireManagerOverrideForUnscheduledClockIn'];
1108            $config['externalProviderManualPunchFallbackEnabled'] = (bool)$config['externalProviderManualPunchFallbackEnabled'];
1109
1110            // Convert numeric fields
1111            $config['clockInEarlyMinutes'] = (int)$config['clockInEarlyMinutes'];
1112            $config['clockInLateMinutes'] = (int)$config['clockInLateMinutes'];
1113            $config['clockOutLateMinutes'] = (int)$config['clockOutLateMinutes'];
1114            $config['payrollRoundingIncrementMinutes'] = (int)$config['payrollRoundingIncrementMinutes'];
1115
1116            $this->sendJsonResponse($config);
1117        } catch (Exception $e) {
1118            error_log("SchedulingController::getConfig error: " . $e->getMessage());
1119            $this->sendErrorResponse('Failed to retrieve configuration', 500, 'SERVER_ERROR');
1120        }
1121    }
1122
1123    /**
1124     * PUT /api/:typeNum/schedule/config
1125     *
1126     * Update store scheduling configuration.
1127     *
1128     * @see SDD Lines 986-989
1129     */
1130    public function updateConfig(): void
1131    {
1132        if (!$this->checkConfigAuth()) {
1133            return;
1134        }
1135
1136        $data = json_decode($this->app->request->getBody(), true);
1137
1138        if (!$data) {
1139            $this->sendErrorResponse('Request body is required', 400, 'MISSING_BODY');
1140            return;
1141        }
1142
1143        try {
1144            $allowedFields = [
1145                'schedulingProvider',
1146                'clockInEarlyMinutes',
1147                'clockInLateMinutes',
1148                'clockOutLateMinutes',
1149                'requireManagerOverrideOutsideClockWindow',
1150                'requireManagerOverrideForUnscheduledClockIn',
1151                'payrollRoundingIncrementMinutes',
1152                'payrollRoundingMode',
1153                'workWeekStartDay',
1154                'workWeekStartTimeLocal',
1155                'statutoryHolidaysMode',
1156                'statutoryHolidayDates',
1157                'schedulePermissions',
1158                'externalProviderManualPunchFallbackEnabled'
1159            ];
1160
1161            $updates = [];
1162            $values = [];
1163
1164            foreach ($data as $field => $value) {
1165                if (in_array($field, $allowedFields)) {
1166                    $updates[] = "$field = :$field";
1167
1168                    // Handle JSON fields
1169                    if (in_array($field, ['statutoryHolidayDates', 'schedulePermissions'])) {
1170                        $values[$field] = $value !== null ? json_encode($value) : null;
1171                    } else {
1172                        $values[$field] = $value;
1173                    }
1174                }
1175            }
1176
1177            if (empty($updates)) {
1178                $this->sendErrorResponse('No valid fields to update', 400, 'NO_UPDATES');
1179                return;
1180            }
1181
1182            // Stores table is in kiosk_buykiosk database
1183            $sql = "UPDATE stores SET " . implode(', ', $updates) . " WHERE typeNum = :typeNum";
1184            $stmt = $this->getBuykioskDb()->prepare($sql);
1185
1186            foreach ($values as $field => $value) {
1187                $stmt->bindValue(":$field", $value);
1188            }
1189            $stmt->bindValue(':typeNum', $this->typeNum);
1190            $stmt->execute();
1191
1192            $this->sendJsonResponse(['success' => true]);
1193        } catch (Exception $e) {
1194            error_log("SchedulingController::updateConfig error: " . $e->getMessage());
1195            $this->sendErrorResponse('Failed to update configuration', 500, 'SERVER_ERROR');
1196        }
1197    }
1198
1199    // =========================================================================
1200    // OVERTIME CONFIGURATION ENDPOINTS
1201    // =========================================================================
1202
1203    /**
1204     * GET /api/:typeNum/schedule/overtime/config
1205     *
1206     * Get overtime configuration for the store.
1207     *
1208     * @see SDD Lines 998-1008
1209     */
1210    public function getOvertimeConfig(): void
1211    {
1212        if (!$this->checkReadAuth()) {
1213            return;
1214        }
1215
1216        try {
1217            $rules = $this->getOvertimeCalculator()->loadRulesForStore($this->typeNum, new DateTime());
1218
1219            $this->sendJsonResponse([
1220                'countryCode' => $rules['countryCode'] ?? null,
1221                'regionCode' => $rules['regionCode'] ?? null,
1222                'ruleType' => $rules['ruleType'] ?? 'preset',
1223                'presetKey' => $rules['presetKey'] ?? null,
1224                'effectiveStartDate' => $rules['effectiveStartDate'] ?? null,
1225                'ruleSchemaVersion' => $rules['ruleSchemaVersion'] ?? 1,
1226                'ruleJson' => $rules['ruleJson'] ?? null
1227            ]);
1228        } catch (Exception $e) {
1229            error_log("SchedulingController::getOvertimeConfig error: " . $e->getMessage());
1230            $this->sendErrorResponse('Failed to retrieve overtime configuration', 500, 'SERVER_ERROR');
1231        }
1232    }
1233
1234    /**
1235     * PUT /api/:typeNum/schedule/overtime/config
1236     *
1237     * Update overtime configuration for the store.
1238     *
1239     * @see SDD Lines 1010-1021
1240     */
1241    public function updateOvertimeConfig(): void
1242    {
1243        if (!$this->checkConfigAuth()) {
1244            return;
1245        }
1246
1247        $data = json_decode($this->app->request->getBody(), true);
1248
1249        if (!$data) {
1250            $this->sendErrorResponse('Request body is required', 400, 'MISSING_BODY');
1251            return;
1252        }
1253
1254        try {
1255            $requiredFields = ['countryCode', 'regionCode', 'ruleType', 'effectiveStartDate'];
1256            foreach ($requiredFields as $field) {
1257                if (!isset($data[$field])) {
1258                    $this->sendErrorResponse("$field is required", 400, 'MISSING_FIELD');
1259                    return;
1260                }
1261            }
1262
1263            // Check if rule already exists for this effective date
1264            $stmt = $this->getCentralDb()->prepare("
1265                SELECT ruleId FROM scheduleOvertimeRules
1266                WHERE typeNum = :typeNum AND effectiveStartDate = :effectiveStartDate
1267            ");
1268            $stmt->bindValue(':typeNum', $this->typeNum);
1269            $stmt->bindValue(':effectiveStartDate', $data['effectiveStartDate']);
1270            $stmt->execute();
1271
1272            $existing = $stmt->fetch(PDO::FETCH_ASSOC);
1273
1274            if ($existing) {
1275                // Update existing rule
1276                $stmt = $this->getCentralDb()->prepare("
1277                    UPDATE scheduleOvertimeRules SET
1278                        countryCode = :countryCode,
1279                        regionCode = :regionCode,
1280                        ruleType = :ruleType,
1281                        presetKey = :presetKey,
1282                        ruleSchemaVersion = :ruleSchemaVersion,
1283                        ruleJson = :ruleJson
1284                    WHERE ruleId = :ruleId
1285                ");
1286                $stmt->bindValue(':ruleId', $existing['ruleId'], PDO::PARAM_INT);
1287            } else {
1288                // Insert new rule
1289                $stmt = $this->getCentralDb()->prepare("
1290                    INSERT INTO scheduleOvertimeRules
1291                    (typeNum, countryCode, regionCode, ruleType, presetKey, effectiveStartDate, ruleSchemaVersion, ruleJson)
1292                    VALUES
1293                    (:typeNum, :countryCode, :regionCode, :ruleType, :presetKey, :effectiveStartDate, :ruleSchemaVersion, :ruleJson)
1294                ");
1295                $stmt->bindValue(':typeNum', $this->typeNum);
1296                $stmt->bindValue(':effectiveStartDate', $data['effectiveStartDate']);
1297            }
1298
1299            $stmt->bindValue(':countryCode', $data['countryCode']);
1300            $stmt->bindValue(':regionCode', $data['regionCode']);
1301            $stmt->bindValue(':ruleType', $data['ruleType']);
1302            $stmt->bindValue(':presetKey', $data['presetKey'] ?? null);
1303            $stmt->bindValue(':ruleSchemaVersion', $data['ruleSchemaVersion'] ?? 1, PDO::PARAM_INT);
1304            $stmt->bindValue(':ruleJson', isset($data['ruleJson']) ? json_encode($data['ruleJson']) : null);
1305            $stmt->execute();
1306
1307            $this->sendJsonResponse(['success' => true]);
1308        } catch (Exception $e) {
1309            error_log("SchedulingController::updateOvertimeConfig error: " . $e->getMessage());
1310            $this->sendErrorResponse('Failed to update overtime configuration', 500, 'SERVER_ERROR');
1311        }
1312    }
1313
1314    // =========================================================================
1315    // EMPLOYEE SCHEDULE VIEW
1316    // =========================================================================
1317
1318    /**
1319     * GET /api/:typeNum/schedule/my-schedule
1320     *
1321     * Get the logged-in employee's schedule for current week + 4 weeks.
1322     * This is a read-only view for employees to see their upcoming shifts.
1323     *
1324     * Response includes:
1325     * - weeks: Array of week objects with shifts and total hours
1326     * - employee: Employee info
1327     * - provider: Active scheduling provider ('buyerkiosk')
1328     *
1329     * @see PRD Feature 15: Employee Schedule View
1330     */
1331    public function getMySchedule(): void
1332    {
1333        // Basic auth check - any logged-in user can see their own schedule
1334        if (!isset($this->app->user) || !$this->app->user) {
1335            $this->sendErrorResponse('Authentication required', 401, 'UNAUTHORIZED');
1336            return;
1337        }
1338
1339        // Check if BuyerKiosk scheduling is enabled
1340        $provider = $this->store->getSchedulingProvider();
1341        if ($provider !== 'buyerkiosk') {
1342            $this->sendErrorResponse(
1343                'Employee schedule view is only available with BuyerKiosk scheduling',
1344                400,
1345                'PROVIDER_NOT_SUPPORTED'
1346            );
1347            return;
1348        }
1349
1350        try {
1351            // For native scheduling, scheduleShifts.employeeId is the kiosk_users.users.id
1352            $employeeId = $this->getEmployeeIdForCurrentUser();
1353
1354            if (!$employeeId) {
1355                $this->sendErrorResponse('Unable to resolve current user', 401, 'UNAUTHORIZED');
1356                return;
1357            }
1358
1359            // Get employee info
1360            $employeeInfo = $this->getEmployeeInfo($employeeId);
1361
1362            // Calculate date range: current week start through 4 weeks out
1363            $storeTimezoneStr = $this->getStoreTimezone();
1364            $storeTimezone = new DateTimeZone($storeTimezoneStr);
1365            $now = new DateTime('now', $storeTimezone);
1366            $weekStart = $this->getWeekStart($now);
1367            $weekEnd = clone $weekStart;
1368            $weekEnd->modify('+5 weeks'); // 5 weeks = current + 4 upcoming
1369
1370            // Convert to UTC for query
1371            $weekStartUtc = clone $weekStart;
1372            $weekStartUtc->setTimezone(new DateTimeZone('UTC'));
1373            $weekEndUtc = clone $weekEnd;
1374            $weekEndUtc->setTimezone(new DateTimeZone('UTC'));
1375
1376            // Get all shifts for this employee in date range
1377            $shifts = $this->getShiftRepository()->findByEmployeeAndDateRange(
1378                $employeeId,
1379                $weekStartUtc,
1380                $weekEndUtc
1381            );
1382
1383            // Group shifts by week
1384            $weeks = $this->groupShiftsByWeek($shifts, $weekStart, 5, $storeTimezone);
1385
1386            $this->sendJsonResponse([
1387                'success' => true,
1388                'hasEmployeeLink' => true,
1389                'employee' => $employeeInfo,
1390                'provider' => 'buyerkiosk',
1391                'weeks' => $weeks,
1392                'storeTimezone' => $storeTimezoneStr
1393            ]);
1394        } catch (Exception $e) {
1395            error_log("SchedulingController::getMySchedule error: " . $e->getMessage());
1396            $this->sendErrorResponse('Failed to get schedule', 500, 'SERVER_ERROR');
1397        }
1398    }
1399
1400    /**
1401     * Get the schedule employee ID for the current user.
1402     *
1403     * For BuyerKiosk native scheduling, scheduleShifts.employeeId and scheduleTimePunches.employeeId
1404     * are kiosk_users.users.id values (not store employees.employeeID).
1405     *
1406     * @return int|null Employee ID or null if no link exists
1407     */
1408    private function getEmployeeIdForCurrentUser(): ?int
1409    {
1410        $userId = $this->getCurrentUserId();
1411        return $userId > 0 ? $userId : null;
1412    }
1413
1414    /**
1415     * Get employee info for display
1416     *
1417     * @param int $employeeId
1418     * @return array Employee info
1419     */
1420    private function getEmployeeInfo(int $employeeId): array
1421    {
1422        try {
1423            $centralDb = $this->getCentralDb();
1424
1425            $stmt = $centralDb->prepare("
1426                SELECT id, firstName, lastName, photoUrl AS avatar
1427                FROM users
1428                WHERE id = :employeeId
1429            ");
1430            $stmt->bindValue(':employeeId', $employeeId, PDO::PARAM_INT);
1431            $stmt->execute();
1432
1433            $user = $stmt->fetch(PDO::FETCH_ASSOC);
1434            if ($user) {
1435                return [
1436                    'id' => (int)$user['id'],
1437                    'firstName' => $user['firstName'],
1438                    'lastName' => $user['lastName'],
1439                    'fullName' => trim($user['firstName'] . ' ' . $user['lastName']),
1440                    'avatar' => $user['avatar']
1441                ];
1442            }
1443
1444            return [
1445                'id' => $employeeId,
1446                'firstName' => '',
1447                'lastName' => '',
1448                'fullName' => 'Unknown Employee',
1449                'avatar' => null
1450            ];
1451        } catch (Exception $e) {
1452            error_log("SchedulingController::getEmployeeInfo error: " . $e->getMessage());
1453            return [
1454                'id' => $employeeId,
1455                'firstName' => '',
1456                'lastName' => '',
1457                'fullName' => 'Unknown Employee',
1458                'avatar' => null
1459            ];
1460        }
1461    }
1462
1463    /**
1464     * Group shifts by week with total hours calculation
1465     *
1466     * @param Shift[] $shifts Array of shifts
1467     * @param DateTime $startWeek First week start date
1468     * @param int $numWeeks Number of weeks to include
1469     * @param DateTimeZone $timezone Store timezone
1470     * @return array Array of week data
1471     */
1472    private function groupShiftsByWeek(array $shifts, DateTime $startWeek, int $numWeeks, DateTimeZone $timezone): array
1473    {
1474        $weeks = [];
1475
1476        // Initialize weeks
1477        $weekStart = clone $startWeek;
1478        for ($i = 0; $i < $numWeeks; $i++) {
1479            $weekEnd = clone $weekStart;
1480            $weekEnd->modify('+7 days');
1481
1482            $weeks[] = [
1483                'weekStart' => $weekStart->format('Y-m-d'),
1484                'weekEnd' => $weekEnd->format('Y-m-d'),
1485                'weekLabel' => $this->formatWeekLabel($weekStart, $weekEnd),
1486                'shifts' => [],
1487                'totalHours' => 0
1488            ];
1489
1490            $weekStart = clone $weekEnd;
1491        }
1492
1493        // Assign shifts to weeks
1494        foreach ($shifts as $shift) {
1495            $shiftStartLocal = clone $shift->getShiftStart();
1496            $shiftStartLocal->setTimezone($timezone);
1497            $shiftDate = $shiftStartLocal->format('Y-m-d');
1498
1499            foreach ($weeks as $index => &$week) {
1500                if ($shiftDate >= $week['weekStart'] && $shiftDate < $week['weekEnd']) {
1501                    $shiftEndLocal = clone $shift->getShiftEnd();
1502                    $shiftEndLocal->setTimezone($timezone);
1503
1504                    $hours = ($shift->getShiftEnd()->getTimestamp() - $shift->getShiftStart()->getTimestamp()) / 3600;
1505
1506                    $week['shifts'][] = [
1507                        'shiftId' => $shift->getShiftId(),
1508                        'date' => $shiftDate,
1509                        'dayOfWeek' => $shiftStartLocal->format('l'),
1510                        'startTime' => $shiftStartLocal->format('g:i A'),
1511                        'endTime' => $shiftEndLocal->format('g:i A'),
1512                        'startTimeIso' => $shift->getShiftStart()->format('c'),
1513                        'endTimeIso' => $shift->getShiftEnd()->format('c'),
1514                        'position' => $shift->getPositionName(),
1515                        'positionColor' => $shift->getPositionColor(),
1516                        'hours' => round($hours, 2)
1517                    ];
1518
1519                    $week['totalHours'] += $hours;
1520                    break;
1521                }
1522            }
1523            unset($week);
1524        }
1525
1526        // Sort shifts within each week by date and start time
1527        foreach ($weeks as &$week) {
1528            usort($week['shifts'], function ($a, $b) {
1529                $dateCompare = strcmp($a['date'], $b['date']);
1530                if ($dateCompare !== 0) {
1531                    return $dateCompare;
1532                }
1533                return strcmp($a['startTimeIso'], $b['startTimeIso']);
1534            });
1535            $week['totalHours'] = round($week['totalHours'], 2);
1536        }
1537        unset($week);
1538
1539        return $weeks;
1540    }
1541
1542    /**
1543     * Format a week label for display
1544     *
1545     * @param DateTime $start Week start
1546     * @param DateTime $end Week end
1547     * @return string Formatted label like "Dec 16 - Dec 22, 2024"
1548     */
1549    private function formatWeekLabel(DateTime $start, DateTime $end): string
1550    {
1551        $endDisplay = clone $end;
1552        $endDisplay->modify('-1 day'); // End date is exclusive, show last day
1553
1554        if ($start->format('Y') === $endDisplay->format('Y')) {
1555            if ($start->format('M') === $endDisplay->format('M')) {
1556                return $start->format('M j') . ' - ' . $endDisplay->format('j, Y');
1557            }
1558            return $start->format('M j') . ' - ' . $endDisplay->format('M j, Y');
1559        }
1560        return $start->format('M j, Y') . ' - ' . $endDisplay->format('M j, Y');
1561    }
1562
1563    // =========================================================================
1564    // HELPER METHODS
1565    // =========================================================================
1566
1567    /**
1568     * Format a Shift model for API response
1569     *
1570     * @param Shift $shift
1571     * @return array
1572     */
1573    private function formatShiftForApi(Shift $shift): array
1574    {
1575        return [
1576            'shiftId' => $shift->getShiftId(),
1577            'employeeId' => $shift->getEmployeeId(),
1578            'employeeName' => $shift->getEmployeeName(),
1579            'shiftStart' => $shift->getShiftStart()->format('c'),
1580            'shiftEnd' => $shift->getShiftEnd()->format('c'),
1581            'positionId' => $shift->getPositionId(),
1582            'position' => $shift->getPositionName(),
1583            'positionColor' => $shift->getPositionColor(),
1584            'updatedAt' => $shift->getUpdatedAt() ? $shift->getUpdatedAt()->format('c') : null
1585        ];
1586    }
1587
1588    /**
1589     * Get the start of the week containing a given date
1590     *
1591     * @param DateTime $date
1592     * @return DateTime
1593     */
1594    private function getWeekStart(DateTime $date): DateTime
1595    {
1596        // Get store's configured week start day (default to Monday)
1597        $weekStartDay = 1; // Monday
1598
1599        try {
1600            // Stores table is in kiosk_buykiosk database
1601            $stmt = $this->getBuykioskDb()->prepare("
1602                SELECT workWeekStartDay FROM stores WHERE typeNum = :typeNum
1603            ");
1604            $stmt->bindValue(':typeNum', $this->typeNum);
1605            $stmt->execute();
1606            $result = $stmt->fetch(PDO::FETCH_ASSOC);
1607
1608            if ($result && $result['workWeekStartDay']) {
1609                $dayMap = ['sun' => 0, 'mon' => 1, 'tue' => 2, 'wed' => 3, 'thu' => 4, 'fri' => 5, 'sat' => 6];
1610                $weekStartDay = $dayMap[strtolower($result['workWeekStartDay'])] ?? 1;
1611            }
1612        } catch (Exception $e) {
1613            // Use default (Monday) on error
1614        }
1615
1616        $weekStart = clone $date;
1617        $currentDayOfWeek = (int)$weekStart->format('w'); // 0 = Sunday
1618
1619        // Calculate days to subtract to get to week start
1620        $daysToSubtract = ($currentDayOfWeek - $weekStartDay + 7) % 7;
1621        $weekStart->modify("-{$daysToSubtract} days");
1622        $weekStart->setTime(0, 0, 0);
1623
1624        return $weekStart;
1625    }
1626}