# Solution Design Document

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user**
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

CON-1 **Framework**: PHP 8.x with Slim 2.6.2 routing, Twig 1.44.8 templates, jQuery + Syncfusion EJ2 on the frontend. No framework migrations.

CON-2 **Syncfusion Grid Migration**: Migrate the backstock overview table from DataTables to Syncfusion EJ2 Grid. Follow the proven `TeamMemberGrid.js` pattern already established in the codebase. Use native checkbox selection (`persistSelection: true`), Excel-like column filtering, and `getSelectedRecords()` for selection tracking. This eliminates the need for a custom `SelectionManager` class.

CON-3 **Database**: MariaDB with per-store databases (`kiosk_{typeNum}`). All schema changes MUST go through the migration system (`userfrosting/migrations/input/`). No direct SQL on store DBs.

CON-4 **Permissions**: Same permission gate as single-bin edits. `checkStoreGroup($typeNum)` for store access. No new permission URIs required.

CON-5 **Backward Compatibility**: All existing single-bin edit flows (manage modal, reprint, delete, hide/activate) must continue to work unchanged. The mass edit feature is purely additive.

CON-6 **Synchronous Processing**: Batch operations process synchronously within a single HTTP request. No async job queues. Acceptable because most stores have < 500 bins.

CON-7 **Existing UI Patterns**: Use Bootstrap 5.3.3 components, design tokens from `tokens.css`, SweetAlert for success/error toasts, and Syncfusion EJ2 dropdowns/multiselects for form inputs within modals.

## Implementation Context

### Required Context Sources

- ICO-1 Backstock Backend Architecture
  ```yaml
  - file: userfrosting/routes/groups/backstock.php
    relevance: HIGH
    sections: [bin CRUD lines 3-100, save-all lines 102-253, bulkCreate lines 754-763, hide/activate]
    why: "All existing bin API endpoints. Mass edit endpoints follow these patterns."

  - file: userfrosting/src/BuyerKiosk/Backstock/BackstockFactory.php
    relevance: HIGH
    sections: [getAllBins, makeBinReadable, bulkCreateBins, getCategoriesArray, getLocationsArray, prepareLookupArrays]
    why: "Factory contains all bin query and transformation logic. Mass edit adds batch update methods here."

  - file: userfrosting/src/BuyerKiosk/Backstock/Bin.php
    relevance: HIGH
    sections: [readByID, update, hide, activate, clearAllCategories, editSubCategories, mergeCategoryTags, isEmpty, createFromRow]
    why: "Bin entity methods that mass edit operations invoke in loops."

  - file: userfrosting/src/BuyerKiosk/Backstock/Action.php
    relevance: HIGH
    sections: [create, getUserNameArray]
    why: "Action logging pattern. Each mass-edited bin gets an individual action log entry."

  - file: userfrosting/src/BuyerKiosk/Backstock/Location.php
    relevance: MEDIUM
    why: "Location entity for mass location change validation."

  - file: userfrosting/src/BuyerKiosk/Backstock/Category.php
    relevance: MEDIUM
    why: "Category entity for mass category change."
  ```

- ICO-2 Backstock Frontend
  ```yaml
  - file: userfrosting/templates/themes/default/backstock/home.html
    relevance: HIGH
    sections: [table container, action toolbar lines 58-87, filter system, hidden bins toggle]
    why: "Main page migrating from DataTable to Syncfusion Grid. Grid container div replaces DataTable markup."

  - file: userfrosting/templates/themes/default/backstock/js/main.js
    relevance: HIGH
    sections: [DataTable init lines 6-70 (REPLACED by Syncfusion Grid init), column filters, search filters lines 284-311, hidden bins lines 450-576]
    why: "Major refactor: DataTable initialization replaced with Syncfusion Grid. Column filters and comma search reimplemented using Grid API."

  - file: userfrosting/templates/themes/default/backstock/js/manageBin.js
    relevance: HIGH
    sections: [saveAllChanges lines 514-618, updateTableRow lines 624-757, Syncfusion component init]
    why: "Single-bin save pattern. updateTableRow() needs adaptation for Syncfusion Grid row update (setCellValue or refreshing dataSource)."

  - file: userfrosting/templates/themes/default/backstock/js/reprintBin.js
    relevance: MEDIUM
    why: "Existing reprint flow. Mass print labels extends this pattern."

  - file: public_html/css/admin/modules/backstock.css
    relevance: MEDIUM
    why: "Existing styles. Floating action bar and mass edit modal styles added here."
  ```

- ICO-3 Syncfusion Grid Reference
  ```yaml
  - file: public_html/js/admin/team-members/TeamMemberGrid.js
    relevance: HIGH
    sections: [initGrid lines 145-300, selectionSettings, persistSelection, getSelectedRecords, bulk actions, filter chips, updateSelectAllState]
    why: "Direct blueprint for Syncfusion Grid migration. Has checkbox selection, persistSelection: true, bulk deactivate/reactivate, custom column templates, filter chips, getSelectedRecords() — the exact pattern backstock mass edit follows."

  - file: public_html/js/workspace/modules/completed/completed-buys-grid.js
    relevance: MEDIUM
    sections: [ES6 class pattern, grid initialization, column templates]
    why: "Alternative Syncfusion Grid pattern using ES6 class. Reference for grid setup conventions."
  ```

- ICO-4 Infrastructure Patterns
  ```yaml
  - file: userfrosting/migrations/input/20251203_001_backstock_bin_hide_feature.json
    relevance: MEDIUM
    why: "Migration JSON format reference (no schema changes needed for this feature)."

  - file: public_html/css/admin/tokens.css
    relevance: MEDIUM
    why: "Design token CSS variables for consistent styling."
  ```

### Implementation Boundaries

- **Must Preserve**:
  - All existing single-bin CRUD endpoints and their behavior
  - Existing column data and search/filter functionality (reimplemented via Syncfusion Grid)
  - Existing modal flows (manage, reprint, add, delete, bulk create)
  - Hidden bin toggle behavior
  - Column filter dropdowns (Location, On-Site, Main Category) — reimplemented as Grid column filters or external filter chips
  - `updateTableRow()` equivalent behavior in manageBin.js (adapted for Syncfusion Grid API)

- **Can Modify**:
  - `home.html` — replace DataTable markup with Syncfusion Grid container div, add floating action bar HTML, include new JS/modal files
  - `js/main.js` — replace DataTable initialization with Syncfusion Grid (following `TeamMemberGrid.js` pattern), add comma search, filter integration
  - `backstock.css` — add floating bar styles, mass edit modal styles, Syncfusion Grid overrides
  - `backstock.php` routes — add new bulk API endpoint group
  - `BackstockFactory.php` — add bulk update methods
  - `manageBin.js` — adapt `updateTableRow()` for Syncfusion Grid row updates

