Implement a redirect action for completed Ivanti queue items. The feature adds a `POST /api/ivanti/todo-queue/:id/redirect` endpoint to the existing route module, fixes the PUT validation message, creates a RedirectModal frontend component, and wires a redirect button into the QueuePanel for completed items. Tasks are ordered: backend bug fix, backend endpoint, frontend modal, frontend integration, with property tests alongside each layer.
Additionally, GRANITE is added as a fourth workflow type across the entire stack — backend validation, RedirectModal, QueuePanel grouping (Inventory section), and AddToQueue popover.
- Validate `workflow_type` against existing `VALID_WORKFLOW_TYPES` constant
- For FP/Archer: validate vendor using existing `isValidVendor()` helper; also check length ≤ 200
- For CARD: accept without vendor
- Fetch original item with `db.get()` scoped to `req.user.id`; return 404 if not found
- Return 400 if original item status is not `"complete"`
- INSERT new row copying finding_id, finding_title, cves_json, ip_address, hostname from original; set status `"pending"`, workflow_type and vendor from request body
- Fetch the inserted row, parse cves_json, return 201 with the new item
- [ ] 7.5 Update all error messages across all endpoints
- Change `"workflow_type must be FP, Archer, or CARD."` to `"workflow_type must be FP, Archer, CARD, or GRANITE."` in POST `/`, POST `/batch`, PUT `/:id`, and POST `/:id/redirect`
- _Requirements: 5.1, 6.3_
- [ ] 8. Add GRANITE to RedirectModal
- [ ] 8.1 Update `WORKFLOW_OPTIONS` in `frontend/src/components/RedirectModal.js`
- Add `{ key: 'GRANITE', label: 'GRANITE', col: '#A1887F', rgb: '161,136,127' }` as the fourth option
- Vendor field already hidden for non-FP/Archer types via `needsVendor` check — no change needed there
- _Requirements: 4.1, 4.3_
- [ ] 9. Update QueuePanel grouping for Inventory section
- [ ] 9.1 Update the `grouped` useMemo in QueuePanel (`frontend/src/components/pages/ReportingPage.js`)
- Change `items.filter((i) => i.workflow_type === 'CARD')` to filter both CARD and GRANITE into inventory items
- Split inventory items into `cardItems` and `graniteItems` sub-arrays
- Change `otherItems` filter from `i.workflow_type !== 'CARD'` to exclude both CARD and GRANITE
- Rename group key from `__CARD__` to `__INVENTORY__`, label from `'CARD'` to `'Inventory'`, and `isCard` to `isInventory`
- Include `cardItems` and `graniteItems` as separate properties on the inventory group object
- _Requirements: 7.1, 7.5_
- [ ] 9.2 Update the QueuePanel rendering to handle the Inventory section
- Update the `.map()` destructuring from `isCard` to `isInventory`
- Update group header border and label color to use `isInventory` instead of `isCard`
- For the Inventory group, render CARD items first, then a subtle sub-divider (only when both `cardItems.length > 0` and `graniteItems.length > 0`), then GRANITE items
- _Requirements: 7.1, 7.2, 7.5_
- [ ] 9.3 Update the workflow type color mapping in QueuePanel item rendering
- Add GRANITE to the `wfColor` ternary: `item.workflow_type === 'GRANITE' ? { col: '#A1887F', rgb: '161,136,127' }` before the default CARD fallback
- _Requirements: 7.3, 7.4_
- [ ] 9.4 Update `isCardItem` to `isInventoryItem` in QueuePanel item rendering