Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 1874
0.00% covered (danger)
0.00%
0 / 71
CRAP
0.00% covered (danger)
0.00%
0 / 1
MobileApiController
0.00% covered (danger)
0.00%
0 / 1874
0.00% covered (danger)
0.00%
0 / 71
154842
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 verifyApiKey
0.00% covered (danger)
0.00%
0 / 98
0.00% covered (danger)
0.00%
0 / 1
156
 getCurrentQueue
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
20
 getCompletedBuys
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 getStorePage
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
72
 getBuyerStats
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
90
 getTodayPerformance
0.00% covered (danger)
0.00%
0 / 74
0.00% covered (danger)
0.00%
0 / 1
12
 getNotes
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
72
 createNote
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 addNoteReaction
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 removeNoteReaction
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 addNoteComment
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 createNoteWithAuthorName
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
182
 updateNote
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
272
 deleteNote
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 getNoteComments
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getTasks
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 getTaskGroups
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 createTask
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 updateTask
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
306
 deleteTask
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 createTaskGroup
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 updateTaskGroup
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 deleteTaskGroup
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
56
 getTodayTaskLists
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 getActiveTaskList
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
12
 updateTaskStatus
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
110
 addTaskComment
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
56
 getTaskComments
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
110
 getCompletedTasks
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
110
 getStoreStats
