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