- **Must Not Touch**:
  - `Bin.php` entity methods (reuse existing methods, don't modify)
  - `Action.php` entity (reuse existing `create()` method)
  - Report/Event/Note controllers and routes
  - Central DB tables (`kiosk_buykiosk`, `kiosk_users`)
  - Print service integration (reuse existing `createReprintJob`)

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    User[Store Manager / Associate] --> Browser[Browser - Backstock Page]
    Browser --> |"AJAX POST"| MassEditAPI["Mass Edit API Endpoints<br/>/api/:typeNum/backstock/mass/*"]
    Browser --> |"AJAX GET"| ExistingAPI["Existing API Endpoints<br/>/api/:typeNum/backstock/*"]

    MassEditAPI --> StoreDB[(Store Database<br/>kiosk_{typeNum})]
    ExistingAPI --> StoreDB

    MassEditAPI --> |"Action Logging"| StoreDB
    MassEditAPI --> |"Reprint Jobs"| Redis[(Redis)]
    Redis --> PrintService[Print Service]

    StoreDB --> |"bsBins, bsLocations,<br/>bsCategories, bsBin_Cat,<br/>bsActions"| Tables[Store Tables]
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Backstock Mass Edit UI"
    type: HTTPS
    format: REST (JSON payload via jQuery AJAX)
    authentication: Session-based (UserFrosting session cookie)
    data_flow: "Mass edit requests from browser to server"

# Data Interfaces
data:
  - name: "Store Database"
    type: MariaDB
    connection: PDO via dbConnectByName($store->getDbName())
    data_flow: "Bin CRUD, action logging, category management"

  - name: "Redis"
    type: Redis
    connection: Predis client
    data_flow: "Reprint job storage for mass print labels"
```

### Project Commands

```bash
# Testing
./test.sh --testsuite unit                                        # Run unit tests
cd userfrosting && ./vendor/bin/phpunit --filter "Backstock"       # Backstock-specific tests

# CSS Build
php userfrosting/conductor build-css                               # Dev build after CSS changes
php userfrosting/conductor build-css --minify                      # Prod build

# Database Migrations
php userfrosting/conductor run                                     # Run pending migrations

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/Backstock/
```

## Solution Strategy

- **Architecture Pattern**: Layered extension of existing backstock module. New bulk API endpoints in the route file delegate to new `BackstockFactory` batch methods which iterate over bins using existing `Bin` entity methods within a single database transaction. Frontend migrates the backstock overview table from DataTables to Syncfusion EJ2 Grid (following `TeamMemberGrid.js` pattern) with native checkbox selection, `persistSelection: true`, and `getSelectedRecords()` for selection tracking. A floating action bar component provides mass action triggers.

- **Integration Approach**: The DataTable-to-Syncfusion-Grid migration is the primary frontend change. New bulk routes added to existing `backstock.php` route group. New JS files included via Twig `{% include %}`. New CSS appended to `backstock.css`. The Syncfusion Grid natively provides checkbox selection, cross-page selection persistence, and select-all — eliminating the need for a custom `SelectionManager` class.

- **Justification**: The backstock module already has a well-established pattern: route -> factory method -> entity methods -> JSON response -> jQuery AJAX -> in-place table update. Mass edit follows this exact pattern but operates on arrays of bin IDs instead of single bins. The `bulkCreateBins()` method provides direct precedent for transaction-wrapped batch operations. The `TeamMemberGrid.js` (1495 lines) already provides a proven Syncfusion Grid implementation with checkbox selection and bulk actions, making this a well-trodden path.

- **Key Decisions**:
  1. Single bulk endpoint per action type (not one generic "mass update" endpoint) — clearer API contracts, simpler validation
  2. Transaction per batch — all-or-nothing at DB level, report partial failures at application level
  3. Syncfusion Grid native selection — `persistSelection: true` + `getSelectedRecords()` replaces custom JS Set tracking. Grid handles cross-page persistence natively.
  4. Floating action bar as new standalone HTML/CSS/JS component

## Building Block View

### Components

```mermaid
graph LR
    subgraph "Browser - Frontend"
        SG["Syncfusion EJ2 Grid<br/>(checkbox selection,<br/>persistSelection: true)"]
        FAB["FloatingActionBar<br/>(UI component)"]
        CS["CommaSearch<br/>(search enhancement)"]
        MM["MassEditModals<br/>(confirmation modals)"]

        SG --> |"rowSelected/rowDeselected<br/>+ getSelectedRecords()"| FAB
        CS --> SG
        FAB --> MM
    end

    subgraph "Server - Backend"
        MR["Mass Edit Routes<br/>(backstock.php)"]
        BF["BackstockFactory<br/>(batch methods)"]
        BIN["Bin Entity<br/>(existing methods)"]
        ACT["Action Entity<br/>(existing create)"]
        DB[(Store Database)]
        RD[(Redis)]

        MR --> BF
        BF --> BIN
        BF --> ACT
        BIN --> DB
        ACT --> DB
        BF --> RD
    end

    MM --> |"AJAX POST"| MR
    MR --> |"JSON Response"| MM
```

### Directory Map

**Backend (PHP)**:
```
userfrosting/
├── routes/groups/
│   └── backstock.php                    # MODIFY: Add mass edit route group (~9 new endpoints)
├── src/BuyerKiosk/Backstock/
│   ├── BackstockFactory.php             # MODIFY: Add 9 batch update methods
│   ├── Bin.php                          # NO CHANGE: Reuse existing methods
│   ├── Action.php                       # NO CHANGE: Reuse existing create()
│   ├── Location.php                     # NO CHANGE: Reuse for validation
│   └── Category.php                     # NO CHANGE: Reuse for validation
└── migrations/input/
    └── (none needed — no schema changes)
```

**Frontend (JS/HTML/CSS)**:
```
userfrosting/templates/themes/default/backstock/
├── home.html                            # MODIFY: Replace DataTable markup with Syncfusion Grid
│                                        #   container div, add floating bar HTML, include new JS/modal files
├── js/
│   ├── main.js                          # MAJOR REFACTOR: Replace DataTable init with Syncfusion Grid
│   │                                    #   init (following TeamMemberGrid.js pattern). Selection handled
│   │                                    #   natively via persistSelection + getSelectedRecords().
│   │                                    #   Add comma search, filter integration, floating bar wiring.
│   ├── floatingBar.js                   # NEW: FloatingActionBar class (sticky bottom bar UI)
│   ├── massEditModals.js                # NEW: Mass edit modal handlers + AJAX submission
│   └── manageBin.js                     # MODIFY: Adapt updateTableRow() for Syncfusion Grid row updates
│                                        #   (setCellValue/setRowData instead of DataTable row().data())
├── modals/
│   └── massEditModals.html              # NEW: All mass edit confirmation modals (Twig)
└── templates/                           # NO CHANGE

public_html/css/admin/modules/
└── backstock.css                        # MODIFY: Add floating bar styles, mass edit modal styles,
                                         #   Syncfusion Grid style overrides
```

**Note**: `selectionManager.js` is no longer needed. Syncfusion Grid's native `persistSelection: true` + `getSelectedRecords()` replaces the custom SelectionManager class entirely. Selection events (`rowSelected`/`rowDeselected`) wire directly to the FloatingActionBar.

### Interface Specifications

#### Data Storage Changes

No schema changes required. All mass edit operations work with existing tables:

```yaml
# Existing tables used (NO modifications needed)
Table: bsBins
  READ: id, uuid, name, mainCategory, location, active, hiddenAt, ageDate, notes, itemCount, estimatedValue
  WRITE: mainCategory, location, active, hiddenAt, ageDate, notes, itemCount, estimatedValue

Table: bsBin_Cat (junction table)
  READ: binID, catID, categoryType
  WRITE: INSERT/DELETE via Bin::editSubCategories(), Bin::mergeCategoryTags(), Bin::clearAllCategories()

Table: bsActions
  WRITE: INSERT via Action::create() — one row per bin per mass operation

Table: bsLocations
  READ: id, name, onsite — for location dropdown validation

Table: bsCategories
  READ: id, name, color — for category dropdown validation
```

#### Internal API Changes

All new endpoints are under `POST /api/:typeNum/backstock/mass/`. All follow the same request/response contract.

**Standard Request Pattern** (all endpoints):
```yaml
# Sent as POST body with: { json: JSON.stringify({...}) }
Common Fields:
  binIds: int[] (required, array of bin IDs to operate on)
  # userId is NOT sent from the client — sourced server-side from $app->user->id
```

**Standard Response Pattern** (all endpoints):
```yaml
Response:
  success: boolean
  message: string (human-readable result description)
  results:
    updated: int[] (bin IDs successfully updated)
    skipped: int[] (bin IDs skipped, e.g., already in target state)
    failed: int[] (bin IDs that failed, e.g., deleted by another user)
  bins: object[] (updated bin objects for in-place table refresh, only for updated bins)
```

**Endpoint Specifications:**

```yaml
# 1. Mass Change Location
Endpoint: POST /api/:typeNum/backstock/mass/change-location
  Additional Request Fields:
    locationId: int (required, must be valid bsLocations.id)
  Skip Condition: bin already at target location
  Action Logged: action=6, categoryID=null

# 2. Mass Empty Bins
Endpoint: POST /api/:typeNum/backstock/mass/empty
  Additional Request Fields: (none)
  Skip Condition: bin already empty (isEmpty() returns true)
  Action Logged: action=0, categoryID=null
  Side Effects: clears mainCategory, all bsBin_Cat entries, resets ageDate to now

# 3. Mass Change Main Category
Endpoint: POST /api/:typeNum/backstock/mass/change-category
  Additional Request Fields:
    categoryId: int (required, POS subcategory code or custom category ID)
    categoryType: string (required, "pos_subcategory" | "custom")
  Skip Condition: none (always updates even if same category)
  Action Logged: action=6, categoryID=categoryId

# 4. Mass Change Tags
Endpoint: POST /api/:typeNum/backstock/mass/change-tags
  Additional Request Fields:
    mode: string (required, "add" | "remove")
    tagIds: int[] (required, category IDs to add or remove)
    tagTypes: string[] (required, parallel array: "custom" | "pos_subcategory")
  Skip Condition: none
  Action Logged: action=6 for add, action=3 for remove, categoryID=first tag ID

# 5. Mass Hide Bins
Endpoint: POST /api/:typeNum/backstock/mass/hide
  Additional Request Fields: (none)
  Skip Condition: bin already hidden (active=0)
  Action Logged: action=7 (hide), categoryID=null — explicit Action::create() call per bin
    (Bin::hide() does NOT log actions internally, so the factory method must call Action::create())
  Extra Response: hiddenCount: int (new total hidden count for badge update)

# 6. Mass Unhide Bins
Endpoint: POST /api/:typeNum/backstock/mass/unhide
  Additional Request Fields: (none)
  Skip Condition: bin already active (active=1)
  Action Logged: action=8 (unhide), categoryID=null — explicit Action::create() call per bin
    (Bin::activate() does NOT log actions internally, so the factory method must call Action::create())
  Extra Response: hiddenCount: int, bins: object[] (reactivated bins for table insertion)

# 7. Mass Print Labels
Endpoint: POST /api/:typeNum/backstock/mass/print-labels
  Additional Request Fields:
    quantity: int (required, labels per bin, 1-10)
  Response (different pattern):
    success: true
    message: "Print job created for X bins (Y total labels)"
    jobUUID: string (reprint job UUID from Redis)
  Note: No results/bins arrays needed. Uses BackstockFactory::createReprintJob().

# 8. Mass Update Age Date
Endpoint: POST /api/:typeNum/backstock/mass/update-age
  Additional Request Fields:
    ageDate: string (required, ISO date YYYY-MM-DD)
  Skip Condition: none (always updates)
  Action Logged: action=6, categoryID=null

# 9. Mass Add Notes
Endpoint: POST /api/:typeNum/backstock/mass/add-notes
  Additional Request Fields:
    note: string (required, text to append)
  Note Format: "[YYYY-MM-DD] {note text}" appended to existing notes with newline
  Skip Condition: none (always appends)
  Action Logged: action=6, categoryID=null
```

#### Application Data Models

```pseudocode
# No new entities. All operations use existing models.

ENTITY: Bin (EXISTING — no changes)
  BEHAVIORS REUSED:
    readByID(id): Bin
    update(): void
    hide(): void
    activate(): void
    clearAllCategories(): void
    editSubCategories(cats[]): void
    mergeCategoryTags(catIds[], categoriesArray): void
    isEmpty(): bool
    markAsUsed(): void

ENTITY: Action (EXISTING — no changes)
  BEHAVIORS REUSED:
    create(): void  — inserts action row with binID, employeeID, action, categoryID

ENTITY: BackstockFactory (MODIFIED — add batch methods)
  NEW BEHAVIORS:
    + massChangeLocation(binIds[], locationId, userId): array
    + massEmptyBins(binIds[], userId): array
    + massChangeCategory(binIds[], categoryId, categoryType, userId): array
    + massChangeTags(binIds[], mode, tagIds[], tagTypes[], userId): array
    + massHideBins(binIds[], userId): array
    + massUnhideBins(binIds[], userId): array
    + massUpdateAge(binIds[], ageDate, userId): array
    + massAddNotes(binIds[], note, userId): array
    + massPrintLabels(binIds[], quantity): string (jobUUID)

  # All mass methods return: ['updated'=>int[], 'skipped'=>int[], 'failed'=>int[], 'bins'=>Bin[]]
  # massPrintLabels returns jobUUID string instead
```

#### Integration Points

```yaml
# Internal Integration (within backstock module)
- from: Mass Edit Routes (backstock.php)
  to: BackstockFactory (batch methods)
  protocol: PHP method calls
  data_flow: "Route validates input, calls factory method with params, returns JSON response"

- from: BackstockFactory (batch methods)
  to: Bin Entity + Action Entity
  protocol: PHP method calls within PDO transaction
  data_flow: "Factory loops bin IDs, calls entity methods, logs actions"

- from: BackstockFactory::massPrintLabels
  to: Redis (via createReprintJob)
  protocol: Predis hset
  data_flow: "Stores bin ID + quantity array as JSON, returns job UUID"
```

### Implementation Examples

#### Example: Batch Update Pattern (BackstockFactory)

**Why this example**: This is the core pattern all 9 mass edit methods follow. Shows transaction wrapping, per-bin processing with skip/fail handling, individual action logging, and bin refresh for response.

```php
/**
 * Mass change location for multiple bins.
 *
 * @param int[] $binIds Array of bin IDs to update
 * @param int $locationId Target location ID
 * @param int $userId Logged-in user's ID (from kiosk_users.users)
 * @return array ['updated'=>[], 'skipped'=>[], 'failed'=>[], 'bins'=>[]]
 */
public function massChangeLocation(array $binIds, int $locationId, int $userId): array
{
    $result = ['updated' => [], 'skipped' => [], 'failed' => [], 'bins' => []];

    // Validate location exists
    $location = new Location($this->store);
    $location->readById($locationId);
    if (!$location->id) {
        throw new \InvalidArgumentException("Location not found: $locationId");
    }

    // Pre-load lookup arrays once for makeBinReadable (prevents N+1)
    $this->prepareLookupArrays();

    $this->storeDB->beginTransaction();
    try {
        foreach ($binIds as $binId) {
            try {
                $bin = new Bin($this->store);
                $bin->readByID($binId);

                if (!$bin->id) {
                    $result['failed'][] = $binId;
                    continue;
                }

                if ((int)$bin->locationID === $locationId) {
                    $result['skipped'][] = $binId;
                    continue;
                }

                $bin->locationID = $locationId;
                $bin->update();

                // Individual action log entry
                $action = new Action($this->store);
                $action->binID = $binId;
                $action->employeeID = $userId;
                $action->action = 6;
                $action->categoryID = null;
                $action->create();

                $result['updated'][] = $binId;

                // Refresh for response
                $bin->readByID($binId);
                $result['bins'][] = $this->makeBinReadable($bin);
            } catch (\Exception $e) {
                $result['failed'][] = $binId;
                $this->log->error("Mass location change failed for bin $binId: " . $e->getMessage());
            }
        }

        $this->storeDB->commit();
    } catch (\Exception $e) {
        $this->storeDB->rollBack();
        throw $e;
    }

    return $result;
}
```

#### Example: Syncfusion Grid Initialization with Selection (main.js)

**Why this example**: Shows the Syncfusion Grid setup with native checkbox selection, `persistSelection: true`, and selection event wiring to the floating action bar. Follows the `TeamMemberGrid.js` pattern established in the codebase. No custom SelectionManager class needed.

```javascript
/**
 * Backstock Overview Grid (Syncfusion EJ2 Grid)
 * Replaces DataTable with Syncfusion Grid for bin overview.
 * Selection tracking is handled natively by the Grid.
 */
var BackstockGrid = {
    grid: null,
    floatingBar: null,  // Set after FloatingActionBar init

    initGrid: function(binsData) {
        var self = this;

        this.grid = new ej.grids.Grid({
            dataSource: binsData,

            // Enable features
            allowSorting: true,
            allowPaging: true,
            allowSelection: true,
            allowFiltering: true,

            // Native checkbox selection with cross-page persistence
            selectionSettings: {
                type: 'Multiple',
                mode: 'Row',
                checkboxOnly: true,
                persistSelection: true  // Grid tracks selections across pages natively
            },

            // Filtering
            filterSettings: {
                type: 'Excel'  // Excel-like column filter dropdowns
            },

            // Pagination
            pageSettings: {
                pageSize: 50,
                pageSizes: [25, 50, 100, 200],
                pageCount: 5
            },

            // Column definitions
            columns: [
                {
                    type: 'checkbox',
                    width: 50,
                    allowSorting: false,
                    allowFiltering: false
                },
                {
                    field: 'id',
                    isPrimaryKey: true,
                    visible: false
                },
                {
                    field: 'name',
                    headerText: 'Bin #',
                    width: 120,
                    template: function(data) {
                        return self.renderBinNameCell(data);
                    }
                },
                // ... additional columns (category, location, age, etc.)
            ],

            // Selection events → update floating bar
            rowSelected: function(args) {
                self.updateFloatingBar();
            },
            rowDeselected: function(args) {
                self.updateFloatingBar();
            },

            // Row styling for hidden bins
            rowDataBound: function(args) {
                if (args.data && !args.data.active) {
                    args.row.classList.add('hidden-bin-row', 'table-secondary', 'text-muted');
                }
            }
        });
        this.grid.appendTo('#backstock-grid');
    },

    /**
     * Get selected bin IDs — native Syncfusion API, no custom tracking needed.
     */
    getSelectedIds: function() {
        if (!this.grid) return [];
        return this.grid.getSelectedRecords().map(function(record) {
            return record.id;
        });
    },

    /**
     * Get active/hidden breakdown from selected records.
     */
    getSelectionBreakdown: function() {
        var records = this.grid.getSelectedRecords();
        var active = 0, hidden = 0;
        records.forEach(function(r) {
            if (r.active) active++; else hidden++;
        });
        return { active: active, hidden: hidden, total: records.length };
    },

    /**
     * Clear all selections — native Grid API.
     */
    clearSelection: function() {
        if (this.grid) this.grid.clearSelection();
        this.updateFloatingBar();
    },

    /**
     * Deselect specific IDs after partial success.
     * Removes updated bins from selection, keeps failed bins selected.
     */
    deselectIds: function(idsToDeselect) {
        var self = this;
        var idSet = new Set(idsToDeselect);
        // Get current selected row indexes and deselect matching ones
        var selectedIndexes = this.grid.getSelectedRowIndexes();
        selectedIndexes.forEach(function(idx) {
            var rowData = self.grid.getRowByIndex(idx);
            if (rowData && idSet.has(rowData.id)) {
                self.grid.selectRow(idx, true); // toggle off
            }
        });
        this.updateFloatingBar();
    },

    updateFloatingBar: function() {
        if (this.floatingBar) {
            var breakdown = this.getSelectionBreakdown();
            this.floatingBar.update(breakdown.total, breakdown);
        }
    }
};
```

#### Example: Comma Search Integration

**Why this example**: The comma search needs to integrate with Syncfusion Grid's dataSource filtering without breaking existing single-term search, AND must trigger auto-selection via Grid's selectRows() API.

```javascript
/**
 * Comma search for Syncfusion Grid.
 * Detects comma-separated input, filters grid to matching bins,
 * and auto-selects all matched rows.
 */
function setupCommaSearch(backstockGrid) {
    var $searchInput = $('#backstock-search-input');
    var debounceTimer = null;
    var fullDataSource = null; // Original unfiltered data

    $searchInput.off('input.commaSearch').on('input.commaSearch', function() {
        clearTimeout(debounceTimer);
        var searchVal = $(this).val();

        debounceTimer = setTimeout(function() {
            hideCommaSearchBanner();

            if (!fullDataSource) {
                fullDataSource = backstockGrid.grid.dataSource.slice(); // Cache original data
            }

            if (searchVal.indexOf(',') !== -1) {
                var terms = searchVal.split(',')
                    .map(function(t) { return t.trim().toLowerCase(); })
                    .filter(function(t) { return t.length > 0; })
                    .filter(function(t, i, arr) { return arr.indexOf(t) === i; }); // dedupe

                if (terms.length >= 2) {
                    // Filter dataSource to matching records (exact match on name or uuid)
                    var matchedRecords = fullDataSource.filter(function(bin) {
                        var binName = (bin.name || '').toLowerCase();
                        var binUUID = (bin.uuid || '').toLowerCase();
                        return terms.some(function(term) {
                            return binName === term || binUUID === term;
                        });
                    });

                    // Track which terms matched (check both name AND uuid)
                    var matchedTerms = new Set();
                    matchedRecords.forEach(function(bin) {
                        var binName = (bin.name || '').toLowerCase();
                        var binUUID = (bin.uuid || '').toLowerCase();
                        terms.forEach(function(t) {
                            if (binName === t || binUUID === t) matchedTerms.add(t);
                        });
                    });

                    var notFound = terms.filter(function(t) { return !matchedTerms.has(t); });

                    // Update grid with filtered data + auto-select all
                    backstockGrid.grid.dataSource = matchedRecords;
                    backstockGrid.clearSelection();

                    // Select all visible rows after grid renders
                    setTimeout(function() {
                        var indexes = [];
                        for (var i = 0; i < matchedRecords.length; i++) indexes.push(i);
                        if (indexes.length > 0) backstockGrid.grid.selectRows(indexes);
                    }, 100);

                    if (notFound.length > 0) {
                        showCommaSearchBanner(matchedTerms.size, terms.length, notFound);
                    }
                    return;
                }
            }

            // Single-term mode: use Grid's built-in search
            if (fullDataSource) {
                backstockGrid.grid.dataSource = fullDataSource; // Restore full data
            }
            backstockGrid.grid.search(searchVal);
        }, 300);
    });
}
```

## Runtime View

### Primary Flow: Mass Action Execution

1. User selects bins via checkbox click/shift+click/ctrl+click/header-checkbox-select-all/comma-search
2. Syncfusion Grid tracks selections natively via `persistSelection: true`, fires `rowSelected`/`rowDeselected` events which update FloatingActionBar count
3. User clicks action button on FloatingActionBar (e.g., "Change Location") — `grid.getSelectedRecords()` provides bin IDs
4. MassEditModal opens with action-specific form and scrollable bin list
5. User fills form (e.g., selects target location) and clicks Confirm
6. Confirm button disables immediately with spinner (double-submit protection)
7. AJAX POST to `/api/:typeNum/backstock/mass/{action}` with bin IDs + form params
8. Server: validates inputs, loops bins in transaction, logs actions, returns BatchResult
9. Client: processes response — updates table rows in-place, shows toast, handles selection
10. On partial failure: toast shows "X of Y updated. Z failed.", failed bins remain selected

```mermaid
sequenceDiagram
    actor User
    participant Grid as Syncfusion Grid
    participant FAB as FloatingActionBar
    participant Modal as MassEditModal
    participant API as Mass Edit API
    participant Factory as BackstockFactory
    participant DB as Store Database

    User->>Grid: Select rows (checkbox click/shift/ctrl)
    Grid->>Grid: persistSelection tracks across pages
    Grid->>FAB: rowSelected/rowDeselected → update(count)
    FAB-->>User: Show "X bins selected" + action buttons

    User->>FAB: Click "Change Location"
    FAB->>Grid: getSelectedRecords()
    FAB->>Modal: open(actionType, selectedIds)
    Modal-->>User: Show location dropdown + bin list

    User->>Modal: Select location, click Confirm
    Modal->>Modal: Disable button, show spinner
    Modal->>API: POST /mass/change-location
    Note over Modal,API: {binIds, locationId, userId}

    API->>Factory: massChangeLocation(binIds, locationId, userId)
    Factory->>DB: BEGIN TRANSACTION
    loop For each binId
        Factory->>DB: Read bin, update location, log action
    end
    Factory->>DB: COMMIT
    Factory-->>API: BatchResult

    API-->>Modal: JSON response
    Modal->>Grid: setRowData() for each updated bin
    Modal->>Grid: clearSelection() or selective deselect
    Modal-->>User: Success toast, close modal
```

### Flow: Comma-Separated Search

```mermaid
sequenceDiagram
    actor User
    participant Search as Search Input
    participant CS as CommaSearch
    participant Grid as Syncfusion Grid
    participant FAB as FloatingActionBar

    User->>Search: Paste "BIN-001, BIN-042, BIN-103"
    Search->>CS: input event (debounced 300ms)
    CS->>CS: Detect commas, split, trim, dedupe
    CS->>Grid: clearSelection()
    CS->>Grid: Set filtered dataSource (exact-match records)
    CS->>Grid: selectRows(all matched indexes)
    Grid->>FAB: rowSelected events → update(matchedCount)
    CS-->>User: Show "Found 2 of 3. Not found: BIN-103"
```

### Flow: Select All (Filtered)

```mermaid
sequenceDiagram
    actor User
    participant CB as Grid Header Checkbox
    participant Grid as Syncfusion Grid
    participant FAB as FloatingActionBar

    User->>CB: Click Select All checkbox (native Grid feature)
    CB->>Grid: selectAllRows() (built-in with persistSelection)
    Note over Grid: Selects ALL filtered rows<br/>across ALL pages automatically
    Grid->>FAB: rowSelected events → update(totalFilteredCount)
    FAB-->>User: "X bins selected"

    User->>CB: Click again (deselect all)
    CB->>Grid: clearSelection()
    Grid->>FAB: rowDeselected events → update(0)
    FAB-->>User: Bar slides down
```

### Error Handling

- **Invalid input (empty binIds, missing params)**: HTTP 400, `{ success: false, message: "binIds is required" }`. Modal re-enables confirm button. User can fix and retry.

- **Invalid location/category ID**: HTTP 400, `{ success: false, message: "Location not found" }`. Factory validates before starting loop. No partial processing occurs.

- **Bin deleted mid-operation**: Bin added to `failed[]` array, loop continues. Transaction commits. Response includes failed IDs. Client shows partial success toast.

- **Database error mid-transaction**: Transaction rolled back. HTTP 500, `{ success: false, message: "Database error. No bins were updated." }`. Client shows error toast, selection preserved.

- **Network timeout**: jQuery AJAX error handler fires. Error toast: "Request timed out. Please check which bins were updated and try again." Selection preserved. Confirm button re-enabled.

- **Permission denied (wrong store)**: `checkStoreGroup()` returns false before any processing. HTTP 403. Client shows error toast.

- **Concurrent modification**: Last write wins (same as existing single-bin behavior). Both users' actions are logged individually.

## Analytics Event Instrumentation

Maps PRD tracking events to implementation emit points.

```yaml
# Frontend Events (emitted via JS, logged to console / analytics endpoint)

- event: mass_action_initiated
  emit_point: MassEditModals.submitMassAction() — before AJAX call
  properties:
    action_type: string (from action parameter)
    bin_count: int (binIds.length)
    store_typeNum: string (from page context)
    user_id: int (from page context — $app->user->id rendered in Twig)

- event: mass_action_completed
  emit_point: MassEditModals.handleMassActionResponse() — on success or partial success
  properties:
    action_type: string
    bin_count: int (total attempted)
    success_count: int (response.results.updated.length)
    failure_count: int (response.results.failed.length)
    duration_ms: int (Date.now() - requestStartTime)

- event: mass_action_cancelled
  emit_point: Modal close via Cancel button or backdrop click
  properties:
    action_type: string
    bin_count: int (binIds passed to modal)
    stage: string ("modal" if cancelled before confirm, "confirm" if cancelled during loading)

- event: comma_search_used
  emit_point: CommaSearch handler — after filtering completes (debounced)
  properties:
    term_count: int (terms.length)
    found_count: int (matchedRecords.length)
    not_found_count: int (notFound.length)

- event: select_all_used
  emit_point: Grid checkboxChange event when header checkbox is clicked
  properties:
    filtered_count: int (grid.getFilteredRecords().length or grid.currentViewData.length)
    total_count: int (fullDataSource.length)
    had_active_filters: boolean (any filter active)

- event: floating_bar_action_clicked
  emit_point: FloatingActionBar button click handlers
  properties:
    action_type: string (button action name)
    bin_count: int (current selection count)
```

**Implementation Note**: Events are emitted client-side via a lightweight `trackEvent(name, properties)` utility function. Initial implementation logs to `console.log` for dev/QA verification. Production analytics integration (Mixpanel, Amplitude, etc.) deferred to a future sprint — the event emit points and property contracts are established here for easy hookup.

## Deployment View

No change to existing deployment. This feature adds new PHP routes, new JS/HTML/CSS, and no schema changes. Standard deployment via existing pipeline.

- **Environment**: Same PHP 8.x server + MariaDB + Redis stack
- **Configuration**: No new env vars or settings
- **Dependencies**: No new PHP packages or JS libraries
- **Performance**: Synchronous batch processing. Expected < 5s for 100 bins, < 15s for 500 bins.

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns reused
- pattern: Transaction-wrapped batch operations (BackstockFactory::bulkCreateBins)
  relevance: CRITICAL
  why: "Establishes the pattern for mass operations with transaction safety"

- pattern: Individual action logging per entity (Action::create)
  relevance: HIGH
  why: "Each mass-edited bin gets its own action log entry for audit trail"

- pattern: In-place table row updates (manageBin.js::updateTableRow)
  relevance: HIGH
  why: "Reused for refreshing each updated row after mass operation"

- pattern: SweetAlert notifications (swal)
  relevance: MEDIUM
  why: "Success/error/partial-success toast notifications"

# New patterns created
- pattern: Floating Action Bar
  relevance: HIGH
  why: "New reusable UI component for multi-select + batch action workflows"

- pattern: Syncfusion Grid with Floating Action Bar
  relevance: HIGH
  why: "Grid native persistSelection + getSelectedRecords() → FloatingActionBar update. Proven by TeamMemberGrid.js pattern."

- pattern: Comma-separated search with auto-select
  relevance: MEDIUM
  why: "Grid dataSource filtering + selectRows() combining filtering + automatic selection"
```

### System-Wide Patterns

- **Security**: Session-based auth (UserFrosting session cookie). Store access via `checkStoreGroup($typeNum)`. No new permission URIs. User ID from `$app->user->id` (server-side session), never from client request payload.

- **Error Handling**: Backend: try/catch with transaction rollback on fatal errors. Per-bin try/catch within the loop to allow partial success. Return structured JSON with `success`, `message`, and `results` arrays. Frontend: jQuery AJAX error handler + response success check.

- **Performance**: Batch DB operations within single transaction. Pre-load lookup arrays once via `prepareLookupArrays()` before loop. `makeBinReadable()` called once per updated bin with cached lookups. No N+1 query problems.

- **Logging/Auditing**: One `bsActions` row per bin per operation. `employeeID` = `$app->user->id` (from `kiosk_users.users`). Individual entries enable per-bin history and accountability.

### Implementation Patterns

#### Code Patterns and Conventions

- **Route naming**: `POST /api/:typeNum/backstock/mass/{action-name}` — kebab-case action names
- **Factory methods**: `mass{ActionName}(array $binIds, ...params, int $userId): array` — camelCase, return associative arrays
- **Response format**: `{ success: bool, message: string, results: { updated: [], skipped: [], failed: [] }, bins: [] }`
- **JS organization**: 2 new files: `floatingBar.js`, `massEditModals.js` + major refactor of `main.js` (DataTable → Syncfusion Grid). Include order: `main.js` (Grid init) -> `floatingBar.js` -> `massEditModals.js`. Each attaches to `window` for cross-file access. No separate `selectionManager.js` needed — Syncfusion Grid handles selection natively.
- **Modal IDs**: `#massEdit{Action}Modal` (e.g., `#massEditLocationModal`)

#### State Management Patterns

- **Selection state**: Syncfusion Grid's native selection engine — source of truth for which bins are selected. Access via `grid.getSelectedRecords()` and `grid.getSelectedRowIndexes()`.
- **Selection persistence**: `persistSelection: true` on the Grid config tells Syncfusion to maintain selections across page changes natively. No custom JS Set or draw-event restoration needed. Requires a primary key column (`field: 'id', isPrimaryKey: true`).
- **Selection clearing**: Triggered by filter changes (location dropdown change, category dropdown change, search bar clear, hidden toggle). Call `grid.clearSelection()`. Exception: comma-search sets filter AND selection atomically (not treated as filter change).
- **Select-all behavior**: Syncfusion Grid's header checkbox with `selectionSettings.type: 'Multiple'` and `persistSelection: true` selects ALL rows matching current filters across ALL pages (not just visible page). This is the native behavior when `checkboxOnly: true` is set. Verification: `grid.getSelectedRecords()` returns the full set regardless of current page.
- **Selection lifecycle reset**: Selection state lives in the Grid's in-memory JS state. It is NOT persisted to localStorage, sessionStorage, or server. Page refresh, browser navigation, or closing the tab resets selection to empty. This matches PRD requirement for clearing on navigation/refresh.
- **Floating bar visibility**: Driven by `grid.getSelectedRecords().length`. `> 0` -> slide up. `=== 0` -> slide down. CSS transition for animation. Updated via Grid's `rowSelected`/`rowDeselected` events.
- **Modal state**: Each modal manages its own form state. On open: populate form + bin list. On close: reset. No persistent modal state.

#### Performance Characteristics

- **Backend batch size**: No hard limit. Practical limit ~500 bins per request (synchronous). Stores with 500+ bins may experience 10-15s response times with loading spinner.
- **Transaction scope**: Single DB transaction wraps entire batch. Commit on success, rollback only on fatal exceptions (not per-bin failures).
- **Lookup caching**: `prepareLookupArrays()` loads locations and categories once before the loop. Prevents N queries for N bins.
- **Response payload**: Only returns updated bin objects (not all bins). Client updates individual rows via Grid's `setRowData()` or dataSource manipulation.
- **Grid rendering**: Syncfusion Grid handles virtual scrolling and pagination natively. `persistSelection` uses primary key index for O(1) selection lookups.
- **Debouncing**: Comma search input debounced at 300ms.

#### Component Structure Pattern

```pseudocode
COMPONENT: BackstockGrid (main.js — Syncfusion EJ2 Grid)
  STATE: grid (ej.grids.Grid instance), floatingBar (ref), fullDataSource (cached for comma search restore)
  INIT: new ej.grids.Grid({ persistSelection: true, selectionSettings: { type: 'Multiple', checkboxOnly: true } })
  EVENTS: grid.rowSelected → updateFloatingBar(), grid.rowDeselected → updateFloatingBar()
  METHODS:
    getSelectedIds() → grid.getSelectedRecords().map(r => r.id)
    getSelectionBreakdown() → { active: count, hidden: count, total: count }
    clearSelection() → grid.clearSelection()
    deselectIds(ids[]) → selective row deselection for partial success
    updateFloatingBar() → floatingBar.update(count, breakdown)

COMPONENT: FloatingActionBar(backstockGrid)
  STATE: visible (bool), count (int), breakdown { active: int, hidden: int }
  RENDER:
    IF count > 0: slide up, show "{count} bins selected" + action buttons
    IF count === 0: slide down (hidden)
    IF breakdown.active > 0 AND breakdown.hidden > 0: show split "Hide {active}" / "Unhide {hidden}"
    IF breakdown.active > 0 AND breakdown.hidden === 0: show "Hide Selected"
    IF breakdown.active === 0 AND breakdown.hidden > 0: show "Unhide Selected"
  METHODS: update(count, breakdown), show(), hide()

COMPONENT: MassEditModals
  METHODS:
    openLocationModal(binIds[])
    openEmptyModal(binIds[])
    openCategoryModal(binIds[])
    openTagsModal(binIds[])
    openHideModal(binIds[])
    openUnhideModal(binIds[])
    openPrintModal(binIds[])
    openAgeDateModal(binIds[])
    openNotesModal(binIds[])
    submitMassAction(actionType, url, payload, binIds[])
    handleMassActionResponse(response, binIds[])
```

#### Data Processing Pattern

```pseudocode
FUNCTION: massAction(binIds[], ...params, userId)
  VALIDATE: binIds not empty, action-specific params valid
  PRE_LOAD: prepareLookupArrays()

  result = { updated: [], skipped: [], failed: [], bins: [] }

  BEGIN_TRANSACTION:
    FOR EACH binId IN binIds:
      TRY:
        bin = Bin.readByID(binId)
        IF not found -> result.failed.push(binId), CONTINUE
        IF skip_condition_met -> result.skipped.push(binId), CONTINUE

        APPLY_CHANGE (action-specific)
        LOG_ACTION: Action.create(binId, userId, actionType, categoryId)
        REFRESH: makeBinReadable(bin)

        result.updated.push(binId)
        result.bins.push(readableBin)
      CATCH:
        result.failed.push(binId), CONTINUE
    END FOR
  COMMIT_TRANSACTION

  RETURN result
```

#### Error Handling Pattern

```pseudocode
# Route-level error handling
FUNCTION: handleMassEditRoute(request)
  TRY:
    VALIDATE_INPUT: parse JSON, check binIds array, check required fields
    IF invalid -> HTTP 400, { success: false, message: "..." }

    CHECK_PERMISSION: checkStoreGroup(typeNum)
    IF denied -> HTTP 403, { success: false, message: "Unauthorized" }

    result = factory.massAction(binIds, params, userId)

    IF result.updated empty AND result.failed not empty:
      HTTP 200, { success: false, message: "No bins updated", results: result }
    ELSE:
      HTTP 200, { success: true, message: buildMessage(result), results: result, bins: result.bins }

  CATCH InvalidArgumentException:
    HTTP 400, { success: false, message: exception.message }
  CATCH PDOException:
    HTTP 500, { success: false, message: "Database error. No bins were updated." }
  CATCH Exception:
    HTTP 500, { success: false, message: "An unexpected error occurred." }

# Frontend response handling
FUNCTION: handleMassActionResponse(response, xhr, binIds)
  IF xhr error (timeout, 500, etc.):
    showErrorToast("Request failed. Please try again.")
    RE_ENABLE confirm button, KEEP selection
    RETURN

  IF response.success === false:
    showErrorToast(response.message)
    RE_ENABLE confirm button, KEEP selection
    RETURN

  IF response.results.failed.length > 0:
    showPartialSuccessToast(response)
    UPDATE Grid rows for successful bins (setRowData per bin)
    DESELECT successful bin IDs via backstockGrid.deselectIds(), KEEP failed selected
    CLOSE modal
    RETURN

  showSuccessToast(response.message)
  UPDATE Grid rows for all bins (setRowData per bin)
  CLEAR selection via backstockGrid.clearSelection()
  CLOSE modal
```

#### Test Pattern

```pseudocode
TEST_SCENARIO: "massChangeLocation updates all bins to target location"
  SETUP: 3 bins at location A, target location B exists, user ID 10
  EXECUTE: factory.massChangeLocation([1, 2, 3], locationB.id, 10)
  VERIFY:
    result.updated == [1, 2, 3], skipped == [], failed == []
    All 3 bins now at locationB, 3 bsActions rows created
    result.bins has 3 readable bin objects

TEST_SCENARIO: "massChangeLocation skips already-at-target bins"
  SETUP: Bin 1 at location A, Bin 2 already at location B (target)
  EXECUTE: factory.massChangeLocation([1, 2], locationB.id, 10)
  VERIFY:
    result.updated == [1], result.skipped == [2]
    Only 1 bsActions row created

TEST_SCENARIO: "massChangeLocation handles deleted bin"
  SETUP: Bin 1 exists, Bin 999 does not
  EXECUTE: factory.massChangeLocation([1, 999], locationB.id, 10)
  VERIFY:
    result.updated == [1], result.failed == [999]
    Transaction committed (partial success)

TEST_SCENARIO: "massEmptyBins resets all bin fields"
  SETUP: Bin with mainCategory, tags, notes, itemCount, estimatedValue
  EXECUTE: factory.massEmptyBins([binId], userId)
  VERIFY:
    bin.mainCategory == null, bsBin_Cat entries cleared
    ageDate reset to today, action=0 logged

TEST_SCENARIO: "massChangeTags add mode appends without replacing"
  SETUP: Bin with existing tags [A, B], new tags [C]
  EXECUTE: factory.massChangeTags([binId], "add", [tagC.id], ["custom"], userId)
  VERIFY:
    Bin now has tags [A, B, C], original tags preserved

TEST_SCENARIO: "massHideBins returns updated hidden count"
  SETUP: Store has 10 hidden bins, 3 active bins selected
  EXECUTE: factory.massHideBins([1, 2, 3], userId)
  VERIFY:
    3 bins now active=0, response.hiddenCount == 13
```

### Integration Points

- **Syncfusion Grid Selection**: Native `persistSelection: true` with `type: 'Multiple'` and `checkboxOnly: true` handles all row selection mechanics. Grid fires `rowSelected`/`rowDeselected` events that wire to the FloatingActionBar. `getSelectedRecords()` provides the selected bin data array.
- **Existing column filters**: Location, On-Site, Category dropdown filters trigger `backstockGrid.clearSelection()` on change event. Filters reimplemented via Grid column filtering or external filter controls.
- **Hidden bin toggle**: When toggled, clears selection. Hidden bins loaded via AJAX integrate with Grid dataSource for tracking.
- **Existing `updateTableRow()`**: The function in `manageBin.js` is adapted for Syncfusion Grid — uses `grid.setRowData(id, updatedData)` or dataSource manipulation instead of DataTable `row().data()`.
- **Redis/Print service**: `massPrintLabels` reuses `BackstockFactory::createReprintJob()` — passes array of `{id, quantity}` objects, returns jobUUID.

## Architecture Decisions

- [x] ADR-1 **Separate endpoint per action type** (vs. single generic mass-update endpoint)
  - Rationale: Each mass action has different parameters (locationId, categoryId+type, tagIds+mode, ageDate, note text, quantity). A single endpoint would require complex conditional validation and a large switch/case. Separate endpoints are explicit, self-documenting, and easier to test independently.
  - Trade-offs: More route definitions (9 endpoints vs 1), but each is simple and follows the same pattern. No added maintenance burden since they share a response format.
  - User confirmed: **Yes**

- [x] ADR-2 **Multiple JS files** (user preference, revised for Syncfusion Grid)
  - Choice: 2 new files: `floatingBar.js`, `massEditModals.js` + major refactor of existing `main.js` (DataTable → Syncfusion Grid)
  - Rationale: Cleaner separation of concerns. Each file has a single responsibility. `selectionManager.js` is no longer needed — Syncfusion Grid's native `persistSelection` + `getSelectedRecords()` replaces the custom SelectionManager class entirely. The Grid init and selection logic live in `main.js` (following `TeamMemberGrid.js` pattern).
  - Trade-offs: Requires careful `{% include %}` ordering in `home.html` (main.js first for Grid init, then floatingBar, then massEditModals). No bundler — order matters. Each file must use global scope or attach to `window`.
  - Include Order: `main.js` (Grid + selection) -> `floatingBar.js` -> `massEditModals.js`
  - User confirmed: **Yes** (original 3-file preference; revised to 2 new files due to Syncfusion Grid eliminating SelectionManager)

- [x] ADR-3 **Client-side comma search filtering** (vs. server-side search endpoint)
  - Rationale: All bin data is already loaded in the Syncfusion Grid dataSource (server-side rendered via Twig, then passed to Grid). Client-side exact-match filtering via dataSource manipulation is instantaneous and requires no new API endpoint.
  - Trade-offs: Won't scale if bin rendering moves to server-side pagination. Currently all bins are loaded client-side, so this is fine for stores with < 1000 bins.
  - User confirmed: **Yes**

- [x] ADR-4 **No database schema changes**
  - Rationale: All mass edit operations map to existing columns and tables. bsActions already supports per-bin logging. bsBins has all needed fields. bsBin_Cat handles tag associations. No migration risk.
  - Trade-offs: No dedicated "batch ID" to group actions from a single mass operation. If future requirements need "undo batch" or "batch history", a batch_id column would be needed — deferred per PRD "Won't Have" section.
  - User confirmed: **Yes**

- [x] ADR-5 **Bootstrap 5 modals for mass edit** (not SweetAlert)
  - Rationale: Mass edit modals need complex content (Syncfusion dropdowns, multiselects, scrollable bin lists, split hide/unhide buttons). SweetAlert is designed for simple confirm/cancel dialogs and doesn't support embedding Syncfusion components. Bootstrap 5 modals support arbitrary HTML and match existing manage bin modal pattern.
  - Trade-offs: More HTML to write vs SweetAlert one-liners. But SweetAlert literally cannot do what we need. Success/error feedback continues to use `swal()` toasts.
  - User confirmed: **Yes**

- [x] ADR-6 **Selection cleared on filter change** (except comma search)
  - Rationale: If a user filters by Location A, selects bins, then switches to Location B, the selected bins from A are no longer visible. Acting on invisible selections leads to confusion and errors. Clearing on filter change prevents stale/invisible selections.
  - Trade-offs: User loses selection if they accidentally change a filter. Mitigated by clear "Selection cleared" feedback.
  - Exception: Comma search sets filter AND selection atomically — treated as "find and select", not a filter change.
  - User confirmed: **Yes**

- [x] ADR-7 **Migrate backstock overview from DataTables to Syncfusion EJ2 Grid**
  - Rationale: Mass edit requires checkbox selection, cross-page selection persistence, and select-all — features that would require significant custom JavaScript with DataTables but are native to Syncfusion Grid. The codebase already has `TeamMemberGrid.js` (1495 lines) as a proven blueprint with checkbox selection, `persistSelection: true`, bulk actions, custom column templates, and filter chips. Migrating eliminates the need for a custom `SelectionManager` class (~100 lines of complex event handling) and provides Excel-like column filtering, better sorting UX, and built-in search.
  - Trade-offs: Larger migration scope (refactoring `main.js` DataTable init + adapting `updateTableRow()` in `manageBin.js`). Any existing code that references the DataTable API (e.g., `$('#binsTable').DataTable()`) must be updated to use Syncfusion Grid API. However, the migration pattern is well-established in the codebase.
  - Alternatives Considered: (1) Keep DataTables + custom SelectionManager — requires ~200 lines of custom JS for selection tracking, checkbox rendering, page-change restoration. (2) Use DataTables Select extension checkboxes — limited native support, still requires custom cross-page persistence.
  - User confirmed: **Yes**

## Quality Requirements

- **Performance**: Mass edit API requests complete in < 5s for up to 100 bins, < 15s for up to 500 bins. Floating bar appears/disappears within 200ms of selection change. Comma search filtering completes within 500ms for 100 terms.

- **Usability**: Confirmation modal always shows scrollable list of affected bin names before execution. Double-submit prevention on all confirm buttons (disable + spinner on first click). Clear count display in floating bar. Partial failure reporting with specific failed bin names.

- **Security**: All endpoints validate store access via `checkStoreGroup($typeNum)`. User ID sourced from server-side session (`$app->user->id`), never from client. All SQL uses prepared statements with named parameters.

- **Reliability**: Transaction wrapping ensures database consistency. Per-bin try/catch allows partial success without aborting the batch. Partial failures reported to user with specific bin IDs. Selection preserved on failure for retry. No data loss on network timeout.

- **Testability**: All batch methods in BackstockFactory are unit-testable with mock PDO (existing PdoMockBuilder pattern). Frontend testable via browser automation at dev2.buyerkiosk.com. Each endpoint independently testable.

## Risks and Technical Debt

### Known Technical Issues

- **Syncfusion Grid requires `isPrimaryKey` column**: `persistSelection` only works when a column has `isPrimaryKey: true`. The `id` field must be included (can be hidden with `visible: false`). Without this, selection state is lost on page change.
- **`updateTableRow()` adaptation**: The existing function in `manageBin.js` uses DataTable API (`row().data()`). Must be adapted to use Syncfusion Grid's `setRowData(primaryKey, updatedRowData)` or direct dataSource manipulation + `grid.refresh()`.
- **Dynamically-added hidden bin rows**: When hidden bins are loaded via AJAX toggle, they must be appended to the Grid's dataSource (not injected as DOM rows). Syncfusion Grid manages its own DOM rendering.
- **Syncfusion component init in modals**: Syncfusion EJ2 components (dropdowns, multiselects) MUST be initialized when the parent modal is visible. Use Bootstrap `shown.bs.modal` event. Destroy + recreate on each open (MEMORY.md documented gotcha).
- **Existing column filter migration**: DataTable column-specific search filters (location dropdown, on-site dropdown, category dropdown) must be reimplemented as either Syncfusion Grid column filters (`filterSettings.type: 'Excel'`) or as external filter controls that manipulate Grid dataSource/query.

### Technical Debt

- Existing `save-all` route handler in `backstock.php` (lines 102-253) is a 150-line inline closure. New mass edit routes should NOT replicate this anti-pattern — they should delegate to BackstockFactory methods and be < 30 lines each.
- `makeBinReadable()` queries the latest action per bin inside the method. For mass operations returning 100+ bins, this means 100+ individual queries. Acceptable for v1 but could be optimized with a batch query in future.

### Implementation Gotchas

- **PDO named parameter reuse**: Cannot reuse `:paramName` in a single prepared statement (`HY093` error). Use unique names (`:param1`, `:param2`) bound to the same value. This error is silently caught in try/catch, making it hard to diagnose.
- **`Bin::createFromRow()` requires `DATE(ageDate) as dateNoTime`**: Any SELECT feeding into `createFromRow()` must include this alias. Mass query methods must include it.
- **`makeBinReadable()` mutates `$bin->mainCategory`**: After calling it, `mainCategory` becomes an array `['id'=>..., 'name'=>..., 'color'=>...]`, not the raw int. Don't compare against int after.
- **MariaDB strict mode**: `INT <> ''` triggers `1292 Truncated incorrect DECIMAL value`. Use `> 0` for INT columns.
- **Filter change detection**: Must hook into ALL existing filter mechanisms (location dropdown `.change`, category dropdown `.change`, search input, hidden toggle `.change`, column sorts) to trigger `clearSelection()`. Missing one creates stale selection bugs.
- **Syncfusion component init in modals**: Syncfusion components MUST be initialized when the modal is visible (not before). Use Bootstrap modal `shown.bs.modal` event to init Syncfusion dropdowns/multiselects inside mass edit modals. Destroy + recreate on each open.

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Mass Change Location — Happy Path**
```gherkin
Given: 3 bins (IDs 1, 2, 3) at Location "Back Room"
And: Location "Storage Unit 111" exists
When: User selects all 3 bins and chooses "Change Location" -> "Storage Unit 111"
Then: All 3 bins updated to "Storage Unit 111"
And: 3 bsActions rows created with employeeID = logged-in user
And: Table rows update in-place showing "Storage Unit 111"
And: Success toast: "3 bins moved to Storage Unit 111"
And: Selection is cleared
```

**Scenario 2: Mass Empty — Partial Skip**
```gherkin
Given: Bin 1 has category "Electronics", Bin 2 is already empty
When: User selects both and clicks "Empty Bins"
Then: Bin 1 is emptied (mainCategory=null, tags cleared, ageDate reset)
And: Bin 2 is skipped (already empty)
And: 1 bsActions row created (for Bin 1 only)
And: Toast: "1 bin emptied (1 already empty, skipped)"
```

**Scenario 3: Partial Failure — Deleted Bin**
```gherkin
Given: Bins 1, 2, 3 selected. Another user deletes Bin 2 before confirm.
When: User confirms mass location change
Then: Bins 1, 3 updated successfully
And: Bin 2 reported as failed
And: Toast: "2 of 3 bins updated. 1 failed."
And: Bin 2 remains selected, Bins 1, 3 deselected
```

**Scenario 4: Comma Search — Mixed Results**
```gherkin
Given: Bins "BIN-001", "BIN-002", "BIN-003" exist. "BIN-999" does not.
When: User types "BIN-001, BIN-002, BIN-999" in search bar
Then: Table filters to show BIN-001 and BIN-002 only
And: Both are auto-selected
And: Banner: "Found 2 of 3 bins. Not found: BIN-999"
And: Floating bar: "2 bins selected"
```

**Scenario 5: Select All Filtered**
```gherkin
Given: 80 bins at "Back Room", 20 at "Storage Unit"
When: User filters by Location = "Back Room", clicks Select All
Then: All 80 filtered bins selected across all pages
And: Floating bar: "80 bins selected"
```

**Scenario 6: Selection Cleared on Filter Change**
```gherkin
Given: 5 bins selected via clicking
When: User changes Location dropdown filter
Then: Selection cleared (0 selected), floating bar hidden
```

**Scenario 7: Mixed Hide/Unhide Split Action**
```gherkin
Given: 5 active bins + 3 hidden bins selected (Show Hidden toggle on)
Then: Floating bar shows "Hide 5 Active" and "Unhide 3 Hidden" buttons
When: User clicks "Hide 5 Active"
Then: Confirmation modal shows only the 5 active bins
And: On confirm, 5 bins hidden. 3 hidden bins unaffected.
```

**Scenario 8: Double-Submit Prevention**
```gherkin
Given: User opens Change Location modal, selects location
When: User clicks Confirm
Then: Button disables, spinner appears
And: Clicking again has no effect
And: Only one AJAX request is sent
```

### Test Coverage Requirements

- **Business Logic**: All 9 mass action factory methods — happy path, skip condition, failure condition, empty input array, invalid params
- **User Interface**: Syncfusion Grid selection tracking across page changes (persistSelection), floating bar visibility and button states, comma search filter + auto-select, header checkbox select-all across pages, filter-change selection clearing, modal form population and submission
- **Integration Points**: AJAX request/response handling, in-place Grid row updates via `grid.setRowData()`, SweetAlert toast display, Syncfusion component init in modals
- **Edge Cases**: 0 bins selected (buttons disabled), large batch (500 bins), bin deleted between select and confirm, network timeout, same bin in comma search and manual select (no duplicates)
- **Security**: Store access validation via `checkStoreGroup()`, user ID from session not client

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Bin | A physical storage container identified by name and 8-char UUID barcode | The unit being mass-edited. Stored in `bsBins` table. |
| Main Category | Primary classification of bin contents from POS or custom categories | `bsBins.mainCategory` column (INT). Set via mass change category. |
| Tags | Additional category labels on a bin beyond the main category | `bsBin_Cat` junction table entries. Added/removed via mass change tags. |
| Location | Physical storage area (e.g., "Back Room", "Storage Unit 111") | `bsLocations` table. Changed via mass change location. |
| Age Date | Timestamp indicating when bin contents were last rotated/reset | `bsBins.ageDate`. Reset by mass empty, updated by mass update age date. |
| Hidden Bin | Bin with `active=0`, excluded from default view | Changed via mass hide/unhide. |
| Action Log | Record of bin operations for audit trail | `bsActions` table. One entry per bin per mass operation. |
| Empty Bin | Bin with no main category assignment | `Bin::isEmpty()` checks this. Mass empty resets all bin fields. |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| BatchResult | Associative array `['updated'=>[], 'skipped'=>[], 'failed'=>[], 'bins'=>[]]` | Return type of all mass edit factory methods. |
| BackstockGrid | JS module wrapping the Syncfusion EJ2 Grid instance with selection helpers | Main frontend component in `main.js`. Replaces DataTable. |
| FloatingActionBar | Fixed-position bottom bar showing selection count and action buttons | Slides up when 1+ bins selected. New UI component. |
| CommaSearch | Search mode detecting comma-separated input for multi-term exact matching | Enhancement to Grid search, filters dataSource + auto-selects matches. |
| persistSelection | Syncfusion Grid config option that maintains checkbox selections across pages | Requires `isPrimaryKey: true` column. Eliminates need for custom SelectionManager. |
| typeNum | Store identifier pattern `[a-z]{2}\d+` (e.g., "ou00", "pc00") | Used in API URL paths and store database scoping. |
| makeBinReadable | Factory method transforming raw DB bin into display-ready object | Converts IDs to names/colors, formats dates. Called per updated bin. |
| prepareLookupArrays | Factory method pre-loading location and category caches | Called once before batch loop to prevent N+1 queries. |

### API Terms

| Term | Definition | Context |
|------|------------|---------|
| `/mass/{action}` | URL pattern for all mass edit endpoints | 9 endpoints under `/api/:typeNum/backstock/mass/` |
| Partial Success | HTTP 200, `success: true`, non-empty `failed[]` | Some bins succeeded, some failed. Updated bins in `bins[]`. |
| Full Failure | HTTP 200, `success: false`, all bins in `failed[]` | No bins could be updated. Selection preserved for retry. |
| Skip | Bin in target state already (e.g., already at location) | Not counted as success or failure. Reported in `skipped[]`. |