0.00% covered (danger)
0.00%
0 / 85
0.00% covered (danger)
0.00%
0 / 1
42
 getStoreStatsFinancials
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
30
 getStoreStatsTodayQueue
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
6
 getStoreStatsAggregate
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
12
 calculateVariance
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 getWeekOverWeek
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
72
 formatLaborEmployees
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 getGoalStatus
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 getDashboard
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 1
240
 handleRequest
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
132
 getBackstockEventsDashboard
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockEvents
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 getBackstockEventDetails
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 createBackstockEvent
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 updateBackstockEvent
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 deleteBackstockEvent
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 updateBackstockEventProgress
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockEventsTimeline
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockAlerts
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 acknowledgeBackstockAlert
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 getBackstockBinsToPull
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockBinsToStore
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockBinCountsByCategories
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
20
 getBackstockEventTemplates
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 createBackstockEventFromTemplate
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 getBackstockNotes
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 createBackstockNote
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 updateBackstockNote
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 deleteBackstockNote
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 reactToBackstockNote
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 toggleBackstockNotePin
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockNoteComments
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 addBackstockNoteComment
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 deleteBackstockNoteComment
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 getBackstockReportsSummary
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockStaleInventory
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockActivitySummary
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 getBackstockBinDetails
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 updateBackstockBinNotes
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 recordBackstockBinAudit
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 getBackstockCategories
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace BuyerKiosk\MobileApi\Controllers;
4
5use \DateTime;
6use \DateTimeZone;
7use \PDO;
8use \PDOException;
9use \KLogger;
10use \BuyQueue;
11use \CompletedBuys;
12use \PrintLog;
13use \EstimatedWaitTime;
14use \Store;
15use \BuyerKiosk\Workbook\KPIService;
16use \BuyerKiosk\Workbook\NoteManager;
17use \BuyerKiosk\Workbook\Note;
18use \BuyerKiosk\Workbook\NoteReaction;
19use \BuyerKiosk\Workbook\NoteComment;
20use \BuyerKiosk\UserEmployee\UserEmployeeLinkManager;
21
22/**
23 * MobileApiController
24 *
25 * Handles mobile API requests for the BuyerKiosk mobile application.
26 * Provides endpoints for queue data, completed buys, store statistics, and dashboard data.
27 *
28 * @package BuyerKiosk\MobileApi
29 */
30class MobileApiController {
31
32    /**
33     * @var \KLogger Logger instance
34     */
35    private $log;
36
37
38    /**
39     * Constructor
40     */
41    public function __construct() {
42        $this->log = new KLogger($_ENV['LOG_DIR'] . "mobile.log", KLogger::DEBUG);
43    }
44
45    /**
46     * Verify API key and return user details with employee links
47     *
48     * Validates the API key, retrieves the associated user's profile information,
49     * and checks for employee record links in each store associated with the API key.
50     *
51     * @param string $apiKey The API key to verify
52     * @return array User details, stores, and employee links
53     */
54    public function verifyApiKey($apiKey) {
55        $this->log->LogDebug("Verify API Key Action");
56
57        // Validate and sanitize API key
58        $apiKey = htmlspecialchars(strip_tags($apiKey));
59
60        // API keys should be at least 8 characters
61        if (strlen($apiKey) < 8) {
62            $this->log->LogWarn("Invalid API Key length: " . $apiKey);
63            return [
64                'success' => false,
65                'error' => 'Invalid API key format'
66            ];
67        }
68
69        // Get database connections
70        // mobileAPI table is in kiosk_buykiosk (DB_NAME)
71        $apiDb = dbConnectByName($_ENV['DB_NAME']);
72        if (!$apiDb) {
73            $this->log->LogError("Failed to connect to API database");
74            return [
75                'success' => false,
76                'error' => 'Database connection failed'
77            ];
78        }
79
80        // uf_user and user_employee_links are in kiosk_users
81        $usersDb = dbConnectByName('kiosk_users');
82        if (!$usersDb) {
83            $this->log->LogError("Failed to connect to users database");
84            return [
85                'success' => false,
86                'error' => 'Database connection failed'
87            ];
88        }
89
90        // Get all stores and user_id for this API key
91        $stmt = $apiDb->prepare("SELECT `key`, store, user_id FROM mobileAPI WHERE `key` = :apiKey");
92        $stmt->bindValue(':apiKey', $apiKey, PDO::PARAM_STR);
93        $stmt->execute();
94        $apiKeyRows = $stmt->fetchAll(PDO::FETCH_ASSOC);
95
96        if (empty($apiKeyRows)) {
97            $this->log->LogWarn("API Key not found: " . $apiKey);
98            return [
99                'success' => false,
100                'error' => 'Invalid API key'
101            ];
102        }
103
104        // Get user_id (same for all rows with this key)
105        $userId = (int) $apiKeyRows[0]['user_id'];
106
107        // Collect all stores for this API key
108        $storeTypeNums = array_map(function($row) {
109            return $row['store'];
110        }, $apiKeyRows);
111
112        // Get user details from uf_user (in kiosk_users database)
113        $userStmt = $usersDb->prepare(
114            "SELECT id, user_name, display_name, email, title, active, enabled, primary_group_id, locale
115             FROM uf_user WHERE id = :userId"
116        );
117        $userStmt->bindValue(':userId', $userId, PDO::PARAM_INT);
118        $userStmt->execute();
119        $user = $userStmt->fetch(PDO::FETCH_ASSOC);
120
121        if (!$user) {
122            $this->log->LogError("User not found for API key: " . $apiKey);
123            return [
124                'success' => false,
125                'error' => 'User account not found'
126            ];
127        }
128
129        // Check if user account is active and enabled
130        if (!$user['active'] || !$user['enabled']) {
131            return [
132                'success' => false,
133                'error' => 'User account is disabled'
134            ];
135        }
136
137        // Initialize UserEmployeeLinkManager (uses kiosk_users for user_employee_links)
138        $linkManager = new UserEmployeeLinkManager($usersDb);
139
140        // Build stores array with employee link info
141        $stores = [];
142        foreach ($storeTypeNums as $typeNum) {
143            $store = getStoreFromID($typeNum);
144
145            if (!$store || (int)$store->getActive() !== 1) {
146                continue;
147            }
148
149            $storeData = [
150                'typeNum' => $typeNum,
151                'storeType' => (int) $store->getStoreType(),
152                'storeCity' => $store->getCity(),
153                'storeName' => $store->getCompanyName(),
154                'employee' => null
155            ];
156
157            // Check for employee link in this store
158            $linkedEmployee = $linkManager->getLinkedEmployee($userId, $typeNum);
159
160            if ($linkedEmployee) {
161                $storeData['employee'] = [
162                    'employeeId' => (int) $linkedEmployee['employeeID'],
163                    'firstName' => $linkedEmployee['employeeFirstName'],
164                    'lastName' => $linkedEmployee['employeeLastName'],
165                    'fullName' => trim($linkedEmployee['employeeFirstName'] . ' ' . $linkedEmployee['employeeLastName']),
166                    'role' => (int) $linkedEmployee['role'],
167                    'active' => (bool) $linkedEmployee['active'],
168                    'linkType' => $linkedEmployee['linkType'],
169                    'linkedAt' => $linkedEmployee['linkedAt']
170                ];
171            }
172
173            $stores[] = $storeData;
174        }
175
176        // Parse display name into first/last name
177        $nameParts = explode(' ', $user['display_name'], 2);
178        $firstName = $nameParts[0] ?? '';
179        $lastName = $nameParts[1] ?? '';
180
181        return [
182            'success' => true,
183            'user' => [
184                'id' => (int) $user['id'],
185                'username' => $user['user_name'],
186                'email' => $user['email'],
187                'displayName' => $user['display_name'],
188                'firstName' => $firstName,
189                'lastName' => $lastName,
190                'title' => $user['title'],
191                'locale' => $user['locale']
192            ],
193            'stores' => $stores,
194            'storeCount' => count($stores)
195        ];
196    }
197
198
199    /**
200     * Get current queue items for a store
201     *
202     * Returns array of queue items with wait times, containers, and customer/buyer information
203     *
204     * @param string $storeId The store ID (typeNum)
205     * @return array Array of queue items
206     */
207    public function getCurrentQueue($storeId) {
208        $this->log->LogDebug("Current Queue Action for store: " . $storeId);
209
210        $store = getStoreFromID($storeId);
211
212        if (!$store) {
213            $this->log->LogError("Store not found: " . $storeId);
214            return [];
215        }
216
217        $dbName = $store->getDbName();
218        $db = dbConnectByName($dbName);
219
220        if (!$db) {
221            $this->log->LogError("Database connection failed for store: " . $storeId);
222            return [];
223        }
224
225        $buyQueue = new BuyQueue();
226        $buyQueue->createStore($store->getTypeNum());
227        $queueItemArray = $buyQueue->getAllCurrentQueueItems($db);
228        $buysArray = [];
229
230        foreach ($queueItemArray as $buy) {
231            $timeEntered = new DateTime($buy->timeEntered, new DateTimeZone('UTC'));
232            $timeEntered->setTimezone(new DateTimeZone($store->getTimeZone()));
233            $timeNow = new DateTime();
234            $timeNow->setTimezone(new DateTimeZone($store->getTimeZone()));
235
236            $timeDiff = $timeNow->diff($timeEntered);
237            $waitTime = getSecondsFromADifference($timeDiff);
238
239            $buysArray[] = [
240                'waitTime' => secondsToMinHour($waitTime),
241                'numContainers' => (int)$buy->numContainers,
242                'processedContainers' => (int)$buy->processedContainers,
243                'inStore' => (int)$buy->inStore,
244                'customerName' => $buy->firstName . " " . substr($buy->lastName, 0, 1),
245                'buyerName' => $buy->buyerFirstName . " " . substr($buy->buyerLastName ?? '', 0, 1)
246            ];
247        }
248
249        return $buysArray;
250    }
251
252    /**
253     * Get completed buys for the day
254     *
255     * Returns array of completed buys with process times and buyer information
256     *
257     * @param string $storeId The store ID (typeNum)
258     * @return array Array of completed buys
259     */
260    public function getCompletedBuys($storeId) {
261        $this->log->LogDebug("Completed Buys Action for store: " . $storeId);
262
263        $store = getStoreFromID($storeId);
264
265        if (!$store) {
266            $this->log->LogError("Store not found: " . $storeId);
267            return [];
268        }
269
270        $completedBuys = new CompletedBuys();
271        $completedBuys->gatherCompletedBuys($store);
272
273        $buysArray = [];
274        foreach ($completedBuys->completedBuys as $buy) {
275            $timeStarted = new DateTime($buy['timestampStarted']);
276            $timeCompleted = new DateTime($buy['timestampCompleted']);
277
278            $timeDiff = $timeCompleted->diff($timeStarted);
279            $processTime = getSecondsFromADifference($timeDiff);
280
281            $customerName = explode(" ", $buy['customerFullName']);
282            $customerFullName = $customerName[0] . " " . substr($customerName[1] ?? '', 0, 1);
283
284            $buysArray[] = [
285                'customerName' => $customerFullName,
286                'buyerName' => $buy['buyerFullName'],
287                'numContainers' => (int)$buy['numContainers'],
288                'dailyNum' => (int)$buy['buyNumber'],
289                'processTime' => secondsToMinHour($processTime),
290                'timeCompleted' => $buy['timeCompleted']
291            ];
292        }
293
294        return $buysArray;
295    }
296
297    /**
298     * Get store statistics page data
299     *
300     * Returns comprehensive store statistics including queue counts, buyer counts, and performance metrics
301     *
302     * @param string $storeId The store ID (typeNum)
303     * @return array Store statistics array
304     */
305    public function getStorePage($storeId) {
306        $this->log->LogDebug("Store Page Action for store: " . $storeId);
307
308        $store = getStoreFromID($storeId);
309
310        if (!$store) {
311            $this->log->LogError("Store not found: " . $storeId);
312            return [];
313        }
314
315        $stats = new PrintLog();
316        $stats->gatherPrintLogArray($store);
317        $storeStats = $stats->storeStats;
318
319        $db = dbConnectByName($store->getDbName());
320        if (!$db) {
321            $this->log->LogError("Database connection failed for store: " . $storeId);
322            return [];
323        }
324
325        // Get current queue information using prepared statement
326        $queueQuery = $db->query("SELECT * FROM buyQueue, customers
327                                   WHERE buyQueue.isProcessed = 0
328                                   AND customers.customerID = buyQueue.customerID
329                                   ORDER BY timeStarted DESC, timeEntered ASC");
330
331        $buyersArray = [];
332        $sortersArray = [];
333        $numQueued = 0;
334
335        while ($buy = $queueQuery->fetch(PDO::FETCH_ASSOC)) {
336            if (isset($buy['buyerID']) && !in_array($buy['buyerID'], $buyersArray)) {
337                $buyersArray[] = $buy['buyerID'];
338            }
339            if ($buy['sorterID'] !== null && !in_array($buy['sorterID'], $sortersArray)) {
340                $sortersArray[] = $buy['sorterID'];
341            }
342            $numQueued++;
343        }
344
345        // Get total buys for the day using prepared statement
346        $date = getCurrentDateRange();
347        $totalQueue = $db->prepare("SELECT COUNT(buyID) as count FROM buyQueue
348                                     WHERE timeEntered BETWEEN :dateStart AND :dateEnd");
349        $totalQueue->bindValue(':dateStart', $date['dateStart']->format("Y-m-d H:i:s"));
350        $totalQueue->bindValue(':dateEnd', $date['dateEnd']->format("Y-m-d H:i:s"));
351        $totalQueue->execute();
352        $buy = $totalQueue->fetch(PDO::FETCH_ASSOC);
353        $totalBuys = $buy['count'];
354
355        return [
356            'totalBuys' => (int)$totalBuys,
357            'startDelay' => $storeStats['storeStartDelay'],
358            'processTimeAvg' => $storeStats['storeProcessAvg'],
359            'processPerContainer' => $storeStats['storePTC'],
360            'inQueue' => $numQueued,
361            'numCompleted' => $totalBuys - $numQueued,
362            'numBuyers' => count($buyersArray),
363            'numSorters' => count($sortersArray)
364        ];
365    }
366
367    /**
368     * Get buyer performance statistics
369     *
370     * Returns detailed statistics for each active buyer including process times and buy counts.
371     * Uses unified users table (kiosk_users.users) for employee names.
372     *
373     * @param string $storeId The store ID (typeNum)
374     * @return array Array of buyer statistics
375     */
376    public function getBuyerStats($storeId) {
377        $this->log->LogDebug("Buyer Stats Action for store: " . $storeId);
378
379        $store = getStoreFromID($storeId);
380
381        if (!$store) {
382            $this->log->LogError("Store not found: " . $storeId);
383            return [];
384        }
385
386        $buyersArray = getBuyersArray($store);
387        $buyerStatsArray = [];
388
389        // Get central DB connection for employee names
390        $centralDb = \dbConnectByName('kiosk_users');
391
392        foreach ($buyersArray as $buyer) {
393            $db = dbConnectByName($store->getDbName());
394            if (!$db) {
395                continue;
396            }
397
398            $date = getCurrentDateRange();
399
400            // Query without employee join (employees now in central database)
401            $query = $db->prepare("SELECT buyID, timeStarted, timeCompleted, buyerID, numContainers
402                                   FROM buyQueue
403                                   WHERE buyQueue.timeEntered BETWEEN :dateStart AND :dateEnd
404                                   AND buyQueue.buyerID = :buyerID
405                                   AND isProcessed = 1");
406
407            $query->bindValue(':dateStart', $date['dateStart']->format("Y-m-d H:i:s"));
408            $query->bindValue(':dateEnd', $date['dateEnd']->format("Y-m-d H:i:s"));
409            $query->bindValue(':buyerID', $buyer['employeeID'], PDO::PARAM_INT);
410            $query->execute();
411
412            $totalBins = 0;
413            $totalBuys = 0;
414            $processSeconds = 0;
415
416            while ($row = $query->fetch(PDO::FETCH_ASSOC)) {
417                if ($row['timeCompleted'] !== "0000-00-00 00:00:00") {
418                    $timeStarted = new DateTime($row['timeStarted']);
419                    $timeCompleted = new DateTime($row['timeCompleted']);
420
421                    $timeDiff = $timeCompleted->diff($timeStarted);
422                    $diffSeconds = getSecondsFromADifference($timeDiff);
423
424                    // Only count buys that took more than 30 seconds
425                    if ($diffSeconds > 30) {
426                        $totalBuys++;
427                        $totalBins += $row['numContainers'];
428                        $processSeconds += $diffSeconds;
429                    }
430                }
431            }
432
433            if ($totalBuys > 0) {
434                // Get buyer name from unified users table
435                $buyerFirstName = '';
436                $buyerLastName = '';
437                $buyerStmt = $centralDb->prepare("SELECT firstName, lastName FROM users WHERE id = :userId");
438                $buyerStmt->execute([':userId' => $buyer['employeeID']]);
439                $buyerData = $buyerStmt->fetch(PDO::FETCH_ASSOC);
440                if ($buyerData) {
441                    $buyerFirstName = $buyerData['firstName'];
442                    $buyerLastName = $buyerData['lastName'];
443                }
444
445                $buyerStatsArray[] = [
446                    'buyerName' => $buyerFirstName . ". " . substr($buyerLastName, 0, 1),
447                    'avgProcessTime' => secondsToReadable(floor($processSeconds / $totalBuys)),
448                    'avgPTC' => secondsToReadable(floor($processSeconds / $totalBins)),
449                    'numBuys' => (int)$totalBuys,
450                    'numBins' => (int)$totalBins
451                ];
452            }
453        }
454
455        return $buyerStatsArray;
456    }
457
458    /**
459     * Get today's performance KPIs for a store
460     *
461     * Returns detailed KPI data including sales, labor, backstock, and buys metrics
462     * This matches the "Today's Performance" expanded view in the web workbook
463     *
464     * @param string $storeId The store ID (typeNum)
465     * @return array KPI data array
466     */
467    public function getTodayPerformance($storeId) {
468        $this->log->LogDebug("Today's Performance Action for store: " . $storeId);
469
470        $store = getStoreFromID($storeId);
471
472        if (!$store) {
473            $this->log->LogError("Store not found: " . $storeId);
474            return ['error' => 'Store not found'];
475        }
476
477        try {
478            $kpiService = new KPIService($store);
479            $detailedKPIs = $kpiService->getDetailedKPIs();
480
481            // Transform to mobile-friendly format
482            return [
483                'success' => true,
484                'date' => $detailedKPIs['date'],
485                'storeId' => $storeId,
486
487                // Main KPIs (top section)
488                'sales' => [
489                    'current' => $detailedKPIs['sales']['current'],
490                    'formatted' => $detailedKPIs['sales']['formatted'],
491                    'goal' => $detailedKPIs['sales']['goal'],
492                    'goalFormatted' => $detailedKPIs['sales']['goalFormatted'],
493                    'variance' => $detailedKPIs['sales']['variance'],
494                    'varianceFormatted' => $detailedKPIs['sales']['varianceFormatted'],
495                    'comp' => $detailedKPIs['sales']['comp'],
496                    'compFormatted' => $detailedKPIs['sales']['compFormatted'],
497                    'status' => $detailedKPIs['sales']['status']
498                ],
499
500                'avgTrans' => [
501                    'current' => $detailedKPIs['avgTrans']['current'],
502                    'formatted' => $detailedKPIs['avgTrans']['formatted'],
503                    'goal' => $detailedKPIs['avgTrans']['goal'],
504                    'goalFormatted' => $detailedKPIs['avgTrans']['goalFormatted'],
505                    'comp' => $detailedKPIs['avgTrans']['comp'],
506                    'compFormatted' => $detailedKPIs['avgTrans']['compFormatted'],
507                    'status' => $detailedKPIs['avgTrans']['status']
508                ],
509
510                'tradePercent' => [
511                    'current' => $detailedKPIs['tradePercent']['current'],
512                    'formatted' => $detailedKPIs['tradePercent']['formatted'],
513                    'goal' => $detailedKPIs['tradePercent']['goal'],
514                    'goalFormatted' => $detailedKPIs['tradePercent']['goalFormatted'],
515                    'comp' => $detailedKPIs['tradePercent']['comp'],
516                    'compFormatted' => $detailedKPIs['tradePercent']['compFormatted'],
517                    'status' => $detailedKPIs['tradePercent']['status']
518                ],
519
520                // Sales & Labor section
521                'salesLabor' => [
522                    'transactions' => $detailedKPIs['salesLabor']['transactions'],
523                    'laborHours' => $detailedKPIs['salesLabor']['laborHours'],
524                    'laborHoursFormatted' => $detailedKPIs['salesLabor']['laborHoursFormatted'],
525                    'salesPerLaborHour' => $detailedKPIs['salesLabor']['salesPerLaborHour'],
526                    'salesPerLaborHourFormatted' => $detailedKPIs['salesLabor']['salesPerLaborHourFormatted'],
527                    'totalWages' => $detailedKPIs['salesLabor']['totalWages'],
528                    'totalWagesFormatted' => $detailedKPIs['salesLabor']['totalWagesFormatted']
529                ],
530
531                // Backstock section
532                'backstock' => [
533                    'pulled' => $detailedKPIs['backstock']['pulled'],
534                    'added' => $detailedKPIs['backstock']['added'],
535                    'recentActivity' => $detailedKPIs['backstock']['recentActivity']
536                ],
537
538                // Buys section
539                'buys' => [
540                    'count' => $detailedKPIs['buys']['count'],
541                    'countFormatted' => $detailedKPIs['buys']['countFormatted'],
542                    'compBuys' => $detailedKPIs['buys']['compBuys'],
543                    'compBuysFormatted' => $detailedKPIs['buys']['compBuysFormatted'],
544                    'tradeTotal' => $detailedKPIs['buys']['tradeTotal'],
545                    'tradeTotalFormatted' => $detailedKPIs['buys']['tradeTotalFormatted'],
546                    'percentTraded' => $detailedKPIs['buys']['percentTraded'],
547                    'percentTradedFormatted' => $detailedKPIs['buys']['percentTradedFormatted'],
548                    'compTradePercent' => $detailedKPIs['buys']['compTradePercent'],
549                    'compTradePercentFormatted' => $detailedKPIs['buys']['compTradePercentFormatted']
550                ]
551            ];
552
553        } catch (\Exception $e) {
554            $this->log->LogError("Error fetching KPIs for store $storeId" . $e->getMessage());
555            return [
556                'success' => false,
557                'error' => 'Failed to fetch KPI data',
558                'message' => $e->getMessage()
559            ];
560        }
561    }
562
563    /**
564     * Get notes/announcements for a store
565     *
566     * Returns paginated notes with author info, reactions, and comments
567     *
568     * @param string $storeId The store ID (typeNum)
569     * @param array $params Query parameters (limit, offset, includeManagerOnly, employeeId)
570     * @return array Notes data array
571     */
572    public function getNotes($storeId, $params = []) {
573        $this->log->LogDebug("Get Notes Action for store: " . $storeId);
574
575        $store = getStoreFromID($storeId);
576
577        if (!$store) {
578            $this->log->LogError("Store not found: " . $storeId);
579            return ['success' => false, 'error' => 'Store not found'];
580        }
581
582        try {
583            $limit = isset($params['limit']) ? (int)$params['limit'] : 10;
584            $limit = max(1, min($limit, 50)); // Clamp between 1 and 50
585            $offset = isset($params['offset']) ? (int)$params['offset'] : 0;
586            $offset = max(0, $offset);
587            $includeManagerOnly = isset($params['includeManagerOnly']) && $params['includeManagerOnly'] === 'true';
588            $employeeId = isset($params['employeeId']) ? (int)$params['employeeId'] : null;
589
590            $manager = new NoteManager($store);
591            $result = $manager->getNotes($limit, $offset, $includeManagerOnly, $employeeId);
592
593            // Get reaction details (counts + names) for all notes in batch
594            $noteIds = array_map(function($note) {
595                return $note->id;
596            }, $result['notes']);
597            $reactionDetailsByNote = $manager->getReactionDetailsBatch($noteIds);
598
599            $noteArray = array_map(function($note) use ($reactionDetailsByNote) {
600                $arr = $note->toArray();
601                // authorName is already computed in Note::toArray() - use stored authorName
602                // or computed from employee firstName/lastName, with fallback to 'Unknown'
603                if (empty($arr['authorName'])) {
604                    $arr['authorName'] = 'Unknown';
605                }
606                // Add reaction details (counts and names for tooltips)
607                $details = $reactionDetailsByNote[$note->id] ?? [
608                    'like' => ['count' => 0, 'names' => []],
609                    'heart' => ['count' => 0, 'names' => []]
610                ];
611                $arr['reactionCounts'] = [
612                    'like' => $details['like']['count'],
613                    'heart' => $details['heart']['count']
614                ];
615                $arr['reactionNames'] = [
616                    'like' => $details['like']['names'],
617                    'heart' => $details['heart']['names']
618                ];
619                return $arr;
620            }, $result['notes']);
621
622            return [
623                'success' => true,
624                'data' => $noteArray,
625                'hasMore' => $result['hasMore'],
626                'offset' => $offset,
627                'limit' => $limit
628            ];
629
630        } catch (\Exception $e) {
631            $this->log->LogError("Error fetching notes for store $storeId" . $e->getMessage());
632            return [
633                'success' => false,
634                'error' => 'Failed to fetch notes',
635                'message' => $e->getMessage()
636            ];
637        }
638    }
639
640    /**
641     * Create a new note/announcement
642     *
643     * @param string $storeId The store ID (typeNum)
644     * @param array $data Note data
645     * @return array Result with noteId or error
646     */
647    public function createNote($storeId, $data) {
648        $this->log->LogDebug("Create Note Action for store: " . $storeId);
649
650        $store = getStoreFromID($storeId);
651
652        if (!$store) {
653            $this->log->LogError("Store not found: " . $storeId);
654            return ['success' => false, 'error' => 'Store not found'];
655        }
656
657        // Validate required fields
658        if (empty($data['authorEmployeeId']) || empty($data['content']) || empty($data['startDate'])) {
659            return ['success' => false, 'error' => 'authorEmployeeId, content, and startDate are required'];
660        }
661
662        try {
663            $manager = new NoteManager($store);
664            $noteId = $manager->create($data);
665
666            if ($noteId) {
667                return [
668                    'success' => true,
669                    'noteId' => $noteId
670                ];
671            } else {
672                return ['success' => false, 'error' => 'Failed to save note'];
673            }
674
675        } catch (\Exception $e) {
676            $this->log->LogError("Error creating note for store $storeId" . $e->getMessage());
677            return [
678                'success' => false,
679                'error' => 'Failed to create note',
680                'message' => $e->getMessage()
681            ];
682        }
683    }
684
685    /**
686     * Add a reaction to a note
687     *
688     * @param string $storeId The store ID (typeNum)
689     * @param int $noteId The note ID
690     * @param array $data Reaction data (employeeId, reactionType)
691     * @return array Result with reaction count or error
692     */
693    public function addNoteReaction($storeId, $noteId, $data) {
694        $this->log->LogDebug("Add Note Reaction Action for store: " . $storeId . ", note: " . $noteId);
695
696        $store = getStoreFromID($storeId);
697
698        if (!$store) {
699            return ['success' => false, 'error' => 'Store not found'];
700        }
701
702        try {
703            $db = dbConnectByName($store->getDbName());
704
705            $employeeId = isset($data['employeeId']) ? (int)$data['employeeId'] : 0;
706            $reactionType = $data['reactionType'] ?? $data['reaction'] ?? 'like';
707
708            NoteReaction::add($db, (int)$noteId, $employeeId, $reactionType);
709            $count = NoteReaction::getCount($db, (int)$noteId);
710
711            return [
712                'success' => true,
713                'reactionCount' => $count
714            ];
715
716        } catch (\Exception $e) {
717            $this->log->LogError("Error adding reaction: " . $e->getMessage());
718            return [
719                'success' => false,
720                'error' => 'Failed to add reaction'
721            ];
722        }
723    }
724
725    /**
726     * Remove a reaction from a note
727     *
728     * @param string $storeId The store ID (typeNum)
729     * @param int $noteId The note ID
730     * @param array $data Reaction data (employeeId)
731     * @return array Result with reaction count or error
732     */
733    public function removeNoteReaction($storeId, $noteId, $data) {
734        $this->log->LogDebug("Remove Note Reaction Action for store: " . $storeId . ", note: " . $noteId);
735
736        $store = getStoreFromID($storeId);
737
738        if (!$store) {
739            return ['success' => false, 'error' => 'Store not found'];
740        }
741
742        try {
743            $db = dbConnectByName($store->getDbName());
744
745            $employeeId = isset($data['employeeId']) ? (int)$data['employeeId'] : 0;
746
747            NoteReaction::remove($db, (int)$noteId, $employeeId);
748            $count = NoteReaction::getCount($db, (int)$noteId);
749
750            return [
751                'success' => true,
752                'reactionCount' => $count
753            ];
754
755        } catch (\Exception $e) {
756            $this->log->LogError("Error removing reaction: " . $e->getMessage());
757            return [
758                'success' => false,
759                'error' => 'Failed to remove reaction'
760            ];
761        }
762    }
763
764    /**
765     * Add a comment to a note
766     *
767     * @param string $storeId The store ID (typeNum)
768     * @param int $noteId The note ID
769     * @param array $data Comment data (employeeId, comment)
770     * @return array Result with commentId or error
771     */
772    public function addNoteComment($storeId, $noteId, $data) {
773        $this->log->LogDebug("Add Note Comment Action for store: " . $storeId . ", note: " . $noteId);
774
775        $store = getStoreFromID($storeId);
776
777        if (!$store) {
778            return ['success' => false, 'error' => 'Store not found'];
779        }
780
781        if (empty($data['comment'])) {
782            return ['success' => false, 'error' => 'comment is required'];
783        }
784
785        if (strlen($data['comment']) > 2000) {
786            return ['success' => false, 'error' => 'Comment exceeds maximum length of 2000 characters'];
787        }
788
789        try {
790            $db = dbConnectByName($store->getDbName());
791
792            $comment = new NoteComment();
793            $comment->noteId = (int)$noteId;
794            $comment->employeeId = isset($data['employeeId']) ? (int)$data['employeeId'] : 0;
795            $comment->comment = trim($data['comment']);
796
797            $commentId = $comment->save($db);
798
799            return [
800                'success' => true,
801                'commentId' => $commentId
802            ];
803
804        } catch (\Exception $e) {
805            $this->log->LogError("Error adding comment: " . $e->getMessage());
806            return [
807                'success' => false,
808                'error' => 'Failed to add comment'
809            ];
810        }
811    }
812
813    /**
814     * Create a new note/announcement using author name instead of employee ID
815     *
816     * This method attempts to match the provided authorName to an employee in the store.
817     * If a match is found, the note is linked to that employee.
818     * If no match is found, the authorName is stored directly in the note.
819     *
820     * @param string $storeId The store ID (typeNum)
821     * @param array $data Note data with authorName
822     * @return array Result with noteId or error
823     */
824    public function createNoteWithAuthorName($storeId, $data) {
825        $this->log->LogDebug("Create Note With Author Name Action for store: " . $storeId);
826
827        $store = getStoreFromID($storeId);
828
829        if (!$store) {
830            $this->log->LogError("Store not found: " . $storeId);
831            return ['success' => false, 'error' => 'Store not found'];
832        }
833
834        // Validate required fields
835        if (empty($data['authorName']) || empty($data['content']) || empty($data['startDate'])) {
836            return ['success' => false, 'error' => 'authorName, content, and startDate are required'];
837        }
838
839        try {
840            $db = dbConnectByName($store->getDbName());
841            $centralDb = \dbConnectByName('kiosk_users');
842            $typeNum = $store->getTypeNum();
843            $authorName = trim($data['authorName']);
844            $employeeId = null;
845
846            // Try to match the name to an employee in unified users table
847            // First try exact match on full name
848            $stmt = $centralDb->prepare("
849                SELECT u.id, u.firstName, u.lastName
850                FROM users u
851                INNER JOIN userStoreAssignments usa ON u.id = usa.userId
852                WHERE CONCAT(u.firstName, ' ', u.lastName) = :fullName
853                AND usa.typeNum = :typeNum
854                AND usa.isActive = 1
855                AND u.enabled = 1
856                LIMIT 1
857            ");
858            $stmt->execute([':fullName' => $authorName, ':typeNum' => $typeNum]);
859            $employee = $stmt->fetch(PDO::FETCH_ASSOC);
860
861            if ($employee) {
862                $employeeId = (int) $employee['id'];
863                $this->log->LogDebug("Matched author name '$authorName' to employee ID: $employeeId");
864            } else {
865                // Try fuzzy match - first name + last initial pattern (e.g., "John S")
866                $nameParts = explode(' ', $authorName);
867                if (count($nameParts) >= 2) {
868                    $firstName = $nameParts[0];
869                    $lastInitial = substr(end($nameParts), 0, 1);
870
871                    $stmt = $centralDb->prepare("
872                        SELECT u.id, u.firstName, u.lastName
873                        FROM users u
874                        INNER JOIN userStoreAssignments usa ON u.id = usa.userId
875                        WHERE u.firstName = :firstName
876                        AND u.lastName LIKE :lastInitial
877                        AND usa.typeNum = :typeNum
878                        AND usa.isActive = 1
879                        AND u.enabled = 1
880                        LIMIT 1
881                    ");
882                    $stmt->execute([
883                        ':firstName' => $firstName,
884                        ':lastInitial' => $lastInitial . '%',
885                        ':typeNum' => $typeNum
886                    ]);
887                    $employee = $stmt->fetch(PDO::FETCH_ASSOC);
888
889                    if ($employee) {
890                        $employeeId = (int) $employee['id'];
891                        $this->log->LogDebug("Fuzzy matched author name '$authorName' to employee ID: $employeeId");
892                    }
893                }
894            }
895
896            // Create the note
897            $note = new Note();
898            $note->authorEmployeeId = $employeeId;
899            $note->authorName = $employeeId ? null : $authorName; // Only store authorName if no employee match
900            $note->title = $data['title'] ?? null;
901            $note->content = $data['content'];
902            $note->contentHtml = $data['contentHtml'] ?? null;
903            $note->startDate = $data['startDate'];
904            $note->endDate = $data['endDate'] ?? null;
905            // Handle string "false"/"true" from POST data
906            $note->isManagerOnly = isset($data['isManagerOnly']) && filter_var($data['isManagerOnly'], FILTER_VALIDATE_BOOLEAN);
907            $note->isPinned = isset($data['isPinned']) && filter_var($data['isPinned'], FILTER_VALIDATE_BOOLEAN);
908
909            $result = $note->save($db);
910
911            if ($result) {
912                return [
913                    'success' => true,
914                    'noteId' => $note->id,
915                    'matchedEmployee' => $employeeId !== null,
916                    'employeeId' => $employeeId
917                ];
918            } else {
919                return ['success' => false, 'error' => 'Failed to save note'];
920            }
921
922        } catch (\Exception $e) {
923            $this->log->LogError("Error creating note with author name for store $storeId" . $e->getMessage());
924            return [
925                'success' => false,
926                'error' => 'Failed to create note',
927                'message' => $e->getMessage()
928            ];
929        }
930    }
931
932    /**
933     * Update an existing note
934     *
935     * @param string $storeId The store ID (typeNum)
936     * @param int $noteId The note ID
937     * @param array $data Updated note data
938     * @return array Result with success or error
939     */
940    public function updateNote($storeId, $noteId, $data) {
941        $this->log->LogDebug("Update Note Action for store: " . $storeId . ", note: " . $noteId);
942
943        $store = getStoreFromID($storeId);
944
945        if (!$store) {
946            $this->log->LogError("Store not found: " . $storeId);
947            return ['success' => false, 'error' => 'Store not found'];
948        }
949
950        try {
951            $db = dbConnectByName($store->getDbName());
952
953            // Build update query dynamically based on provided fields
954            $updates = [];
955            $params = [':id' => (int)$noteId];
956
957            if (isset($data['title'])) {
958                $updates[] = 'title = :title';
959                $params[':title'] = $data['title'];
960            }
961            if (isset($data['content'])) {
962                $updates[] = 'content = :content';
963                $params[':content'] = $data['content'];
964            }
965            if (isset($data['contentHtml'])) {
966                $updates[] = 'contentHtml = :contentHtml';
967                $params[':contentHtml'] = $data['contentHtml'];
968            }
969            if (isset($data['startDate'])) {
970                $updates[] = 'startDate = :startDate';
971                $params[':startDate'] = $data['startDate'];
972            }
973            if (isset($data['endDate'])) {
974                $updates[] = 'endDate = :endDate';
975                $params[':endDate'] = $data['endDate'] ?: null;
976            }
977            if (isset($data['isManagerOnly'])) {
978                $updates[] = 'isManagerOnly = :isManagerOnly';
979                $params[':isManagerOnly'] = filter_var($data['isManagerOnly'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
980            }
981            if (isset($data['isPinned'])) {
982                $updates[] = 'isPinned = :isPinned';
983                $params[':isPinned'] = filter_var($data['isPinned'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
984            }
985
986            if (empty($updates)) {
987                return ['success' => false, 'error' => 'No fields to update'];
988            }
989
990            $sql = "UPDATE workbook_notes SET " . implode(', ', $updates) . " WHERE id = :id AND deletedAt IS NULL";
991            $stmt = $db->prepare($sql);
992            $result = $stmt->execute($params);
993
994            if ($result && $stmt->rowCount() > 0) {
995                return ['success' => true];
996            } else {
997                return ['success' => false, 'error' => 'Note not found or no changes made'];
998            }
999
1000        } catch (\Exception $e) {
1001            $this->log->LogError("Error updating note: " . $e->getMessage());
1002            return [
1003                'success' => false,
1004                'error' => 'Failed to update note'
1005            ];
1006        }
1007    }
1008
1009    /**
1010     * Soft delete a note (set deletedAt timestamp)
1011     *
1012     * @param string $storeId The store ID (typeNum)
1013     * @param int $noteId The note ID
1014     * @return array Result with success or error
1015     */
1016    public function deleteNote($storeId, $noteId) {
1017        $this->log->LogDebug("Delete Note Action for store: " . $storeId . ", note: " . $noteId);
1018
1019        $store = getStoreFromID($storeId);
1020
1021        if (!$store) {
1022            $this->log->LogError("Store not found: " . $storeId);
1023            return ['success' => false, 'error' => 'Store not found'];
1024        }
1025
1026        try {
1027            $db = dbConnectByName($store->getDbName());
1028
1029            $stmt = $db->prepare("UPDATE workbook_notes SET deletedAt = NOW() WHERE id = :id AND deletedAt IS NULL");
1030            $result = $stmt->execute([':id' => (int)$noteId]);
1031
1032            if ($result && $stmt->rowCount() > 0) {
1033                return ['success' => true];
1034            } else {
1035                return ['success' => false, 'error' => 'Note not found or already deleted'];
1036            }
1037
1038        } catch (\Exception $e) {
1039            $this->log->LogError("Error deleting note: " . $e->getMessage());
1040            return [
1041                'success' => false,
1042                'error' => 'Failed to delete note'
1043            ];
1044        }
1045    }
1046
1047    /**
1048     * Get comments for a note
1049     *
1050     * @param string $storeId The store ID (typeNum)
1051     * @param int $noteId The note ID
1052     * @return array Result with comments or error
1053     */
1054    public function getNoteComments($storeId, $noteId) {
1055        $this->log->LogDebug("Get Note Comments Action for store: " . $storeId . ", note: " . $noteId);
1056
1057        $store = getStoreFromID($storeId);
1058
1059        if (!$store) {
1060            return ['success' => false, 'error' => 'Store not found'];
1061        }
1062
1063        try {
1064            $db = dbConnectByName($store->getDbName());
1065
1066            $comments = NoteComment::getForNote($db, (int)$noteId);
1067
1068            return [
1069                'success' => true,
1070                'data' => $comments
1071            ];
1072
1073        } catch (\Exception $e) {
1074            $this->log->LogError("Error getting comments: " . $e->getMessage());
1075            return [
1076                'success' => false,
1077                'error' => 'Failed to get comments'
1078            ];
1079        }
1080    }
1081
1082    /**
1083     * Get all tasks for a store
1084     *
1085     * @param string $storeId The store ID (typeNum)
1086     * @param int|null $groupId Optional task group ID to filter by
1087     * @return array Result with tasks or error
1088     */
1089    public function getTasks($storeId, $groupId = null) {
1090        $this->log->LogDebug("Get Tasks Action for store: " . $storeId);
1091
1092        $store = getStoreFromID($storeId);
1093
1094        if (!$store) {
1095            return ['success' => false, 'error' => 'Store not found'];
1096        }
1097
1098        try {
1099            $db = dbConnectByName($store->getDbName());
1100
1101            $sql = "SELECT t.*, tg.groupName
1102                    FROM tasks t
1103                    LEFT JOIN taskGroups tg ON t.taskGroup = tg.id";
1104            $params = [];
1105
1106            if ($groupId !== null) {
1107                $sql .= " WHERE t.taskGroup = :groupId";
1108                $params[':groupId'] = (int)$groupId;
1109            }
1110
1111            $sql .= " ORDER BY t.taskGroup, t.sortOrder, t.id";
1112
1113            $stmt = $db->prepare($sql);
1114            $stmt->execute($params);
1115            $tasks = $stmt->fetchAll(\PDO::FETCH_ASSOC);
1116
1117            return [
1118                'success' => true,
1119                'data' => $tasks
1120            ];
1121
1122        } catch (\Exception $e) {
1123            $this->log->LogError("Error getting tasks: " . $e->getMessage());
1124            return [
1125                'success' => false,
1126                'error' => 'Failed to get tasks'
1127            ];
1128        }
1129    }
1130
1131    /**
1132     * Get all task groups for a store
1133     *
1134     * @param string $storeId The store ID (typeNum)
1135     * @return array Result with task groups or error
1136     */
1137    public function getTaskGroups($storeId) {
1138        $this->log->LogDebug("Get Task Groups Action for store: " . $storeId);
1139
1140        $store = getStoreFromID($storeId);
1141
1142        if (!$store) {
1143            return ['success' => false, 'error' => 'Store not found'];
1144        }
1145
1146        try {
1147            $db = dbConnectByName($store->getDbName());
1148
1149            $stmt = $db->query("SELECT * FROM taskGroups ORDER BY id");
1150            $groups = $stmt->fetchAll(\PDO::FETCH_ASSOC);
1151
1152            return [
1153                'success' => true,
1154                'data' => $groups
1155            ];
1156
1157        } catch (\Exception $e) {
1158            $this->log->LogError("Error getting task groups: " . $e->getMessage());
1159            return [
1160                'success' => false,
1161                'error' => 'Failed to get task groups'
1162            ];
1163        }
1164    }
1165
1166    /**
1167     * Create a new task
1168     *
1169     * @param string $storeId The store ID (typeNum)
1170     * @param array $data Task data
1171     * @return array Result with taskId or error
1172     */
1173    public function createTask($storeId, $data) {
1174        $this->log->LogDebug("Create Task Action for store: " . $storeId);
1175
1176        $store = getStoreFromID($storeId);
1177
1178        if (!$store) {
1179            return ['success' => false, 'error' => 'Store not found'];
1180        }
1181
1182        // Validate required fields
1183        if (empty($data['taskName']) || empty($data['taskGroup'])) {
1184            return ['success' => false, 'error' => 'taskName and taskGroup are required'];
1185        }
1186
1187        try {
1188            $task = new \Task($storeId);
1189            $task->taskName = $data['taskName'];
1190            $task->comment = $data['comment'] ?? '';
1191            $task->taskGroup = (int)$data['taskGroup'];
1192            $task->recurOn = $data['recurOn'] ?? 'SUN MON TUE WED THU FRI SAT';
1193            $task->priority = isset($data['priority']) ? (int)$data['priority'] : 2;
1194            $task->sortOrder = isset($data['sortOrder']) ? (int)$data['sortOrder'] : 0;
1195            $task->startDate = $data['startDate'] ?? null;
1196            $task->endDate = $data['endDate'] ?? null;
1197            $task->timeOfDay = $data['timeOfDay'] ?? null;
1198
1199            $taskId = $task->create();
1200
1201            if ($taskId) {
1202                return [
1203                    'success' => true,
1204                    'taskId' => $taskId
1205                ];
1206            } else {
1207                return ['success' => false, 'error' => 'Failed to create task'];
1208            }
1209
1210        } catch (\Exception $e) {
1211            $this->log->LogError("Error creating task: " . $e->getMessage());
1212            return [
1213                'success' => false,
1214                'error' => 'Failed to create task'
1215            ];
1216        }
1217    }
1218
1219    /**
1220     * Update an existing task
1221     *
1222     * @param string $storeId The store ID (typeNum)
1223     * @param int $taskId The task ID
1224     * @param array $data Updated task data
1225     * @return array Result with success or error
1226     */
1227    public function updateTask($storeId, $taskId, $data) {
1228        $this->log->LogDebug("Update Task Action for store: " . $storeId . ", task: " . $taskId);
1229
1230        $store = getStoreFromID($storeId);
1231
1232        if (!$store) {
1233            return ['success' => false, 'error' => 'Store not found'];
1234        }
1235
1236        try {
1237            $task = new \Task($storeId);
1238            if (!$task->get($taskId)) {
1239                return ['success' => false, 'error' => 'Task not found'];
1240            }
1241
1242            // Update fields if provided
1243            if (isset($data['taskName'])) {
1244                $task->taskName = $data['taskName'];
1245            }
1246            if (isset($data['comment'])) {
1247                $task->comment = $data['comment'];
1248            }
1249            if (isset($data['taskGroup'])) {
1250                $task->taskGroup = (int)$data['taskGroup'];
1251            }
1252            if (isset($data['recurOn'])) {
1253                $task->recurOn = $data['recurOn'];
1254            }
1255            if (isset($data['priority'])) {
1256                $task->priority = (int)$data['priority'];
1257            }
1258            if (isset($data['sortOrder'])) {
1259                $task->sortOrder = (int)$data['sortOrder'];
1260            }
1261            if (array_key_exists('startDate', $data)) {
1262                $task->startDate = $data['startDate'] ?: null;
1263            }
1264            if (array_key_exists('endDate', $data)) {
1265                $task->endDate = $data['endDate'] ?: null;
1266            }
1267            if (array_key_exists('timeOfDay', $data)) {
1268                $task->timeOfDay = $data['timeOfDay'] ?: null;
1269            }
1270
1271            if ($task->update()) {
1272                return ['success' => true];
1273            } else {
1274                return ['success' => false, 'error' => 'Failed to update task'];
1275            }
1276
1277        } catch (\Exception $e) {
1278            $this->log->LogError("Error updating task: " . $e->getMessage());
1279            return [
1280                'success' => false,
1281                'error' => 'Failed to update task'
1282            ];
1283        }
1284    }
1285
1286    /**
1287     * Delete a task
1288     *
1289     * @param string $storeId The store ID (typeNum)
1290     * @param int $taskId The task ID
1291     * @return array Result with success or error
1292     */
1293    public function deleteTask($storeId, $taskId) {
1294        $this->log->LogDebug("Delete Task Action for store: " . $storeId . ", task: " . $taskId);
1295
1296        $store = getStoreFromID($storeId);
1297
1298        if (!$store) {
1299            return ['success' => false, 'error' => 'Store not found'];
1300        }
1301
1302        try {
1303            $task = new \Task($storeId);
1304            if (!$task->get($taskId)) {
1305                return ['success' => false, 'error' => 'Task not found'];
1306            }
1307
1308            if ($task->delete()) {
1309                return ['success' => true];
1310            } else {
1311                return ['success' => false, 'error' => 'Failed to delete task'];
1312            }
1313
1314        } catch (\Exception $e) {
1315            $this->log->LogError("Error deleting task: " . $e->getMessage());
1316            return [
1317                'success' => false,
1318                'error' => 'Failed to delete task'
1319            ];
1320        }
1321    }
1322
1323    /**
1324     * Create a new task group
1325     *
1326     * @param string $storeId The store ID (typeNum)
1327     * @param array $data Task group data
1328     * @return array Result with groupId or error
1329     */
1330    public function createTaskGroup($storeId, $data) {
1331        $this->log->LogDebug("Create Task Group Action for store: " . $storeId);
1332
1333        $store = getStoreFromID($storeId);
1334
1335        if (!$store) {
1336            return ['success' => false, 'error' => 'Store not found'];
1337        }
1338
1339        if (empty($data['groupName'])) {
1340            return ['success' => false, 'error' => 'groupName is required'];
1341        }
1342
1343        try {
1344            $db = dbConnectByName($store->getDbName());
1345
1346            $stmt = $db->prepare("INSERT INTO taskGroups (groupName) VALUES (:groupName)");
1347            $stmt->execute([':groupName' => $data['groupName']]);
1348            $groupId = $db->lastInsertId();
1349
1350            return [
1351                'success' => true,
1352                'groupId' => (int)$groupId
1353            ];
1354
1355        } catch (\Exception $e) {
1356            $this->log->LogError("Error creating task group: " . $e->getMessage());
1357            return [
1358                'success' => false,
1359                'error' => 'Failed to create task group'
1360            ];
1361        }
1362    }
1363
1364    /**
1365     * Update a task group
1366     *
1367     * @param string $storeId The store ID (typeNum)
1368     * @param int $groupId The task group ID
1369     * @param array $data Updated group data
1370     * @return array Result with success or error
1371     */
1372    public function updateTaskGroup($storeId, $groupId, $data) {
1373        $this->log->LogDebug("Update Task Group Action for store: " . $storeId . ", group: " . $groupId);
1374
1375        $store = getStoreFromID($storeId);
1376
1377        if (!$store) {
1378            return ['success' => false, 'error' => 'Store not found'];
1379        }
1380
1381        if (empty($data['groupName'])) {
1382            return ['success' => false, 'error' => 'groupName is required'];
1383        }
1384
1385        try {
1386            $db = dbConnectByName($store->getDbName());
1387
1388            $stmt = $db->prepare("UPDATE taskGroups SET groupName = :groupName WHERE id = :id");
1389            $result = $stmt->execute([':groupName' => $data['groupName'], ':id' => (int)$groupId]);
1390
1391            if ($result && $stmt->rowCount() > 0) {
1392                return ['success' => true];
1393            } else {
1394                return ['success' => false, 'error' => 'Task group not found or no changes made'];
1395            }
1396
1397        } catch (\Exception $e) {
1398            $this->log->LogError("Error updating task group: " . $e->getMessage());
1399            return [
1400                'success' => false,
1401                'error' => 'Failed to update task group'
1402            ];
1403        }
1404    }
1405
1406    /**
1407     * Delete a task group (and optionally its tasks)
1408     *
1409     * @param string $storeId The store ID (typeNum)
1410     * @param int $groupId The task group ID
1411     * @param bool $deleteTasks Whether to delete tasks in the group
1412     * @return array Result with success or error
1413     */
1414    public function deleteTaskGroup($storeId, $groupId, $deleteTasks = false) {
1415        $this->log->LogDebug("Delete Task Group Action for store: " . $storeId . ", group: " . $groupId);
1416
1417        $store = getStoreFromID($storeId);
1418
1419        if (!$store) {
1420            return ['success' => false, 'error' => 'Store not found'];
1421        }
1422
1423        try {
1424            $db = dbConnectByName($store->getDbName());
1425
1426            // Check if group has tasks
1427            $stmt = $db->prepare("SELECT COUNT(*) as count FROM tasks WHERE taskGroup = :groupId");
1428            $stmt->execute([':groupId' => (int)$groupId]);
1429            $result = $stmt->fetch(\PDO::FETCH_ASSOC);
1430
1431            if ($result['count'] > 0 && !$deleteTasks) {
1432                return [
1433                    'success' => false,
1434                    'error' => 'Task group has ' . $result['count'] . ' task(s). Set deleteTasks=true to delete them.'
1435                ];
1436            }
1437
1438            $db->beginTransaction();
1439
1440            // Delete tasks in group if requested
1441            if ($deleteTasks) {
1442                $stmt = $db->prepare("DELETE FROM tasks WHERE taskGroup = :groupId");
1443                $stmt->execute([':groupId' => (int)$groupId]);
1444            }
1445
1446            // Delete the group
1447            $stmt = $db->prepare("DELETE FROM taskGroups WHERE id = :id");
1448            $stmt->execute([':id' => (int)$groupId]);
1449
1450            $db->commit();
1451
1452            return ['success' => true];
1453
1454        } catch (\Exception $e) {
1455            if ($db->inTransaction()) {
1456                $db->rollBack();
1457            }
1458            $this->log->LogError("Error deleting task group: " . $e->getMessage());
1459            return [
1460                'success' => false,
1461                'error' => 'Failed to delete task group'
1462            ];
1463        }
1464    }
1465
1466    // =========================================================================
1467    // Task List Completion Tracking (Workbook)
1468    // =========================================================================
1469
1470    /**
1471     * Get today's task lists with completion status
1472     *
1473     * Returns all task lists scheduled for today with tasks and their
1474     * completion status, including who completed them and any notes.
1475     *
1476     * @param string $storeId The store ID (typeNum)
1477     * @return array Result with task lists or error
1478     */
1479    public function getTodayTaskLists($storeId) {
1480        $this->log->LogDebug("Get Today Task Lists Action for store: " . $storeId);
1481
1482        $store = getStoreFromID($storeId);
1483
1484        if (!$store) {
1485            return ['success' => false, 'error' => 'Store not found'];
1486        }
1487
1488        try {
1489            $manager = new \BuyerKiosk\Workbook\TaskListManager($store);
1490            $lists = $manager->getTodayLists();
1491
1492            return [
1493                'success' => true,
1494                'data' => $lists
1495            ];
1496
1497        } catch (\Exception $e) {
1498            $this->log->LogError("Error getting today's task lists: " . $e->getMessage());
1499            return [
1500                'success' => false,
1501                'error' => 'Failed to get task lists'
1502            ];
1503        }
1504    }
1505
1506    /**
1507     * Get the currently active task list with carryover tasks
1508     *
1509     * Returns the list that should be currently active based on time,
1510     * plus any incomplete tasks from earlier lists (carryover).
1511     *
1512     * @param string $storeId The store ID (typeNum)
1513     * @return array Result with active list and carryover tasks
1514     */
1515    public function getActiveTaskList($storeId) {
1516        $this->log->LogDebug("Get Active Task List Action for store: " . $storeId);
1517
1518        $store = getStoreFromID($storeId);
1519
1520        if (!$store) {
1521            return ['success' => false, 'error' => 'Store not found'];
1522        }
1523
1524        try {
1525            $manager = new \BuyerKiosk\Workbook\TaskListManager($store);
1526            $result = $manager->getActiveListWithCarryover();
1527
1528            return [
1529                'success' => true,
1530                'activeList' => $result['activeList'],
1531                'carryoverTasks' => $result['carryoverTasks'],
1532                'nextListTime' => $result['nextListTime'],
1533                'nextListName' => $result['nextListName'] ?? null
1534            ];
1535
1536        } catch (\Exception $e) {
1537            $this->log->LogError("Error getting active task list: " . $e->getMessage());
1538            return [
1539                'success' => false,
1540                'error' => 'Failed to get active task list'
1541            ];
1542        }
1543    }
1544
1545    /**
1546     * Update task completion status
1547     *
1548     * @param string $storeId The store ID (typeNum)
1549     * @param int $taskId The task ID
1550     * @param array $data Status data (status, employeeId, notes)
1551     * @return array Result with success or error
1552     */
1553    public function updateTaskStatus($storeId, $taskId, $data) {
1554        $this->log->LogDebug("Update Task Status Action for store: " . $storeId . ", task: " . $taskId);
1555
1556        $store = getStoreFromID($storeId);
1557
1558        if (!$store) {
1559            return ['success' => false, 'error' => 'Store not found'];
1560        }
1561
1562        // Validate status
1563        $status = isset($data['status']) ? (int)$data['status'] : null;
1564        if ($status === null || !in_array($status, [0, 1, 2])) {
1565            return ['success' => false, 'error' => 'Invalid status. Must be 0 (Not Started), 1 (In Progress), or 2 (Completed)'];
1566        }
1567
1568        try {
1569            $db = dbConnectByName($store->getDbName());
1570            $timezone = new \DateTimeZone($store->timezone);
1571            $now = new \DateTime('now', $timezone);
1572            $today = $now->format('Y-m-d');
1573
1574            // Check if task exists
1575            $stmt = $db->prepare("SELECT id FROM tasks WHERE id = :taskId");
1576            $stmt->execute([':taskId' => (int)$taskId]);
1577            if (!$stmt->fetch()) {
1578                return ['success' => false, 'error' => 'Task not found'];
1579            }
1580
1581            // Check for existing completion record
1582            $stmt = $db->prepare("SELECT id FROM workbook_task_completions WHERE taskId = :taskId AND date = :date");
1583            $stmt->execute([':taskId' => (int)$taskId, ':date' => $today]);
1584            $existing = $stmt->fetch(PDO::FETCH_ASSOC);
1585
1586            $employeeId = isset($data['employeeId']) ? (int)$data['employeeId'] : null;
1587            $notes = $data['notes'] ?? null;
1588            $completedAt = ($status == 2) ? $now->format('Y-m-d H:i:s') : null;
1589
1590            if ($existing) {
1591                // Update existing record
1592                $stmt = $db->prepare("
1593                    UPDATE workbook_task_completions
1594                    SET status = :status,
1595                        completedBy = :completedBy,
1596                        completedAt = :completedAt,
1597                        notes = :notes
1598                    WHERE id = :id
1599                ");
1600                $stmt->execute([
1601                    ':id' => $existing['id'],
1602                    ':status' => $status,
1603                    ':completedBy' => $employeeId,
1604                    ':completedAt' => $completedAt,
1605                    ':notes' => $notes
1606                ]);
1607                $completionId = $existing['id'];
1608            } else {
1609                // Insert new record
1610                $stmt = $db->prepare("
1611                    INSERT INTO workbook_task_completions (taskId, date, status, completedBy, completedAt, notes)
1612                    VALUES (:taskId, :date, :status, :completedBy, :completedAt, :notes)
1613                ");
1614                $stmt->execute([
1615                    ':taskId' => (int)$taskId,
1616                    ':date' => $today,
1617                    ':status' => $status,
1618                    ':completedBy' => $employeeId,
1619                    ':completedAt' => $completedAt,
1620                    ':notes' => $notes
1621                ]);
1622                $completionId = $db->lastInsertId();
1623            }
1624
1625            return [
1626                'success' => true,
1627                'completionId' => (int)$completionId,
1628                'status' => $status,
1629                'date' => $today
1630            ];
1631
1632        } catch (\Exception $e) {
1633            $this->log->LogError("Error updating task status: " . $e->getMessage());
1634            return [
1635                'success' => false,
1636                'error' => 'Failed to update task status'
1637            ];
1638        }
1639    }
1640
1641    /**
1642     * Add a comment to a task for today
1643     *
1644     * @param string $storeId The store ID (typeNum)
1645     * @param int $taskId The task ID
1646     * @param array $data Comment data (comment, employeeId)
1647     * @return array Result with commentId or error
1648     */
1649    public function addTaskComment($storeId, $taskId, $data) {
1650        $this->log->LogDebug("Add Task Comment Action for store: " . $storeId . ", task: " . $taskId);
1651
1652        $store = getStoreFromID($storeId);
1653
1654        if (!$store) {
1655            return ['success' => false, 'error' => 'Store not found'];
1656        }
1657
1658        if (empty($data['comment'])) {
1659            return ['success' => false, 'error' => 'Comment is required'];
1660        }
1661
1662        if (strlen($data['comment']) > 1000) {
1663            return ['success' => false, 'error' => 'Comment exceeds maximum length of 1000 characters'];
1664        }
1665
1666        try {
1667            $db = dbConnectByName($store->getDbName());
1668            $timezone = new \DateTimeZone($store->timezone);
1669            $now = new \DateTime('now', $timezone);
1670            $today = $now->format('Y-m-d');
1671
1672            // Check if task exists
1673            $stmt = $db->prepare("SELECT id FROM tasks WHERE id = :taskId");
1674            $stmt->execute([':taskId' => (int)$taskId]);
1675            if (!$stmt->fetch()) {
1676                return ['success' => false, 'error' => 'Task not found'];
1677            }
1678
1679            $employeeId = isset($data['employeeId']) ? (int)$data['employeeId'] : null;
1680
1681            $stmt = $db->prepare("
1682                INSERT INTO workbook_task_comments (taskId, date, employeeId, comment, createdAt)
1683                VALUES (:taskId, :date, :employeeId, :comment, :createdAt)
1684            ");
1685            $stmt->execute([
1686                ':taskId' => (int)$taskId,
1687                ':date' => $today,
1688                ':employeeId' => $employeeId,
1689                ':comment' => $data['comment'],
1690                ':createdAt' => $now->format('Y-m-d H:i:s')
1691            ]);
1692
1693            return [
1694                'success' => true,
1695                'commentId' => (int)$db->lastInsertId()
1696            ];
1697
1698        } catch (\Exception $e) {
1699            $this->log->LogError("Error adding task comment: " . $e->getMessage());
1700            return [
1701                'success' => false,
1702                'error' => 'Failed to add comment'
1703            ];
1704        }
1705    }
1706
1707    /**
1708     * Get comments for a task on a specific date
1709     *
1710     * @param string $storeId The store ID (typeNum)
1711     * @param int $taskId The task ID
1712     * @param string|null $date Date (Y-m-d), defaults to today
1713     * @return array Result with comments or error
1714     */
1715    public function getTaskComments($storeId, $taskId, $date = null) {
1716        $this->log->LogDebug("Get Task Comments Action for store: " . $storeId . ", task: " . $taskId);
1717
1718        $store = getStoreFromID($storeId);
1719
1720        if (!$store) {
1721            return ['success' => false, 'error' => 'Store not found'];
1722        }
1723
1724        try {
1725            $db = dbConnectByName($store->getDbName());
1726            $timezone = new \DateTimeZone($store->timezone);
1727            $now = new \DateTime('now', $timezone);
1728            $targetDate = $date ?: $now->format('Y-m-d');
1729
1730            // Query comments without employee join (employees now in central database)
1731            $stmt = $db->prepare("
1732                SELECT c.*
1733                FROM workbook_task_comments c
1734                WHERE c.taskId = :taskId AND c.date = :date
1735                ORDER BY c.createdAt ASC
1736            ");
1737            $stmt->execute([
1738                ':taskId' => (int)$taskId,
1739                ':date' => $targetDate
1740            ]);
1741            $comments = $stmt->fetchAll(PDO::FETCH_ASSOC);
1742
1743            // Enrich with employee names from unified users table
1744            if (!empty($comments)) {
1745                $centralDb = \dbConnectByName('kiosk_users');
1746                $userIds = array_unique(array_filter(array_column($comments, 'employeeId')));
1747                $userNames = [];
1748                if (!empty($userIds)) {
1749                    $placeholders = implode(',', array_fill(0, count($userIds), '?'));
1750                    $userStmt = $centralDb->prepare("
1751                        SELECT id, firstName, lastName
1752                        FROM users
1753                        WHERE id IN ($placeholders)
1754                    ");
1755                    $userStmt->execute(array_values($userIds));
1756                    $users = $userStmt->fetchAll(PDO::FETCH_ASSOC);
1757                    foreach ($users as $user) {
1758                        $userNames[$user['id']] = [
1759                            'firstName' => $user['firstName'],
1760                            'lastName' => $user['lastName']
1761                        ];
1762                    }
1763                }
1764
1765                // Add employee names to comments
1766                foreach ($comments as &$comment) {
1767                    $userId = $comment['employeeId'];
1768                    if ($userId && isset($userNames[$userId])) {
1769                        $comment['employeeFirstName'] = $userNames[$userId]['firstName'];
1770                        $comment['employeeLastName'] = $userNames[$userId]['lastName'];
1771                    } else {
1772                        $comment['employeeFirstName'] = null;
1773                        $comment['employeeLastName'] = null;
1774                    }
1775                }
1776                unset($comment);
1777            }
1778
1779            return [
1780                'success' => true,
1781                'date' => $targetDate,
1782                'data' => $comments
1783            ];
1784
1785        } catch (\Exception $e) {
1786            $this->log->LogError("Error getting task comments: " . $e->getMessage());
1787            return [
1788                'success' => false,
1789                'error' => 'Failed to get comments'
1790            ];
1791        }
1792    }
1793
1794    /**
1795     * Get completed tasks for a specific date
1796     *
1797     * @param string $storeId The store ID (typeNum)
1798     * @param string|null $date Date (Y-m-d), defaults to today
1799     * @return array Result with completed tasks or error
1800     */
1801    public function getCompletedTasks($storeId, $date = null) {
1802        $this->log->LogDebug("Get Completed Tasks Action for store: " . $storeId);
1803
1804        $store = getStoreFromID($storeId);
1805
1806        if (!$store) {
1807            return ['success' => false, 'error' => 'Store not found'];
1808        }
1809
1810        try {
1811            $db = dbConnectByName($store->getDbName());
1812            $timezone = new \DateTimeZone($store->timezone);
1813            $now = new \DateTime('now', $timezone);
1814            $targetDate = $date ?: $now->format('Y-m-d');
1815
1816            // Query without employee join (employees now in central database)
1817            $stmt = $db->prepare("
1818                SELECT t.id, t.taskName, t.comment, t.taskGroup,
1819                       tg.groupName as listName,
1820                       dtc.status, dtc.completedBy, dtc.completedAt, dtc.notes as completionNotes
1821                FROM workbook_task_completions dtc
1822                JOIN tasks t ON dtc.taskId = t.id
1823                LEFT JOIN taskGroups tg ON t.taskGroup = tg.id
1824                WHERE dtc.date = :date AND dtc.status = 2
1825                ORDER BY dtc.completedAt ASC
1826            ");
1827            $stmt->execute([':date' => $targetDate]);
1828            $tasks = $stmt->fetchAll(PDO::FETCH_ASSOC);
1829
1830            // Enrich with employee names from unified users table
1831            if (!empty($tasks)) {
1832                $centralDb = \dbConnectByName('kiosk_users');
1833                $userIds = array_unique(array_filter(array_column($tasks, 'completedBy')));
1834                $userNames = [];
1835                if (!empty($userIds)) {
1836                    $placeholders = implode(',', array_fill(0, count($userIds), '?'));
1837                    $userStmt = $centralDb->prepare("
1838                        SELECT id, firstName, lastName
1839                        FROM users
1840                        WHERE id IN ($placeholders)
1841                    ");
1842                    $userStmt->execute(array_values($userIds));
1843                    $users = $userStmt->fetchAll(PDO::FETCH_ASSOC);
1844                    foreach ($users as $user) {
1845                        $userNames[$user['id']] = [
1846                            'firstName' => $user['firstName'],
1847                            'lastName' => $user['lastName']
1848                        ];
1849                    }
1850                }
1851
1852                // Add employee names to tasks
1853                foreach ($tasks as &$task) {
1854                    $userId = $task['completedBy'];
1855                    if ($userId && isset($userNames[$userId])) {
1856                        $task['completedByFirstName'] = $userNames[$userId]['firstName'];
1857                        $task['completedByLastName'] = $userNames[$userId]['lastName'];
1858                    } else {
1859                        $task['completedByFirstName'] = null;
1860                        $task['completedByLastName'] = null;
1861                    }
1862                }
1863                unset($task);
1864            }
1865
1866            return [
1867                'success' => true,
1868                'date' => $targetDate,
1869                'data' => $tasks
1870            ];
1871
1872        } catch (\Exception $e) {
1873            $this->log->LogError("Error getting completed tasks: " . $e->getMessage());
1874            return [
1875                'success' => false,
1876                'error' => 'Failed to get completed tasks'
1877            ];
1878        }
1879    }
1880
1881    /**
1882     * Get comprehensive store statistics
1883     *
1884     * Returns detailed store statistics including:
1885     * - Financial progress (sales/buys vs goals)
1886     * - Today's operations (queue, customers, times)
1887     * - 30-day averages (from storeStatsAggregate)
1888     * - Week-over-week trends
1889     * - Customer satisfaction (90-day survey data)
1890     * - Labor data (if WhenIWork enabled)
1891     *
1892     * @param string $storeId The store ID (typeNum)
1893     * @return array Comprehensive statistics array
1894     */
1895    public function getStoreStats($storeId) {
1896        $this->log->LogDebug("Get Store Stats Action for store: " . $storeId);
1897
1898        $store = getStoreFromID($storeId);
1899
1900        if (!$store) {
1901            $this->log->LogError("Store not found: " . $storeId);
1902            return ['success' => false, 'error' => 'Store not found'];
1903        }
1904
1905        try {
1906            $db = dbConnectByName($store->getDbName());
1907            if (!$db) {
1908                $this->log->LogError("Database connection failed for store: " . $storeId);
1909                return ['success' => false, 'error' => 'Database connection failed'];
1910            }
1911
1912            $timezone = new DateTimeZone($store->getTimezone());
1913            $today = new DateTime('now', $timezone);
1914
1915            // Get live financials data
1916            $financials = $this->getStoreStatsFinancials($db, $today);
1917
1918            // Get today's queue statistics
1919            $todayQueue = $this->getStoreStatsTodayQueue($db, $today, $store);
1920
1921            // Get pre-aggregated data (30-day averages, 90-day survey, etc.)
1922            $aggregate = $this->getStoreStatsAggregate($db);
1923
1924            // Calculate variance vs 30-day averages
1925            $vsAverage = [
1926                'buys' => $this->calculateVariance(
1927                    $todayQueue['buysCompleted'],
1928                    $aggregate['avg30_buys']
1929                ),
1930                'containers' => $this->calculateVariance(
1931                    $todayQueue['containersProcessed'],
1932                    $aggregate['avg30_containers']
1933                ),
1934                'newCustomers' => $this->calculateVariance(
1935                    $todayQueue['newCustomers'],
1936                    $aggregate['avg30_new_customers']
1937                )
1938            ];
1939
1940            // Get week-over-week trends (live query from recent data)
1941            $weekOverWeek = $this->getWeekOverWeek($db, $today);
1942
1943            // Get labor data if WhenIWork is enabled
1944            $labor = ['enabled' => false];
1945            if ($store->getWiwEnable() > 0) {
1946                try {
1947                    $fc = new \BuyerKiosk\WhenIWork\FinancialsController($GLOBALS['app'], $store);
1948                    $laborTotals = $fc->getLaborTotals();
1949
1950                    // Format employee data
1951                    $employees = $this->formatLaborEmployees(
1952                        $laborTotals['employeeData'] ?? [],
1953                        $store
1954                    );
1955
1956                    $labor = [
1957                        'enabled' => true,
1958                        'clockedInCount' => count($employees),
1959                        'totalHours' => round($laborTotals['hr'] ?? 0, 2),
1960                        'totalWages' => round($laborTotals['dollars'] ?? 0, 2),
1961                        'laborPercent' => $laborTotals['laborPercent'] ?? '0%',
1962                        'salesPerLaborHour' => round($laborTotals['salesPerLaborHour'] ?? 0, 2),
1963                        'employees' => $employees
1964                    ];
1965                } catch (\Exception $e) {
1966                    $this->log->LogError("Error fetching labor data: " . $e->getMessage());
1967                    $labor = ['enabled' => true, 'error' => 'Failed to fetch labor data'];
1968                }
1969            }
1970
1971            return [
1972                'success' => true,
1973                'storeId' => $storeId,
1974                'date' => $today->format('Y-m-d'),
1975
1976                // Financial progress (live data)
1977                'financials' => $financials,
1978
1979                // Today's operations (live data)
1980                'today' => $todayQueue,
1981
1982                // 30-day averages (pre-aggregated)
1983                'averages30Day' => [
1984                    'buysPerDay' => round($aggregate['avg30_buys'], 1),
1985                    'containersPerDay' => round($aggregate['avg30_containers'], 1),
1986                    'newCustomersPerDay' => round($aggregate['avg30_new_customers'], 1),
1987                    'avgWaitMinutes' => round($aggregate['avg30_wait_time'] / 60, 1),
1988                    'avgProcessMinutes' => round($aggregate['avg30_process_time'] / 60, 1)
1989                ],
1990
1991                // Today vs 30-day average
1992                'vsAverage' => $vsAverage,
1993
1994                // Week-over-week trends
1995                'weekOverWeek' => $weekOverWeek,
1996
1997                // 90-day customer satisfaction
1998                'customerSatisfaction' => [
1999                    'responseCount' => (int) $aggregate['survey90_response_count'],
2000                    'npsScore' => round($aggregate['survey90_nps_score'], 1),
2001                    'avgOverallRating' => round($aggregate['survey90_avg_step4'], 2),
2002                    'breakdown' => [
2003                        'greeting' => round($aggregate['survey90_avg_step1'], 2),
2004                        'waitTime' => round($aggregate['survey90_avg_step2'], 2),
2005                        'buyerKnowledge' => round($aggregate['survey90_avg_step3'], 2),
2006                        'overall' => round($aggregate['survey90_avg_step4'], 2),
2007                        'recommendation' => round($aggregate['survey90_avg_step6'], 2)
2008                    ]
2009                ],
2010
2011                // Labor data (if enabled)
2012                'labor' => $labor
2013            ];
2014
2015        } catch (\Exception $e) {
2016            $this->log->LogError("Error fetching store stats for store $storeId" . $e->getMessage());
2017            return [
2018                'success' => false,
2019                'error' => 'Failed to fetch store statistics',
2020                'message' => $e->getMessage()
2021            ];
2022        }
2023    }
2024
2025    /**
2026     * Get live financial data from LiveFinancials table
2027     *
2028     * @param PDO $db Database connection
2029     * @param DateTime $today Current date
2030     * @return array Financial metrics
2031     */
2032    private function getStoreStatsFinancials($db, $today) {
2033        try {
2034            $stmt = $db->prepare("SELECT * FROM LiveFinancials WHERE date = :date");
2035            $stmt->execute([':date' => $today->format('Y-m-d')]);
2036            $row = $stmt->fetch(PDO::FETCH_ASSOC);
2037
2038            if ($row) {
2039                $salesCurrent = (float) ($row['salesCurrent'] ?? 0);
2040                $salesGoal = (float) ($row['salesGoal'] ?? 0);
2041                $buysCurrent = (float) ($row['buysCurrent'] ?? 0);
2042                $buysGoal = (float) ($row['buysGoal'] ?? 0);
2043                $buysOutstanding = (int) ($row['buysOutstanding'] ?? 0);
2044
2045                $salesPercent = $salesGoal > 0 ? ($salesCurrent / $salesGoal) * 100 : 0;
2046                $buysPercent = $buysGoal > 0 ? ($buysCurrent / $buysGoal) * 100 : 0;
2047
2048                return [
2049                    'sales' => [
2050                        'current' => $salesCurrent,
2051                        'goal' => $salesGoal,
2052                        'percentOfGoal' => round($salesPercent, 1),
2053                        'status' => $this->getGoalStatus($salesCurrent, $salesGoal)
2054                    ],
2055                    'buys' => [
2056                        'current' => $buysCurrent,
2057                        'goal' => $buysGoal,
2058                        'percentOfGoal' => round($buysPercent, 1),
2059                        'outstanding' => $buysOutstanding
2060                    ]
2061                ];
2062            }
2063
2064            // Return defaults if no data
2065            return [
2066                'sales' => [
2067                    'current' => 0,
2068                    'goal' => 0,
2069                    'percentOfGoal' => 0,
2070                    'status' => 'neutral'
2071                ],
2072                'buys' => [
2073                    'current' => 0,
2074                    'goal' => 0,
2075                    'percentOfGoal' => 0,
2076                    'outstanding' => 0
2077                ]
2078            ];
2079
2080        } catch (PDOException $e) {
2081            $this->log->LogError("Error fetching LiveFinancials: " . $e->getMessage());
2082            return [
2083                'sales' => ['current' => 0, 'goal' => 0, 'percentOfGoal' => 0, 'status' => 'neutral'],
2084                'buys' => ['current' => 0, 'goal' => 0, 'percentOfGoal' => 0, 'outstanding' => 0]
2085            ];
2086        }
2087    }
2088
2089    /**
2090     * Get today's queue statistics (live data)
2091     *
2092     * @param PDO $db Database connection
2093     * @param DateTime $today Current date
2094     * @param Store $store Store object
2095     * @return array Today's queue metrics
2096     */
2097    private function getStoreStatsTodayQueue($db, $today, $store) {
2098        try {
2099            $dateStart = clone $today;
2100            $dateStart->setTime(0, 0, 0);
2101            $dateEnd = clone $today;
2102            $dateEnd->setTime(23, 59, 59);
2103
2104            // Get completed buys and aggregate data for today
2105            $stmt = $db->prepare("
2106                SELECT
2107                    COUNT(*) as buysCompleted,
2108                    SUM(numContainers) as containersProcessed,
2109                    SUM(isNewCustomer) as newCustomers,
2110                    AVG(TIMESTAMPDIFF(SECOND, timeEntered, timeStarted)) as avgWaitSeconds,
2111                    AVG(TIMESTAMPDIFF(SECOND, timeStarted, timeCompleted)) as avgProcessSeconds
2112                FROM buyQueue
2113                WHERE timeEntered BETWEEN :dateStart AND :dateEnd
2114                AND isProcessed = 1
2115                AND timeCompleted != '0000-00-00 00:00:00'
2116            ");
2117            $stmt->execute([
2118                ':dateStart' => $dateStart->format('Y-m-d H:i:s'),
2119                ':dateEnd' => $dateEnd->format('Y-m-d H:i:s')
2120            ]);
2121            $completed = $stmt->fetch(PDO::FETCH_ASSOC);
2122
2123            // Get current queue count
2124            $stmt = $db->prepare("SELECT COUNT(*) as count FROM buyQueue WHERE isProcessed = 0");
2125            $stmt->execute();
2126            $queueRow = $stmt->fetch(PDO::FETCH_ASSOC);
2127
2128            $buysCompleted = (int) ($completed['buysCompleted'] ?? 0);
2129            $newCustomers = (int) ($completed['newCustomers'] ?? 0);
2130            $repeatCustomers = $buysCompleted - $newCustomers;
2131
2132            return [
2133                'buysCompleted' => $buysCompleted,
2134                'buysInQueue' => (int) ($queueRow['count'] ?? 0),
2135                'containersProcessed' => (int) ($completed['containersProcessed'] ?? 0),
2136                'newCustomers' => $newCustomers,
2137                'repeatCustomers' => max(0, $repeatCustomers),
2138                'avgWaitMinutes' => round(($completed['avgWaitSeconds'] ?? 0) / 60, 1),
2139                'avgProcessMinutes' => round(($completed['avgProcessSeconds'] ?? 0) / 60, 1)
2140            ];
2141
2142        } catch (PDOException $e) {
2143            $this->log->LogError("Error fetching today's queue stats: " . $e->getMessage());
2144            return [
2145                'buysCompleted' => 0,
2146                'buysInQueue' => 0,
2147                'containersProcessed' => 0,
2148                'newCustomers' => 0,
2149                'repeatCustomers' => 0,
2150                'avgWaitMinutes' => 0,
2151                'avgProcessMinutes' => 0
2152            ];
2153        }
2154    }
2155
2156    /**
2157     * Get pre-aggregated statistics from storeStatsAggregate table
2158     *
2159     * @param PDO $db Database connection
2160     * @return array Aggregated statistics
2161     */
2162    private function getStoreStatsAggregate($db) {
2163        try {
2164            // Get the most recent aggregate record
2165            $stmt = $db->prepare("
2166                SELECT * FROM storeStatsAggregate
2167                ORDER BY date DESC
2168                LIMIT 1
2169            ");
2170            $stmt->execute();
2171            $row = $stmt->fetch(PDO::FETCH_ASSOC);
2172
2173            if ($row) {
2174                return [
2175                    'avg30_buys' => (float) ($row['avg30_buys'] ?? 0),
2176                    'avg30_containers' => (float) ($row['avg30_containers'] ?? 0),
2177                    'avg30_new_customers' => (float) ($row['avg30_new_customers'] ?? 0),
2178                    'avg30_process_time' => (int) ($row['avg30_process_time'] ?? 0),
2179                    'avg30_wait_time' => (int) ($row['avg30_wait_time'] ?? 0),
2180                    'survey90_response_count' => (int) ($row['survey90_response_count'] ?? 0),
2181                    'survey90_avg_step1' => (float) ($row['survey90_avg_step1'] ?? 0),
2182                    'survey90_avg_step2' => (float) ($row['survey90_avg_step2'] ?? 0),
2183                    'survey90_avg_step3' => (float) ($row['survey90_avg_step3'] ?? 0),
2184                    'survey90_avg_step4' => (float) ($row['survey90_avg_step4'] ?? 0),
2185                    'survey90_avg_step6' => (float) ($row['survey90_avg_step6'] ?? 0),
2186                    'survey90_nps_score' => (float) ($row['survey90_nps_score'] ?? 0)
2187                ];
2188            }
2189
2190            // Return defaults if no aggregate data exists yet
2191            return [
2192                'avg30_buys' => 0,
2193                'avg30_containers' => 0,
2194                'avg30_new_customers' => 0,
2195                'avg30_process_time' => 0,
2196                'avg30_wait_time' => 0,
2197                'survey90_response_count' => 0,
2198                'survey90_avg_step1' => 0,
2199                'survey90_avg_step2' => 0,
2200                'survey90_avg_step3' => 0,
2201                'survey90_avg_step4' => 0,
2202                'survey90_avg_step6' => 0,
2203                'survey90_nps_score' => 0
2204            ];
2205
2206        } catch (PDOException $e) {
2207            $this->log->LogError("Error fetching storeStatsAggregate: " . $e->getMessage());
2208            // Return defaults on error
2209            return [
2210                'avg30_buys' => 0,
2211                'avg30_containers' => 0,
2212                'avg30_new_customers' => 0,
2213                'avg30_process_time' => 0,
2214                'avg30_wait_time' => 0,
2215                'survey90_response_count' => 0,
2216                'survey90_avg_step1' => 0,
2217                'survey90_avg_step2' => 0,
2218                'survey90_avg_step3' => 0,
2219                'survey90_avg_step4' => 0,
2220                'survey90_avg_step6' => 0,
2221                'survey90_nps_score' => 0
2222            ];
2223        }
2224    }
2225
2226    /**
2227     * Calculate variance between current and average values
2228     *
2229     * @param float $current Current value
2230     * @param float $average Average value
2231     * @return array Variance data with value, formatted string, and status
2232     */
2233    private function calculateVariance($current, $average) {
2234        if ($average == 0) {
2235            return [
2236                'value' => 0,
2237                'formatted' => '0.0%',
2238                'status' => 'equal'
2239            ];
2240        }
2241
2242        $difference = $current - $average;
2243        $percentChange = ($difference / $average) * 100;
2244
2245        $status = 'equal';
2246        if ($percentChange > 5) {
2247            $status = 'above';
2248        } elseif ($percentChange < -5) {
2249            $status = 'below';
2250        }
2251
2252        $sign = $percentChange >= 0 ? '+' : '';
2253        $formatted = $sign . number_format($percentChange, 1) . '%';
2254
2255        return [
2256            'value' => round($difference, 1),
2257            'formatted' => $formatted,
2258            'status' => $status
2259        ];
2260    }
2261
2262    /**
2263     * Get week-over-week trends (this week vs last week)
2264     *
2265     * @param PDO $db Database connection
2266     * @param DateTime $today Current date
2267     * @return array Week-over-week comparison data
2268     */
2269    private function getWeekOverWeek($db, $today) {
2270        try {
2271            // This week: last 7 days
2272            $thisWeekStart = clone $today;
2273            $thisWeekStart->modify('-6 days')->setTime(0, 0, 0);
2274            $thisWeekEnd = clone $today;
2275            $thisWeekEnd->setTime(23, 59, 59);
2276
2277            // Last week: 7-13 days ago
2278            $lastWeekStart = clone $today;
2279            $lastWeekStart->modify('-13 days')->setTime(0, 0, 0);
2280            $lastWeekEnd = clone $today;
2281            $lastWeekEnd->modify('-7 days')->setTime(23, 59, 59);
2282
2283            // This week data
2284            $stmt = $db->prepare("
2285                SELECT
2286                    COUNT(*) as buys,
2287                    SUM(numContainers) as containers
2288                FROM buyQueue
2289                WHERE timeEntered BETWEEN :start AND :end
2290                AND isProcessed = 1
2291            ");
2292            $stmt->execute([
2293                ':start' => $thisWeekStart->format('Y-m-d H:i:s'),
2294                ':end' => $thisWeekEnd->format('Y-m-d H:i:s')
2295            ]);
2296            $thisWeek = $stmt->fetch(PDO::FETCH_ASSOC);
2297
2298            // Last week data
2299            $stmt->execute([
2300                ':start' => $lastWeekStart->format('Y-m-d H:i:s'),
2301                ':end' => $lastWeekEnd->format('Y-m-d H:i:s')
2302            ]);
2303            $lastWeek = $stmt->fetch(PDO::FETCH_ASSOC);
2304
2305            // Get sales data from LiveFinancials (sum for the week)
2306            $stmt = $db->prepare("
2307                SELECT SUM(salesCurrent) as sales
2308                FROM LiveFinancials
2309                WHERE date BETWEEN :start AND :end
2310            ");
2311            $stmt->execute([
2312                ':start' => $thisWeekStart->format('Y-m-d'),
2313                ':end' => $thisWeekEnd->format('Y-m-d')
2314            ]);
2315            $thisWeekSales = $stmt->fetch(PDO::FETCH_ASSOC);
2316
2317            $stmt->execute([
2318                ':start' => $lastWeekStart->format('Y-m-d'),
2319                ':end' => $lastWeekEnd->format('Y-m-d')
2320            ]);
2321            $lastWeekSales = $stmt->fetch(PDO::FETCH_ASSOC);
2322
2323            $thisWeekBuys = (int) ($thisWeek['buys'] ?? 0);
2324            $lastWeekBuys = (int) ($lastWeek['buys'] ?? 0);
2325            $thisWeekContainers = (int) ($thisWeek['containers'] ?? 0);
2326            $lastWeekContainers = (int) ($lastWeek['containers'] ?? 0);
2327            $thisWeekSalesValue = (float) ($thisWeekSales['sales'] ?? 0);
2328            $lastWeekSalesValue = (float) ($lastWeekSales['sales'] ?? 0);
2329
2330            // Calculate percent changes
2331            $buysChange = $lastWeekBuys > 0
2332                ? (($thisWeekBuys - $lastWeekBuys) / $lastWeekBuys) * 100
2333                : 0;
2334            $salesChange = $lastWeekSalesValue > 0
2335                ? (($thisWeekSalesValue - $lastWeekSalesValue) / $lastWeekSalesValue) * 100
2336                : 0;
2337            $containersChange = $lastWeekContainers > 0
2338                ? (($thisWeekContainers - $lastWeekContainers) / $lastWeekContainers) * 100
2339                : 0;
2340
2341            return [
2342                'buysChange' => ($buysChange >= 0 ? '+' : '') . number_format($buysChange, 1) . '%',
2343                'salesChange' => ($salesChange >= 0 ? '+' : '') . number_format($salesChange, 1) . '%',
2344                'containersChange' => ($containersChange >= 0 ? '+' : '') . number_format($containersChange, 1) . '%',
2345                'thisWeekBuys' => $thisWeekBuys,
2346                'lastWeekBuys' => $lastWeekBuys
2347            ];
2348
2349        } catch (PDOException $e) {
2350            $this->log->LogError("Error calculating week-over-week: " . $e->getMessage());
2351            return [
2352                'buysChange' => '0.0%',
2353                'salesChange' => '0.0%',
2354                'containersChange' => '0.0%',
2355                'thisWeekBuys' => 0,
2356                'lastWeekBuys' => 0
2357            ];
2358        }
2359    }
2360
2361    /**
2362     * Format labor employee data for API response
2363     *
2364     * Uses unified users table (kiosk_users.users) for employee names.
2365     * WhenIWork user IDs are stored as externalId with source='wheniwork'.
2366     *
2367     * @param array $employeeData Raw employee data from WhenIWork
2368     * @param Store $store Store object
2369     * @return array Formatted employee array
2370     */
2371    private function formatLaborEmployees($employeeData, $store) {
2372        if (empty($employeeData)) {
2373            return [];
2374        }
2375
2376        $centralDb = \dbConnectByName('kiosk_users');
2377        $typeNum = $store->getTypeNum();
2378        $employees = [];
2379
2380        foreach ($employeeData as $userId => $data) {
2381            try {
2382                // Get employee name from unified users table
2383                // WhenIWork user IDs are stored as externalId with source='wheniwork'
2384                $stmt = $centralDb->prepare("
2385                    SELECT u.firstName, u.lastName
2386                    FROM users u
2387                    INNER JOIN userStoreAssignments usa ON u.id = usa.userId
2388                    WHERE u.source = 'wheniwork'
2389                      AND u.externalId = :wiwId
2390                      AND usa.typeNum = :typeNum
2391                      AND usa.isActive = 1
2392                      AND u.enabled = 1
2393                    LIMIT 1
2394                ");
2395                $stmt->execute([':wiwId' => (string)$userId, ':typeNum' => $typeNum]);
2396                $emp = $stmt->fetch(PDO::FETCH_ASSOC);
2397
2398                $firstName = $emp ? $emp['firstName'] : 'Employee';
2399                $lastName = $emp ? $emp['lastName'] : (string)$userId;
2400                $lastInitial = substr($lastName, 0, 1);
2401
2402                $employees[] = [
2403                    'name' => $firstName . ' ' . $lastInitial . '.',
2404                    'hours' => round($data['shiftHours'] ?? 0, 1),
2405                    'wages' => round($data['dollarValue'] ?? 0, 2),
2406                    'rate' => ($data['shiftHours'] ?? 0) > 0
2407                        ? round(($data['dollarValue'] ?? 0) / ($data['shiftHours'] ?? 1), 2)
2408                        : 0
2409                ];
2410            } catch (PDOException $e) {
2411                // Skip this employee on error
2412                continue;
2413            }
2414        }
2415
2416        return $employees;
2417    }
2418
2419    /**
2420     * Determine goal status based on achievement percentage
2421     *
2422     * @param float $current Current value
2423     * @param float $goal Goal value
2424     * @return string Status: 'good', 'warning', 'danger', 'neutral'
2425     */
2426    private function getGoalStatus($current, $goal) {
2427        if ($goal === null || $goal <= 0) {
2428            return 'neutral';
2429        }
2430
2431        $percentage = ($current / $goal);
2432
2433        if ($percentage >= 1.0) {
2434            return 'good';
2435        } elseif ($percentage >= 0.9) {
2436            return 'warning';
2437        } else {
2438            return 'danger';
2439        }
2440    }
2441
2442    /**
2443     * Get dashboard data for all stores associated with an API key
2444     *
2445     * Validates API key and returns comprehensive data for all associated stores
2446     *
2447     * @param string $apiKey The API key
2448     * @return array Array of store dashboard data or error
2449     */
2450    public function getDashboard($apiKey) {
2451        $this->log->LogDebug("Dashboard Action");
2452
2453        // Validate and sanitize API key
2454        $apiKey = htmlspecialchars(strip_tags($apiKey));
2455
2456        // API keys should be at least 8 characters
2457        if (strlen($apiKey) < 8) {
2458            $this->log->LogWarn("Invalid API Key length: " . $apiKey);
2459            http_response_code(403);
2460            return ['error' => 'Invalid API key'];
2461        }
2462
2463        // Get central database connection (uses DB_NAME from .env)
2464        $centralDb = dbConnectByName($_ENV['DB_NAME']);
2465        if (!$centralDb) {
2466            $this->log->LogError("Failed to connect to central database");
2467            http_response_code(500);
2468            return ['error' => 'Database connection failed'];
2469        }
2470
2471        // Validate API key using prepared statement
2472        $apiCheck = $centralDb->prepare("SELECT COUNT(store) as count FROM mobileAPI WHERE `key` = ?");
2473        $apiCheck->execute([$apiKey]);
2474        $row = $apiCheck->fetch(PDO::FETCH_ASSOC);
2475
2476        if ($row['count'] === 0) {
2477            $this->log->LogWarn("API Key not found: " . $apiKey);
2478            http_response_code(403);
2479            return ['error' => 'Invalid API key'];
2480        }
2481
2482        $this->log->LogDebug("Valid API Key: " . $apiKey);
2483
2484        // Get stores associated with API key using prepared statement
2485        $apiQuery = $centralDb->prepare("SELECT store FROM mobileAPI WHERE `key` = ?");
2486        $apiQuery->execute([$apiKey]);
2487
2488        $storeData = [];
2489
2490        while ($row = $apiQuery->fetch(PDO::FETCH_ASSOC)) {
2491            $this->log->LogDebug("Processing store: " . $row['store']);
2492
2493            $store = getStoreFromID($row['store']);
2494
2495            if (!$store || (int)$store->getActive() !== 1) {
2496                $this->log->LogDebug("Store inactive or not found: " . $row['store']);
2497                continue;
2498            }
2499
2500            $storeDb = dbConnectByName($store->getDbName());
2501            if (!$storeDb) {
2502                $this->log->LogError("Database connection failed for store: " . $row['store']);
2503                continue;
2504            }
2505
2506            // Get current queue
2507            $queueQuery = $storeDb->query("SELECT * FROM buyQueue, customers
2508                                          WHERE buyQueue.isProcessed = 0
2509                                          AND customers.customerID = buyQueue.customerID
2510                                          ORDER BY timeStarted DESC, timeEntered ASC");
2511
2512            // Get live financials data
2513            $today = new DateTime('now', new DateTimeZone($store->getTimeZone()));
2514            $financialData = [
2515                "bGoal" => '$0.00',
2516                "bCurrent" => '$0.00',
2517                "sGoal" => '$0.00',
2518                "sCurrent" => '$0.00',
2519                "date" => $today->format('Y-m-d')
2520            ];
2521
2522            try {
2523                $stmt = $storeDb->prepare("SELECT * FROM LiveFinancials WHERE date = :today");
2524                $stmt->bindValue(":today", $today->format('Y-m-d'));
2525                $stmt->execute();
2526
2527                if ($finRow = $stmt->fetch(PDO::FETCH_ASSOC)) {
2528                    $financialData = [
2529                        "bGoal" => '$' . number_format(floatval($finRow['buysGoal']), 2),
2530                        "bCurrent" => '$' . number_format(floatval($finRow['buysCurrent']), 2),
2531                        "sGoal" => '$' . number_format(floatval($finRow['salesGoal']), 2),
2532                        "sCurrent" => '$' . number_format(floatval($finRow['salesCurrent']), 2),
2533                        "date" => $finRow['date']
2534                    ];
2535                }
2536            } catch (PDOException $e) {
2537                $this->log->LogDebug("PDO exception in LiveFinancials: " . $e->getMessage());
2538            }
2539
2540            // Calculate queue statistics
2541            $buyersArray = [];
2542            $sortersArray = [];
2543            $numQueued = 0;
2544
2545            while ($buy = $queueQuery->fetch(PDO::FETCH_ASSOC)) {
2546                if (isset($buy['buyerID']) && !in_array($buy['buyerID'], $buyersArray)) {
2547                    $buyersArray[] = $buy['buyerID'];
2548                }
2549                if (isset($buy['sorterID']) && !in_array($buy['sorterID'], $sortersArray)) {
2550                    $sortersArray[] = $buy['sorterID'];
2551                }
2552                $numQueued++;
2553            }
2554
2555            // Get total buys for the day
2556            $date = getCurrentDateRange();
2557            $totalQueue = $storeDb->prepare("SELECT COUNT(buyID) as count FROM buyQueue
2558                                            WHERE timeEntered BETWEEN :dateStart AND :dateEnd");
2559            $totalQueue->bindValue(':dateStart', $date['dateStart']->format("Y-m-d H:i:s"));
2560            $totalQueue->bindValue(':dateEnd', $date['dateEnd']->format("Y-m-d H:i:s"));
2561            $totalQueue->execute();
2562            $buyCount = $totalQueue->fetch(PDO::FETCH_ASSOC);
2563            $totalBuys = $buyCount['count'];
2564
2565            // Get estimated wait time
2566            $est = new EstimatedWaitTime();
2567            $est->getEstimatedWaitTime($store);
2568
2569            // Build store data array
2570            $tempArray = [];
2571            $tempArray['typeNum'] = $store->getTypeNum();
2572            $tempArray['storeCity'] = $store->getCity();
2573            $tempArray['storeType'] = (int)$store->getStoreType();
2574
2575            $tempArray['numBuyers'] = count($buyersArray);
2576            $tempArray['numSorters'] = count($sortersArray);
2577            $tempArray['numInQueue'] = $numQueued;
2578            $tempArray['numBuysToday'] = (int)$totalBuys;
2579            $tempArray['waitTimeMinutes'] = $est->waitMinutes;
2580            $tempArray['liveFinancials'] = $financialData;
2581
2582            $storeData[] = $tempArray;
2583        }
2584
2585        return $storeData;
2586    }
2587
2588    /**
2589     * Main handler for API requests
2590     *
2591     * Routes requests to appropriate action methods based on POST parameters
2592     *
2593     * @param array $postData POST request data
2594     * @return void (outputs JSON directly)
2595     */
2596    public function handleRequest($postData) {
2597        header('Content-Type: application/json');
2598
2599        if (!isset($postData['APIKey'])) {
2600            http_response_code(400);
2601            echo json_encode(['error' => 'API key required']);
2602            return;
2603        }
2604
2605        $action = $postData['action'] ?? '';
2606
2607        // Dashboard action doesn't require a store parameter
2608        if ($action === 'dashboard') {
2609            $result = $this->getDashboard($postData['APIKey']);
2610            echo json_encode($result);
2611            return;
2612        }
2613
2614        // All other actions require a store parameter
2615        if (!isset($postData['store'])) {
2616            http_response_code(400);
2617            echo json_encode(['error' => 'Store parameter required']);
2618            return;
2619        }
2620
2621        $storeId = $postData['store'];
2622
2623        switch ($action) {
2624            case 'currentQueue':
2625                $result = $this->getCurrentQueue($storeId);
2626                break;
2627
2628            case 'completedBuys':
2629                $result = $this->getCompletedBuys($storeId);
2630                break;
2631
2632            case 'storePage':
2633                $result = $this->getStorePage($storeId);
2634                break;
2635
2636            case 'buyerStats':
2637                $result = $this->getBuyerStats($storeId);
2638                break;
2639
2640            case 'todayPerformance':
2641                $result = $this->getTodayPerformance($storeId);
2642                break;
2643
2644            case 'storeStats':
2645                $result = $this->getStoreStats($storeId);
2646                break;
2647
2648            default:
2649                http_response_code(400);
2650                echo json_encode(['error' => 'Invalid action']);
2651                return;
2652        }
2653
2654        echo json_encode($result);
2655    }
2656
2657    // ========================================================================
2658    // BACKSTOCK MANAGEMENT METHODS
2659    // ========================================================================
2660
2661    /**
2662     * Get backstock events dashboard summary
2663     *
2664     * @param string $storeId Store typeNum
2665     * @return array Dashboard data
2666     */
2667    public function getBackstockEventsDashboard($storeId) {
2668        try {
2669            $store = new Store();
2670            if (!$store->createStore($storeId)) {
2671                return ['success' => false, 'error' => 'Invalid store'];
2672            }
2673
2674            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2675            $dashboard = $eventService->getDashboardSummary();
2676
2677            return [
2678                'success' => true,
2679                'dashboard' => $dashboard
2680            ];
2681        } catch (\Exception $e) {
2682            $this->log->LogError("getBackstockEventsDashboard error: " . $e->getMessage());
2683            return ['success' => false, 'error' => 'Failed to get dashboard'];
2684        }
2685    }
2686
2687    /**
2688     * Get backstock events
2689     *
2690     * @param string $storeId Store typeNum
2691     * @param string|null $status Filter by status (active, upcoming, null for all)
2692     * @return array Events list
2693     */
2694    public function getBackstockEvents($storeId, $status = null) {
2695        try {
2696            $store = new Store();
2697            if (!$store->createStore($storeId)) {
2698                return ['success' => false, 'error' => 'Invalid store'];
2699            }
2700
2701            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2702
2703            switch ($status) {
2704                case 'active':
2705                    $events = $eventService->getActiveEvents();
2706                    break;
2707                case 'upcoming':
2708                    $events = $eventService->getUpcomingEvents(30);
2709                    break;
2710                default:
2711                    $events = $eventService->getAllEvents();
2712            }
2713
2714            return [
2715                'success' => true,
2716                'events' => $events
2717            ];
2718        } catch (\Exception $e) {
2719            $this->log->LogError("getBackstockEvents error: " . $e->getMessage());
2720            return ['success' => false, 'error' => 'Failed to get events'];
2721        }
2722    }
2723
2724    /**
2725     * Get single backstock event details
2726     *
2727     * @param string $storeId Store typeNum
2728     * @param int $eventId Event ID
2729     * @return array Event details
2730     */
2731    public function getBackstockEventDetails($storeId, $eventId) {
2732        try {
2733            $store = new Store();
2734            if (!$store->createStore($storeId)) {
2735                return ['success' => false, 'error' => 'Invalid store'];
2736            }
2737
2738            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2739            $event = $eventService->getEventById($eventId);
2740
2741            if (!$event) {
2742                return ['success' => false, 'error' => 'Event not found'];
2743            }
2744
2745            return [
2746                'success' => true,
2747                'event' => $event
2748            ];
2749        } catch (\Exception $e) {
2750            $this->log->LogError("getBackstockEventDetails error: " . $e->getMessage());
2751            return ['success' => false, 'error' => 'Failed to get event details'];
2752        }
2753    }
2754
2755    /**
2756     * Create a new backstock event
2757     *
2758     * @param string $storeId Store typeNum
2759     * @param array $eventData Event data
2760     * @return array Created event
2761     */
2762    public function createBackstockEvent($storeId, $eventData) {
2763        try {
2764            $store = new Store();
2765            if (!$store->createStore($storeId)) {
2766                return ['success' => false, 'error' => 'Invalid store'];
2767            }
2768
2769            // Validate required fields
2770            if (empty($eventData['name']) || empty($eventData['startDate']) || empty($eventData['endDate'])) {
2771                return ['success' => false, 'error' => 'Missing required fields: name, startDate, endDate'];
2772            }
2773
2774            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2775            $event = $eventService->createEvent($eventData);
2776
2777            return [
2778                'success' => true,
2779                'event' => $event
2780            ];
2781        } catch (\Exception $e) {
2782            $this->log->LogError("createBackstockEvent error: " . $e->getMessage());
2783            return ['success' => false, 'error' => 'Failed to create event'];
2784        }
2785    }
2786
2787    /**
2788     * Update a backstock event
2789     *
2790     * @param string $storeId Store typeNum
2791     * @param int $eventId Event ID
2792     * @param array $eventData Event data to update
2793     * @return array Updated event
2794     */
2795    public function updateBackstockEvent($storeId, $eventId, $eventData) {
2796        try {
2797            $store = new Store();
2798            if (!$store->createStore($storeId)) {
2799                return ['success' => false, 'error' => 'Invalid store'];
2800            }
2801
2802            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2803            $event = $eventService->updateEvent($eventId, $eventData);
2804
2805            if (!$event) {
2806                return ['success' => false, 'error' => 'Event not found'];
2807            }
2808
2809            return [
2810                'success' => true,
2811                'event' => $event
2812            ];
2813        } catch (\Exception $e) {
2814            $this->log->LogError("updateBackstockEvent error: " . $e->getMessage());
2815            return ['success' => false, 'error' => 'Failed to update event'];
2816        }
2817    }
2818
2819    /**
2820     * Delete a backstock event
2821     *
2822     * @param string $storeId Store typeNum
2823     * @param int $eventId Event ID
2824     * @return array Result
2825     */
2826    public function deleteBackstockEvent($storeId, $eventId) {
2827        try {
2828            $store = new Store();
2829            if (!$store->createStore($storeId)) {
2830                return ['success' => false, 'error' => 'Invalid store'];
2831            }
2832
2833            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2834            $success = $eventService->deleteEvent($eventId);
2835
2836            if (!$success) {
2837                return ['success' => false, 'error' => 'Event not found or could not be deleted'];
2838            }
2839
2840            return ['success' => true];
2841        } catch (\Exception $e) {
2842            $this->log->LogError("deleteBackstockEvent error: " . $e->getMessage());
2843            return ['success' => false, 'error' => 'Failed to delete event'];
2844        }
2845    }
2846
2847    /**
2848     * Update backstock event progress
2849     *
2850     * @param string $storeId Store typeNum
2851     * @param int $eventId Event ID
2852     * @param array $progressData Progress data
2853     * @return array Result
2854     */
2855    public function updateBackstockEventProgress($storeId, $eventId, $progressData) {
2856        try {
2857            $store = new Store();
2858            if (!$store->createStore($storeId)) {
2859                return ['success' => false, 'error' => 'Invalid store'];
2860            }
2861
2862            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2863            $progress = $eventService->recordProgress($eventId, $progressData);
2864
2865            return [
2866                'success' => true,
2867                'progress' => $progress
2868            ];
2869        } catch (\Exception $e) {
2870            $this->log->LogError("updateBackstockEventProgress error: " . $e->getMessage());
2871            return ['success' => false, 'error' => 'Failed to update progress'];
2872        }
2873    }
2874
2875    /**
2876     * Get backstock events timeline
2877     *
2878     * @param string $storeId Store typeNum
2879     * @param int $days Number of days to look ahead
2880     * @return array Timeline data
2881     */
2882    public function getBackstockEventsTimeline($storeId, $days = 30) {
2883        try {
2884            $store = new Store();
2885            if (!$store->createStore($storeId)) {
2886                return ['success' => false, 'error' => 'Invalid store'];
2887            }
2888
2889            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2890            $timeline = $eventService->getTimeline($days);
2891
2892            return [
2893                'success' => true,
2894                'timeline' => $timeline
2895            ];
2896        } catch (\Exception $e) {
2897            $this->log->LogError("getBackstockEventsTimeline error: " . $e->getMessage());
2898            return ['success' => false, 'error' => 'Failed to get timeline'];
2899        }
2900    }
2901
2902    /**
2903     * Get backstock alerts
2904     *
2905     * @param string $storeId Store typeNum
2906     * @return array Alerts list
2907     */
2908    public function getBackstockAlerts($storeId) {
2909        try {
2910            $store = new Store();
2911            if (!$store->createStore($storeId)) {
2912                return ['success' => false, 'error' => 'Invalid store'];
2913            }
2914
2915            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2916            $alerts = $eventService->getUnacknowledgedAlerts();
2917
2918            return [
2919                'success' => true,
2920                'alerts' => $alerts,
2921                'count' => count($alerts)
2922            ];
2923        } catch (\Exception $e) {
2924            $this->log->LogError("getBackstockAlerts error: " . $e->getMessage());
2925            return ['success' => false, 'error' => 'Failed to get alerts'];
2926        }
2927    }
2928
2929    /**
2930     * Acknowledge a backstock alert
2931     *
2932     * @param string $storeId Store typeNum
2933     * @param int $alertId Alert ID
2934     * @param int $employeeId Employee ID
2935     * @return array Result
2936     */
2937    public function acknowledgeBackstockAlert($storeId, $alertId, $employeeId) {
2938        try {
2939            $store = new Store();
2940            if (!$store->createStore($storeId)) {
2941                return ['success' => false, 'error' => 'Invalid store'];
2942            }
2943
2944            if (empty($employeeId)) {
2945                return ['success' => false, 'error' => 'Missing employeeId'];
2946            }
2947
2948            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2949            $success = $eventService->acknowledgeAlert($alertId, $employeeId);
2950
2951            if (!$success) {
2952                return ['success' => false, 'error' => 'Alert not found'];
2953            }
2954
2955            return ['success' => true];
2956        } catch (\Exception $e) {
2957            $this->log->LogError("acknowledgeBackstockAlert error: " . $e->getMessage());
2958            return ['success' => false, 'error' => 'Failed to acknowledge alert'];
2959        }
2960    }
2961
2962    /**
2963     * Get bins to pull for upcoming events
2964     *
2965     * @param string $storeId Store typeNum
2966     * @param int $days Days ahead to look
2967     * @return array Bins list
2968     */
2969    public function getBackstockBinsToPull($storeId, $days = 14) {
2970        try {
2971            $store = new Store();
2972            if (!$store->createStore($storeId)) {
2973                return ['success' => false, 'error' => 'Invalid store'];
2974            }
2975
2976            $eventService = new \BuyerKiosk\Backstock\EventService($store);
2977            $bins = $eventService->getBinsToPull($days);
2978
2979            return [
2980                'success' => true,
2981                'bins' => $bins,
2982                'count' => count($bins)
2983            ];
2984        } catch (\Exception $e) {
2985            $this->log->LogError("getBackstockBinsToPull error: " . $e->getMessage());
2986            return ['success' => false, 'error' => 'Failed to get bins to pull'];
2987        }
2988    }
2989
2990    /**
2991     * Get bins to store after events
2992     *
2993     * @param string $storeId Store typeNum
2994     * @param int $days Days to look back
2995     * @return array Bins list
2996     */
2997    public function getBackstockBinsToStore($storeId, $days = 14) {
2998        try {
2999            $store = new Store();
3000            if (!$store->createStore($storeId)) {
3001                return ['success' => false, 'error' => 'Invalid store'];
3002            }
3003
3004            $eventService = new \BuyerKiosk\Backstock\EventService($store);
3005            $bins = $eventService->getBinsToStore($days);
3006
3007            return [
3008                'success' => true,
3009                'bins' => $bins,
3010                'count' => count($bins)
3011            ];
3012        } catch (\Exception $e) {
3013            $this->log->LogError("getBackstockBinsToStore error: " . $e->getMessage());
3014            return ['success' => false, 'error' => 'Failed to get bins to store'];
3015        }
3016    }
3017
3018    /**
3019     * Get bin counts by category IDs
3020     *
3021     * @param string $storeId Store typeNum
3022     * @param string $categoryIds Comma-separated list of category IDs
3023     * @return array Bin counts (total, onsite, offsite)
3024     */
3025    public function getBackstockBinCountsByCategories($storeId, $categoryIds) {
3026        try {
3027            $store = new Store();
3028            if (!$store->createStore($storeId)) {
3029                return ['success' => false, 'error' => 'Invalid store'];
3030            }
3031
3032            if (empty($categoryIds)) {
3033                return [
3034                    'success' => true,
3035                    'total' => 0,
3036                    'onsite' => 0,
3037                    'offsite' => 0
3038                ];
3039            }
3040
3041            // Convert comma-separated string to array of integers
3042            $categoryIdsArray = array_map('intval', explode(',', $categoryIds));
3043
3044            $eventService = new \BuyerKiosk\Backstock\EventService($store);
3045            $counts = $eventService->getBinCountsByCategoryIds($categoryIdsArray);
3046
3047            return [
3048                'success' => true,
3049                'total' => $counts['total'],
3050                'onsite' => $counts['onsite'],
3051                'offsite' => $counts['offsite'],
3052                'categoryIds' => $categoryIdsArray
3053            ];
3054        } catch (\Exception $e) {
3055            $this->log->LogError("getBackstockBinCountsByCategories error: " . $e->getMessage());
3056            return ['success' => false, 'error' => 'Failed to get bin counts'];
3057        }
3058    }
3059
3060    /**
3061     * Get event templates
3062     *
3063     * @param string $storeId Store typeNum
3064     * @return array Templates list
3065     */
3066    public function getBackstockEventTemplates($storeId) {
3067        try {
3068            $store = new Store();
3069            if (!$store->createStore($storeId)) {
3070                return ['success' => false, 'error' => 'Invalid store'];
3071            }
3072
3073            $eventService = new \BuyerKiosk\Backstock\EventService($store);
3074            $templates = $eventService->getTemplates();
3075
3076            return [
3077                'success' => true,
3078                'templates' => $templates
3079            ];
3080        } catch (\Exception $e) {
3081            $this->log->LogError("getBackstockEventTemplates error: " . $e->getMessage());
3082            return ['success' => false, 'error' => 'Failed to get templates'];
3083        }
3084    }
3085
3086    /**
3087     * Create event from template
3088     *
3089     * @param string $storeId Store typeNum
3090     * @param int $templateId Template ID
3091     * @param int $year Year for the event
3092     * @return array Created event
3093     */
3094    public function createBackstockEventFromTemplate($storeId, $templateId, $year) {
3095        try {
3096            $store = new Store();
3097            if (!$store->createStore($storeId)) {
3098                return ['success' => false, 'error' => 'Invalid store'];
3099            }
3100
3101            if (empty($templateId) || empty($year)) {
3102                return ['success' => false, 'error' => 'Missing templateId or year'];
3103            }
3104
3105            $eventService = new \BuyerKiosk\Backstock\EventService($store);
3106            $event = $eventService->createFromTemplate($templateId, $year);
3107
3108            if (!$event) {
3109                return ['success' => false, 'error' => 'Template not found'];
3110            }
3111
3112            return [
3113                'success' => true,
3114                'event' => $event
3115            ];
3116        } catch (\Exception $e) {
3117            $this->log->LogError("createBackstockEventFromTemplate error: " . $e->getMessage());
3118            return ['success' => false, 'error' => 'Failed to create event from template'];
3119        }
3120    }
3121
3122    // ========================================================================
3123    // BACKSTOCK TEAM NOTES METHODS
3124    // ========================================================================
3125
3126    /**
3127     * Get backstock team notes
3128     *
3129     * @param string $storeId Store typeNum
3130     * @param string|null $filter Filter (all, active, pinned)
3131     * @return array Notes list
3132     */
3133    public function getBackstockNotes($storeId, $filter = null) {
3134        try {
3135            $store = new Store();
3136            if (!$store->createStore($storeId)) {
3137                return ['success' => false, 'error' => 'Invalid store'];
3138            }
3139
3140            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3141
3142            switch ($filter) {
3143                case 'active':
3144                    $notes = $noteService->getActiveNotes();
3145                    break;
3146                case 'pinned':
3147                    $notes = $noteService->getPinnedNotes();
3148                    break;
3149                default:
3150                    $notes = $noteService->getAllNotes();
3151            }
3152
3153            return [
3154                'success' => true,
3155                'notes' => $notes
3156            ];
3157        } catch (\Exception $e) {
3158            $this->log->LogError("getBackstockNotes error: " . $e->getMessage());
3159            return ['success' => false, 'error' => 'Failed to get notes'];
3160        }
3161    }
3162
3163    /**
3164     * Create a backstock note
3165     *
3166     * @param string $storeId Store typeNum
3167     * @param array $noteData Note data
3168     * @return array Created note
3169     */
3170    public function createBackstockNote($storeId, $noteData) {
3171        try {
3172            $store = new Store();
3173            if (!$store->createStore($storeId)) {
3174                return ['success' => false, 'error' => 'Invalid store'];
3175            }
3176
3177            if (empty($noteData['content'])) {
3178                return ['success' => false, 'error' => 'Note content is required'];
3179            }
3180
3181            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3182            $note = $noteService->createNote($noteData);
3183
3184            return [
3185                'success' => true,
3186                'note' => $note
3187            ];
3188        } catch (\Exception $e) {
3189            $this->log->LogError("createBackstockNote error: " . $e->getMessage());
3190            return ['success' => false, 'error' => 'Failed to create note'];
3191        }
3192    }
3193
3194    /**
3195     * Update a backstock note
3196     *
3197     * @param string $storeId Store typeNum
3198     * @param int $noteId Note ID
3199     * @param array $noteData Note data
3200     * @return array Updated note
3201     */
3202    public function updateBackstockNote($storeId, $noteId, $noteData) {
3203        try {
3204            $store = new Store();
3205            if (!$store->createStore($storeId)) {
3206                return ['success' => false, 'error' => 'Invalid store'];
3207            }
3208
3209            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3210            $note = $noteService->updateNote($noteId, $noteData);
3211
3212            if (!$note) {
3213                return ['success' => false, 'error' => 'Note not found'];
3214            }
3215
3216            return [
3217                'success' => true,
3218                'note' => $note
3219            ];
3220        } catch (\Exception $e) {
3221            $this->log->LogError("updateBackstockNote error: " . $e->getMessage());
3222            return ['success' => false, 'error' => 'Failed to update note'];
3223        }
3224    }
3225
3226    /**
3227     * Delete a backstock note
3228     *
3229     * @param string $storeId Store typeNum
3230     * @param int $noteId Note ID
3231     * @return array Result
3232     */
3233    public function deleteBackstockNote($storeId, $noteId) {
3234        try {
3235            $store = new Store();
3236            if (!$store->createStore($storeId)) {
3237                return ['success' => false, 'error' => 'Invalid store'];
3238            }
3239
3240            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3241            $success = $noteService->deleteNote($noteId);
3242
3243            if (!$success) {
3244                return ['success' => false, 'error' => 'Note not found'];
3245            }
3246
3247            return ['success' => true];
3248        } catch (\Exception $e) {
3249            $this->log->LogError("deleteBackstockNote error: " . $e->getMessage());
3250            return ['success' => false, 'error' => 'Failed to delete note'];
3251        }
3252    }
3253
3254    /**
3255     * React to a backstock note
3256     *
3257     * @param string $storeId Store typeNum
3258     * @param int $noteId Note ID
3259     * @param string $reactionType Reaction type (like, heart)
3260     * @param string $deviceId Device identifier
3261     * @return array Result with reaction counts
3262     */
3263    public function reactToBackstockNote($storeId, $noteId, $reactionType, $deviceId) {
3264        try {
3265            $store = new Store();
3266            if (!$store->createStore($storeId)) {
3267                return ['success' => false, 'error' => 'Invalid store'];
3268            }
3269
3270            if (empty($reactionType) || !in_array($reactionType, ['like', 'heart'])) {
3271                return ['success' => false, 'error' => 'Invalid reaction type'];
3272            }
3273
3274            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3275            $result = $noteService->toggleReaction($noteId, $reactionType, $deviceId);
3276
3277            return [
3278                'success' => true,
3279                'hasReacted' => $result['hasReacted'],
3280                'count' => $result['count']
3281            ];
3282        } catch (\Exception $e) {
3283            $this->log->LogError("reactToBackstockNote error: " . $e->getMessage());
3284            return ['success' => false, 'error' => 'Failed to react'];
3285        }
3286    }
3287
3288    /**
3289     * Toggle pin status of a backstock note
3290     *
3291     * @param string $storeId Store typeNum
3292     * @param int $noteId Note ID
3293     * @return array Result
3294     */
3295    public function toggleBackstockNotePin($storeId, $noteId) {
3296        try {
3297            $store = new Store();
3298            if (!$store->createStore($storeId)) {
3299                return ['success' => false, 'error' => 'Invalid store'];
3300            }
3301
3302            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3303            $isPinned = $noteService->togglePin($noteId);
3304
3305            return [
3306                'success' => true,
3307                'isPinned' => $isPinned
3308            ];
3309        } catch (\Exception $e) {
3310            $this->log->LogError("toggleBackstockNotePin error: " . $e->getMessage());
3311            return ['success' => false, 'error' => 'Failed to toggle pin'];
3312        }
3313    }
3314
3315    /**
3316     * Get comments for a backstock note
3317     *
3318     * @param string $storeId Store typeNum
3319     * @param int $noteId Note ID
3320     * @return array Comments list
3321     */
3322    public function getBackstockNoteComments($storeId, $noteId) {
3323        try {
3324            $store = new Store();
3325            if (!$store->createStore($storeId)) {
3326                return ['success' => false, 'error' => 'Invalid store'];
3327            }
3328
3329            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3330            $comments = $noteService->getComments($noteId);
3331
3332            return [
3333                'success' => true,
3334                'comments' => $comments
3335            ];
3336        } catch (\Exception $e) {
3337            $this->log->LogError("getBackstockNoteComments error: " . $e->getMessage());
3338            return ['success' => false, 'error' => 'Failed to get comments'];
3339        }
3340    }
3341
3342    /**
3343     * Add a comment to a backstock note
3344     *
3345     * @param string $storeId Store typeNum
3346     * @param int $noteId Note ID
3347     * @param string $comment Comment text
3348     * @param string $authorName Author name
3349     * @return array Created comment
3350     */
3351    public function addBackstockNoteComment($storeId, $noteId, $comment, $authorName) {
3352        try {
3353            $store = new Store();
3354            if (!$store->createStore($storeId)) {
3355                return ['success' => false, 'error' => 'Invalid store'];
3356            }
3357
3358            if (empty($comment)) {
3359                return ['success' => false, 'error' => 'Comment is required'];
3360            }
3361
3362            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3363            $newComment = $noteService->addComment($noteId, $comment, $authorName);
3364
3365            return [
3366                'success' => true,
3367                'comment' => $newComment
3368            ];
3369        } catch (\Exception $e) {
3370            $this->log->LogError("addBackstockNoteComment error: " . $e->getMessage());
3371            return ['success' => false, 'error' => 'Failed to add comment'];
3372        }
3373    }
3374
3375    /**
3376     * Delete a comment from a backstock note
3377     *
3378     * @param string $storeId Store typeNum
3379     * @param int $noteId Note ID
3380     * @param int $commentId Comment ID
3381     * @return array Result
3382     */
3383    public function deleteBackstockNoteComment($storeId, $noteId, $commentId) {
3384        try {
3385            $store = new Store();
3386            if (!$store->createStore($storeId)) {
3387                return ['success' => false, 'error' => 'Invalid store'];
3388            }
3389
3390            $noteService = new \BuyerKiosk\Backstock\NoteService($store);
3391            $success = $noteService->deleteComment($noteId, $commentId);
3392
3393            if (!$success) {
3394                return ['success' => false, 'error' => 'Comment not found'];
3395            }
3396
3397            return ['success' => true];
3398        } catch (\Exception $e) {
3399            $this->log->LogError("deleteBackstockNoteComment error: " . $e->getMessage());
3400            return ['success' => false, 'error' => 'Failed to delete comment'];
3401        }
3402    }
3403
3404    // ========================================================================
3405    // BACKSTOCK REPORTS METHODS
3406    // ========================================================================
3407
3408    /**
3409     * Get backstock reports summary
3410     *
3411     * @param string $storeId Store typeNum
3412     * @return array Summary statistics
3413     */
3414    public function getBackstockReportsSummary($storeId) {
3415        try {
3416            $store = new Store();
3417            if (!$store->createStore($storeId)) {
3418                return ['success' => false, 'error' => 'Invalid store'];
3419            }
3420
3421            $reportService = new \BuyerKiosk\Backstock\ReportService($store);
3422            $summary = $reportService->getSummary();
3423
3424            return [
3425                'success' => true,
3426                'summary' => $summary
3427            ];
3428        } catch (\Exception $e) {
3429            $this->log->LogError("getBackstockReportsSummary error: " . $e->getMessage());
3430            return ['success' => false, 'error' => 'Failed to get summary'];
3431        }
3432    }
3433
3434    /**
3435     * Get stale inventory
3436     *
3437     * @param string $storeId Store typeNum
3438     * @param int $days Days threshold for stale
3439     * @return array Stale bins list
3440     */
3441    public function getBackstockStaleInventory($storeId, $days = 90) {
3442        try {
3443            $store = new Store();
3444            if (!$store->createStore($storeId)) {
3445                return ['success' => false, 'error' => 'Invalid store'];
3446            }
3447
3448            $reportService = new \BuyerKiosk\Backstock\ReportService($store);
3449            $bins = $reportService->getStaleInventory($days);
3450
3451            return [
3452                'success' => true,
3453                'bins' => $bins,
3454                'count' => count($bins)
3455            ];
3456        } catch (\Exception $e) {
3457            $this->log->LogError("getBackstockStaleInventory error: " . $e->getMessage());
3458            return ['success' => false, 'error' => 'Failed to get stale inventory'];
3459        }
3460    }
3461
3462    /**
3463     * Get activity summary
3464     *
3465     * @param string $storeId Store typeNum
3466     * @param int $days Days to look back
3467     * @return array Activity data
3468     */
3469    public function getBackstockActivitySummary($storeId, $days = 30) {
3470        try {
3471            $store = new Store();
3472            if (!$store->createStore($storeId)) {
3473                return ['success' => false, 'error' => 'Invalid store'];
3474            }
3475
3476            $reportService = new \BuyerKiosk\Backstock\ReportService($store);
3477            $activity = $reportService->getActivitySummary($days);
3478
3479            return [
3480                'success' => true,
3481                'activity' => $activity
3482            ];
3483        } catch (\Exception $e) {
3484            $this->log->LogError("getBackstockActivitySummary error: " . $e->getMessage());
3485            return ['success' => false, 'error' => 'Failed to get activity summary'];
3486        }
3487    }
3488
3489    // ========================================================================
3490    // BACKSTOCK BIN DETAILS METHODS
3491    // ========================================================================
3492
3493    /**
3494     * Get bin details
3495     *
3496     * @param string $storeId Store typeNum
3497     * @param int $binId Bin ID
3498     * @return array Bin details
3499     */
3500    public function getBackstockBinDetails($storeId, $binId) {
3501        try {
3502            $store = new Store();
3503            if (!$store->createStore($storeId)) {
3504                return ['success' => false, 'error' => 'Invalid store'];
3505            }
3506
3507            $binService = new \BuyerKiosk\Backstock\BinService($store);
3508            $bin = $binService->getBinDetails($binId);
3509
3510            if (!$bin) {
3511                return ['success' => false, 'error' => 'Bin not found'];
3512            }
3513
3514            return [
3515                'success' => true,
3516                'bin' => $bin
3517            ];
3518        } catch (\Exception $e) {
3519            $this->log->LogError("getBackstockBinDetails error: " . $e->getMessage());
3520            return ['success' => false, 'error' => 'Failed to get bin details'];
3521        }
3522    }
3523
3524    /**
3525     * Update bin notes
3526     *
3527     * @param string $storeId Store typeNum
3528     * @param int $binId Bin ID
3529     * @param string $notes Notes text
3530     * @return array Result
3531     */
3532    public function updateBackstockBinNotes($storeId, $binId, $notes) {
3533        try {
3534            $store = new Store();
3535            if (!$store->createStore($storeId)) {
3536                return ['success' => false, 'error' => 'Invalid store'];
3537            }
3538
3539            $binService = new \BuyerKiosk\Backstock\BinService($store);
3540            $success = $binService->updateNotes($binId, $notes);
3541
3542            if (!$success) {
3543                return ['success' => false, 'error' => 'Bin not found'];
3544            }
3545
3546            return ['success' => true];
3547        } catch (\Exception $e) {
3548            $this->log->LogError("updateBackstockBinNotes error: " . $e->getMessage());
3549            return ['success' => false, 'error' => 'Failed to update notes'];
3550        }
3551    }
3552
3553    /**
3554     * Record bin audit
3555     *
3556     * @param string $storeId Store typeNum
3557     * @param int $binId Bin ID
3558     * @param array $auditData Audit data
3559     * @return array Result with updated bin
3560     */
3561    public function recordBackstockBinAudit($storeId, $binId, $auditData) {
3562        try {
3563            $store = new Store();
3564            if (!$store->createStore($storeId)) {
3565                return ['success' => false, 'error' => 'Invalid store'];
3566            }
3567
3568            if (empty($auditData['employeeId'])) {
3569                return ['success' => false, 'error' => 'Employee ID is required'];
3570            }
3571
3572            $binService = new \BuyerKiosk\Backstock\BinService($store);
3573            $result = $binService->recordAudit($binId, $auditData);
3574
3575            if (!$result) {
3576                return ['success' => false, 'error' => 'Bin not found'];
3577            }
3578
3579            return [
3580                'success' => true,
3581                'bin' => $result
3582            ];
3583        } catch (\Exception $e) {
3584            $this->log->LogError("recordBackstockBinAudit error: " . $e->getMessage());
3585            return ['success' => false, 'error' => 'Failed to record audit'];
3586        }
3587    }
3588
3589    /**
3590     * Get backstock categories
3591     *
3592     * @param string $storeId Store typeNum
3593     * @return array Categories list
3594     */
3595    public function getBackstockCategories($storeId) {
3596        try {
3597            $store = new Store();
3598            if (!$store->createStore($storeId)) {
3599                return ['success' => false, 'error' => 'Invalid store'];
3600            }
3601
3602            $bsFactory = new \BuyerKiosk\Backstock\BackstockFactory($store);
3603            $categories = $bsFactory->getAllCategories();
3604
3605            // Convert to array if needed
3606            if (!is_array($categories)) {
3607                $categories = [];
3608            }
3609            $categories = array_values($categories);
3610
3611            return [
3612                'success' => true,
3613                'categories' => $categories
3614            ];
3615        } catch (\Exception $e) {
3616            $this->log->LogError("getBackstockCategories error: " . $e->getMessage());
3617            return ['success' => false, 'error' => 'Failed to get categories'];
3618        }
3619    }
3620}