Compare commits

...

83 Commits

Author SHA1 Message Date
kelly
81745fbf70 fix: Increase seeder to process 5 businesses to include Cannabrands 2025-11-16 00:24:43 -07:00
kelly
6c3be5221b fix: Use slug as route key for Business model to fix URL generation 2025-11-16 00:22:08 -07:00
kelly
1e6cb75422 fix: Update seeder to work with any business type and fix column names 2025-11-16 00:20:44 -07:00
kelly
b4bc8c129f fix: Rename getSeverityColor instance method to getBadgeClass to avoid conflict 2025-11-16 00:19:17 -07:00
kelly
86e656a89b fix: Use business_type instead of user_type in inventory seeder 2025-11-16 00:16:52 -07:00
kelly
c7c15fa484 fix: Remove default values from computed columns in inventory migration 2025-11-16 00:14:48 -07:00
kelly
1e60212644 chore: Add SESSION_ACTIVE to gitignore to protect progress tracker 2025-11-15 23:59:58 -07:00
kelly
33607ff982 style: Fix code style issues found by Pint 2025-11-15 23:58:42 -07:00
kelly
bb34d24e1b feat: Add comprehensive inventory management module
## Overview
- Complete inventory tracking system with multi-location support
- Movement tracking for receipts, transfers, adjustments, sales, consumption, waste
- Automated alert system for low stock, expiration, and quality issues
- Freemium model: core features free, advanced features premium

## Database Schema
- inventory_items: 40+ fields for tracking items, quantities, costs, locations
- inventory_movements: Complete audit trail for all transactions
- inventory_alerts: Automated alerting with workflow management

## Models & Controllers
- 3 Eloquent models with business logic and security
- 4 controllers with proper business_id isolation
- 38 routes under /s/{business}/inventory

## UI
- 15 Blade templates with DaisyUI styling
- Dashboard with stats, alerts, and quick actions
- Full CRUD for items, movements, and alerts
- Advanced filtering and bulk operations

## Module System
- Updated ModuleSeeder with core vs premium feature distinction
- Marketing & Buyer Analytics marked as fully premium
- Navigation updated with premium badges
- Sample data seeder for local testing

## Security
- Business_id scoping on ALL queries
- Foreign key validation
- User tracking on all mutations
- Soft deletes for audit trail

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 23:56:10 -07:00
kelly
3de733a528 feat: Add multi-tenancy support for divisions and complete marketing module
## Route Improvements
- Add user UUID route binding for consistent user references across routes
- Add automatic redirect from /s/{business} to /s/{business}/dashboard
- Add brand management routes (CRUD operations for brands)
- Fix category routes to include required {type} parameter for edit/update/delete

## Division/Subdivision Support
Enable subdivisions to access parent company resources:

### BrandController
- Allow divisions to view/edit brands from parent company
- Update show(), preview(), edit(), update(), destroy() methods
- Check both business_id and parent_id when validating brand access
- Display parent company name with division name in brands index

### CategoryController
- Allow divisions to view/edit categories from parent company
- Update index(), create(), edit(), update(), destroy() methods
- Include parent categories in all category queries for divisions
- Support both ProductCategory and ComponentCategory hierarchies

### SettingsController
- Exclude business owner from users management list (owner has full perms)
- Add editUser() method for dedicated user edit page
- Create comprehensive user edit view with permissions and departments

## Marketing Module Completion
- Add MarketingAudience model with manual/dynamic/imported types
- Add AudienceMember polymorphic pivot (supports Users and Contacts)
- Create marketing_audiences migration with filters and recipient tracking
- Create audience_members migration with unique constraints
- Enable broadcast audience targeting functionality

## UI/UX Improvements
- Add user edit page with full permission management interface
- Update brands sidebar link to use correct route (brands.index vs settings.brands)
- Sort premium modules alphabetically in navigation menu
- Show division name in brand management subtitle

## Files Changed
- routes/seller.php
- app/Http/Controllers/Seller/BrandController.php
- app/Http/Controllers/Seller/CategoryController.php
- app/Http/Controllers/Seller/SettingsController.php
- app/Models/MarketingAudience.php (new)
- app/Models/AudienceMember.php (new)
- database/migrations/2025_11_15_084232_create_marketing_audiences_table.php (new)
- database/migrations/2025_11_15_084313_create_audience_members_table.php (new)
- resources/views/seller/settings/users-edit.blade.php (new)
- resources/views/seller/brands/index.blade.php
- resources/views/components/seller-sidebar.blade.php
2025-11-15 02:14:48 -07:00
Yeltsin Batiancila
eccaedf219 feat: add EmailTemplate resource with CRUD functionality and email template seeder 2025-11-15 01:31:22 -07:00
Kelly
a4e465c428 fix: resolve duplicate migration timestamp for vehicles table
- Renamed 2025_10_10_034707_create_vehicles_table.php to 2025_10_10_034708_create_vehicles_table.php
- Migration now runs sequentially after drivers table creation
- Resolves PostgreSQL duplicate table creation errors in CI tests
2025-11-15 01:21:40 -07:00
Kelly
b96f5d6d59 feat: add broadcast system with mass messaging
- Add 3 database tables (broadcasts, recipients, events)
- Add BroadcastService with sending logic
- Add 3 queue jobs (send, send message, scheduled)
- Add BroadcastController with full CRUD
- Add real-time progress tracking
- Add analytics dashboard
- Support email, SMS, push, multi-channel
- Add pause/resume/cancel functionality
- Add rate limiting support
- Add event tracking (opens, clicks, unsubscribes)
- Add beautiful Blade views with DaisyUI

Part 5 of Marketing System

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 01:17:13 -07:00
Kelly
28d1701904 feat: implement marketing templates system (PR #4)
- Add 6 database migrations for template management
  - template_categories with 8 pre-seeded categories
  - templates table with design_json, MJML, HTML content
  - template_versions for version history tracking
  - template_blocks for reusable components
  - brand_templates pivot for brand associations
  - template_analytics for engagement tracking

- Create 5 Eloquent models with relationships
  - Template: business-scoped with system template support
  - TemplateCategory: organized template library
  - TemplateVersion: automatic version snapshots
  - TemplateBlock: reusable content blocks
  - TemplateAnalytics: performance metrics tracking

- Implement 4 comprehensive services
  - TemplateService: CRUD, import/export, versioning
  - MjmlService: responsive email rendering
  - MergeTagService: 20+ variable replacements
  - AIContentService: Claude API integration

- Add TemplateController with 25+ methods
  - Full CRUD operations with business isolation
  - Duplicate, preview, test email endpoints
  - Version management and restore
  - Brand association management
  - Import/export (HTML, MJML, ZIP)
  - AI content generation endpoints

- Add 20+ routes to seller.php under /marketing/templates
  - CRUD routes with business scoping
  - AI assistant endpoints
  - Analytics and version history
  - Brand management actions

- Create 4 Blade views
  - index: template library grid with filters
  - create: template creation form
  - show: template details and stats
  - edit: template editor

All code follows Laravel 12 best practices with proper business_id isolation for multi-tenancy.
2025-11-15 01:17:00 -07:00
kelly
4cb6b87134 fix(sidebar): Update Inactive Modules section labels
Changed section header from 'Premium Features' to 'Inactive Modules'
and changed module subtitle from 'Inactive' back to 'Premium Feature'
for better clarity.

Section now shows:
- Header: 'Inactive Modules'
- Each module subtitle: 'Premium Feature'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 01:13:00 -07:00
kelly
e3f7181558 fix(sidebar): Change logo from SVG to PNG for better compatibility
Switched sidebar logo from canna_white.svg to canna_white.png to resolve
rendering issues where logo was not appearing in some environments.

PNG format has better browser/Docker compatibility than SVG.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 01:12:15 -07:00
kelly
456b44681c refactor(modules): Remove SMS Gateway, integrate into Marketing module
SMS functionality will be part of the Marketing module instead of
a separate premium module.

Changes:
- Removed SMS Gateway from ModuleSeeder
- Updated Marketing module description to include SMS messaging
- Added 'sms_messaging' to Marketing module features config
- Removed SMS Gateway from sidebar Premium Features section
- Deleted sms_gateway record from modules table

Marketing module now includes:
- Campaign management
- Email marketing
- SMS messaging (new)
- Brand assets
- Analytics

This consolidates communication features under one premium module
rather than fragmenting into separate SMS and Marketing modules.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 01:08:04 -07:00
kelly
e60accf724 feat(sidebar): Move locked modules to bottom Premium Features section
Major navigation UX improvement - locked modules now appear at bottom:

BEFORE:
- Modules showed in fixed positions with "Premium Feature" badge
- Mixed active and locked features throughout navigation
- Confusing UX with locked features scattered in menu

AFTER:
Active features first (top of navigation):
- Overview (Dashboard, Analytics)
- Buyer Analytics (if enabled)
- Processing (if enabled)
- Transactions (Orders, Invoices, Customers)
- Brands
- Inventory Management
- Manufacturing (if enabled)
- Reports

Premium Features section (bottom of navigation):
- Shows ALL locked/inactive premium modules
- Each displays with lock icon and "Inactive" subtitle
- Greyed out appearance (opacity-50)
- Tooltip: "Premium feature - contact support to enable"

Locked modules dynamically added based on business flags:
- Buyer Analytics (if !has_analytics)
- Processing (if !has_processing)
- Manufacturing (if !has_manufacturing)
- Marketing (if !has_marketing)
- Compliance (if !has_compliance)
- Accounting & Finance (always shown - column pending)
- SMS Gateway (always shown - column pending)

Benefits:
- Cleaner navigation hierarchy
- Active features immediately visible
- Clear separation of available vs locked features
- Better upsell visibility for premium modules
- Consistent UX across all sellers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 01:06:33 -07:00
kelly
66db854ebc feat(sidebar): Add module gating to Manufacturing section
Manufacturing section now requires has_manufacturing module:
- Shows to all sellers with business profile
- Displays as locked "Premium Feature" when module disabled
- Unlocks with full access when module enabled (for owners/admins)
- Consistent with Buyer Analytics and Processing module patterns

Manufacturing locked state shows:
- Lock icon
- "Manufacturing" title
- "Premium Feature" subtitle
- Greyed out appearance (opacity-50)
- Tooltip: "Premium feature - contact support to enable"

All premium modules now properly gated:
- Buyer Analytics (has_analytics)
- Processing (has_processing)
- Manufacturing (has_manufacturing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 01:03:39 -07:00
kelly
2d02493b24 feat(sidebar): Restructure navigation and add module-based access control
Major sidebar navigation improvements:

1. Moved Brands to standalone section
   - No longer nested under Transactions
   - Has own "Brands" menu label

2. Moved Inventory Management to standalone section
   - No longer nested under Transactions
   - Has own "Inventory Management" menu label
   - Contains: Products, Components

3. Wash Reports now requires Processing module
   - Added has_processing check for both department users and owners
   - Reports section only shows when module is enabled

4. Added has_processing migration
   - New boolean column on businesses table
   - Defaults to false (module disabled)

5. Disabled all premium modules for all businesses
   - has_analytics = false
   - has_manufacturing = false
   - has_compliance = false
   - has_marketing = false
   - has_processing = false

Navigation structure now:
- Overview (Dashboard, Analytics)
- Intelligence (Buyer Analytics - premium module)
- Processing (premium module with department-based subitems)
- Transactions (Orders, Invoices, Customers)
- Brands (All Brands)
- Inventory Management (Products, Components)
- Manufacturing (Purchase Orders, Work Orders, Drivers, Vehicles)
- Reports (Wash Reports - requires Processing module)

All premium features now properly gated behind module system.
Admins can enable modules per business in /admin area.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 01:02:46 -07:00
kelly
e3c7d14001 refactor(sidebar): Reorganize navigation structure
Navigation improvements:
- Removed "Operations" section label (unnecessary grouping)
- Moved Fleet Management (Drivers/Vehicles) under Manufacturing section
  (fleet is part of manufacturing operations, not standalone)
- Renamed "Ecommerce" to "Transactions" (clearer terminology)
- Removed "Distribution" section (consolidated into Manufacturing)

Sidebar now has cleaner hierarchy with Manufacturing containing:
- Purchase Orders
- Work Orders
- Drivers (if user has fleet access)
- Vehicles (if user has fleet access)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:59:38 -07:00
kelly
966d381740 fix(sidebar): Standardize locked module display to 'Premium Feature'
Updated Processing module locked state to match Buyer Analytics:
- Changed text from "Module Not Enabled" to "Premium Feature"
- Added tooltip explaining how to enable
- Consistent greyed-out styling (opacity-50)
- Standardized padding and layout

All premium/locked modules now display consistently in the sidebar
with clear "Premium Feature" messaging and visual greying.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:57:32 -07:00
kelly
1eff01496b feat(modules): Configure module pricing tiers and approval requirements
Updated ModuleSeeder with proper module categorization (alphabetically sorted):

FREE Modules (no approval required):
- Accounting & Finance (Finance)
- CRM (Sales)
- Inventory Management (Operations)
- Processing (Operations)
- Sales (Core) - enabled by default

PREMIUM Modules (require approval):
- Buyer Analytics (Intelligence)
- Compliance (Regulatory)
- Manufacturing (Operations)
- Marketing (Growth)
- SMS Gateway (Communication)

All modules set to active. Premium modules require admin approval
before businesses can enable them.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:55:02 -07:00
kelly
bf83c4bc63 feat: Add Buyer Analytics and Processing module management
Added module management system for Buyer Analytics and Processing features:

1. Module Seeder Updates
   - Added 'buyer_analytics' module (premium, Intelligence category)
   - Added 'processing' module (free, Operations category)
   - Both modules now manageable via Filament admin /admin/modules

2. Database Schema
   - Migration: Added has_processing column to businesses table
   - Business model: Added all module flags to casts (has_analytics,
     has_manufacturing, has_processing, has_marketing, has_compliance)

3. Seller Sidebar Integration
   - Added Processing module section (module-based, like Buyer Analytics)
   - Shows when has_processing=true on business
   - Menu items: My Work Orders, Idle Fresh Frozen, Conversions, Wash Reports
   - Locked state when module not enabled
   - Added menuProcessingModule to Alpine.js data

Module Features:
- Buyer Analytics: Product engagement, buyer scores, email campaigns, sales funnel
- Processing: Work orders, solventless, BHO, conversions, wash reports, yield tracking

Admin can now enable/disable these modules per business via Filament.
Sidebar displays module sections based on business module flags.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:48:29 -07:00
kelly
aec4a12af8 refactor: Remove Business/Company section from sidebar
Removed the Business section and nested Company menu from sidebar since
all business settings have been moved to the user profile dropdown.

Removed items:
- Business menu label
- Admin Panel link (for super admins)
- Company collapsible section with:
  - Company Information
  - Manage Divisions (parent companies)
  - Users
  - Sales Config
  - Brand Kit
  - Payments
  - Invoice Settings
  - Manage Licenses
  - Plans and Billing
  - Notifications
  - Reports

All these settings are now accessible via the seller-account-dropdown
component in the user profile menu.

Also cleaned up Alpine.js data by removing unused menuBusiness variable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:42:34 -07:00
kelly
49ef373cbe Merge feature/solventless-department into develop
Implements idle fresh frozen inventory tracking for Solventless department.

Features added:
- Full ProcessingController implementation (replaces stub)
  - idleFreshFrozen() queries ComponentCategory and Components
  - Filters by 'fresh-frozen' category slug
  - Returns components with stock (quantity_on_hand > 0)
  - pressing() and washing() helper methods

- Idle Fresh Frozen inventory page (141 lines)
  - Shows fresh frozen material waiting to be processed
  - Integrated with existing Processing → Solventless menu

- Layout fixes for conversion views
  - Updated 5 conversion blade files to use correct layout
  - Fixes: create, index, show, waste, yields

Conflict resolution:
- Took branch version of ProcessingController (real implementation over stub)

Menu item already exists in sidebar. Route already defined.
This merge makes the existing nav link functional.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:31:39 -07:00
kelly
9a40e1945e fix: Resolve fatal errors and complete post-merge setup
Fixed critical issues blocking the application after push notifications merge:

1. Created missing Processing module stub controllers
   - ProcessingController with idleFreshFrozen() method
   - WashReportController with index(), activeDashboard(), dailyPerformance(), search()
   - Routes existed but controllers were missing

2. Fixed duplicate methods in SettingsController from unresolved merge
   - Removed duplicate inviteUser() at line 799
   - Removed duplicate removeUser() at line 842
   - Removed duplicate updateNotifications() at line 1055
   - Removed first updateUser() (kept advanced version with department assignments)
   - Preserved unique methods: changePlan, cancelDowngrade, viewInvoice, downloadInvoice, switchView
   - These duplicates were preventing routes from loading with "Cannot redeclare" errors

3. Updated dependencies from composer update
   - Updated composer.lock with new packages (laravel/horizon, webpush, etc.)
   - Updated Filament from v4.1.10 to v4.2.2
   - Updated Laravel framework from v12.35.1 to v12.38.1
   - Published updated Filament assets

Application now loads without fatal errors. Routes verified working.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:20:20 -07:00
kelly
99e34832a0 Merge feature/push-notifications-horizon into develop
Adds Premium Buyer Analytics features extracted from PR #37:
- Web push notifications for high-intent buyer signals
- Laravel Horizon for queue management and monitoring
- Complete test data seeder for local testing
- Comprehensive setup documentation

Features integrated:
1. Push Notifications (Analytics Module)
   - HighIntentSignalNotification sends browser push
   - PushSubscription model stores subscriptions
   - SendHighIntentSignalPushNotification listener (queued)
   - Triggers on: repeated_view, high_engagement, spec_download, contact_click

2. Laravel Horizon
   - Queue monitoring dashboard at /horizon
   - Redis-based queue management
   - Configured for local and production environments

3. Test Data & Documentation
   - PushNotificationTestDataSeeder with realistic scenarios
   - PUSH_NOTIFICATIONS_SETUP.md with complete setup guide
   - Safe for local testing (not deployed to production)

Integration complete:
- Dependencies added to composer.json
- Event listener registered in AppServiceProvider
- HorizonServiceProvider added to bootstrap/providers.php

Post-merge commands required:
- composer update
- php artisan horizon:install
- php artisan migrate
- php artisan webpush:vapid

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:08:30 -07:00
kelly
e1ebf245b2 feat: Complete push notifications integration
Fully integrated push notifications and Horizon - ready to use!

Changes:
1. composer.json - Added dependencies:
   - laravel-notification-channels/webpush: ^10.2
   - laravel/horizon: ^5.39

2. app/Providers/AppServiceProvider.php:
   - Registered HighIntentBuyerDetected event listener
   - Sends push notifications when high-intent signals detected

3. bootstrap/providers.php:
   - Registered HorizonServiceProvider for queue dashboard

Integration Complete:
 Dependencies defined in composer.json
 Event listener registered and wired up
 Horizon provider loaded
 All files properly namespaced in Analytics module
 Test data seeder ready (local only)
 Complete documentation in PUSH_NOTIFICATIONS_SETUP.md

Next Steps After Merge:
1. Run: composer update
2. Run: php artisan horizon:install
3. Run: php artisan migrate
4. Run: php artisan webpush:vapid
5. Start: php artisan horizon
6. Test: php artisan db:seed --class=PushNotificationTestDataSeeder

Feature is now fully integrated and ready to merge!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:03:38 -07:00
kelly
10688606ca docs: Add comprehensive setup guide and test data seeder
Added complete documentation and test data for local development:

New Files:
- PUSH_NOTIFICATIONS_SETUP.md - Complete setup guide with:
  • Installation steps (composer, horizon, migrate, vapid)
  • Local development setup
  • Production deployment checklist
  • Supervisor configuration
  • Troubleshooting guide
  • Security notes

- database/seeders/PushNotificationTestDataSeeder.php - Test data seeder:
  • 5 repeated product views (triggers notification)
  • High engagement score 95% (triggers notification)
  • 4 intent signals (all types)
  • Test notification event
  • Instructions for testing
  • LOCAL ONLY - Not for production!

Local Testing:
✓ All features work locally with Laravel Sail
✓ Redis included in Sail
✓ Horizon runs locally
✓ Push notifications work on localhost
✓ Complete test scenarios
✓ Visual verification via /horizon and analytics dashboards

Production Notes:
⚠️ DO NOT run test seeder in production
⚠️ Generate environment-specific VAPID keys
⚠️ Configure supervisor for Horizon
✓ All commands documented for deployment

Run locally:
  ./vendor/bin/sail up -d
  php artisan migrate
  php artisan webpush:vapid
  php artisan horizon (in separate terminal)
  php artisan db:seed --class=PushNotificationTestDataSeeder

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 00:01:03 -07:00
kelly
f36aad8d6d feat: Add Web Push Notifications and Laravel Horizon to Analytics Module
Extracted from PR #37 and organized within the Analytics module structure.

These are CORE components of the Premium Buyer Analytics module:
- Push notifications alert on high-intent buyer signals
- Horizon provides queue management infrastructure

File Organization (Analytics Module):
- app/Notifications/Analytics/HighIntentSignalNotification.php
- app/Models/Analytics/PushSubscription.php ✓ Moved to Analytics
- app/Listeners/Analytics/SendHighIntentSignalPushNotification.php ✓ Moved to Analytics
- app/Providers/HorizonServiceProvider.php (infrastructure)
- database/migrations/2025_11_09_003106_create_push_subscriptions_table.php
- config/webpush.php
- config/horizon.php

Integration with Premium Analytics:
- Triggers on HighIntentBuyerDetected event
- Works with BuyerEngagementScore model
- Uses IntentSignal tracking
- Requires has_analytics flag (CheckAnalyticsModule middleware)

TODO - Manual Integration:
1. composer.json: Add webpush ^10.2 and horizon ^5.39
2. AppServiceProvider: Register event listener
3. bootstrap/providers.php: Add HorizonServiceProvider
4. Run: composer update && php artisan horizon:install && migrate
5. Generate VAPID keys: php artisan webpush:vapid

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 23:56:17 -07:00
kelly
f543fe930a Merge feature/marketing-module into develop
This merge integrates the complete analytics and marketing module system
while preserving all recent work from develop.

## Analytics Features Added:
- Premium Buyer Intelligence module with RFDI scoring system
- Product engagement tracking and intent signals
- Email campaign analytics and buyer scoring
- Conversion funnel and sales analytics
- Session tracking and user engagement metrics

## Route Changes:
- Basic seller analytics: /s/{business}/analytics (always available)
- Premium buyer intelligence: /s/{business}/buyer-intelligence/* (requires has_analytics flag)
- Added CheckAnalyticsModule middleware for access control

## Admin Interface:
- Added module flags: has_analytics, has_marketing, has_manufacturing
- Filament admin toggles for enabling premium features
- Module badges in BusinessResource table

## UI Changes:
- Added locked "Buyer Analytics" menu in seller sidebar (shows "Premium Feature" when disabled)
- Preserved Executive Dashboard for parent companies (from develop)
- Preserved Manufacturing section with department permissions (from develop)
- Preserved login badge positioning above version info (from develop)

## Backend Changes:
- Enhanced PermissionService with audit logging, caching, and wildcards
- Added analytics event tracking system with queue processing
- Added view-as functionality for admin impersonation
- Analytics tracking JavaScript integration

## Database:
- 14 new analytics-related migrations
- Module flags on businesses table
- Permission audit logging tables

## Conflict Resolutions:
- Business.php: Merged both override_billing (develop) + order/notification settings (marketing)
- Component.php: Used component_category_id (more explicit naming)
- seller-sidebar.blade.php: Manually merged to preserve Executive Dashboard, Manufacturing, AND add Buyer Analytics
- dashboard.blade.php: Preserved develop version with Processing sections
- BrandController.php: Preserved develop version with show() and preview() methods
- PermissionService.php: Used marketing-module version (more complete implementation)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 23:27:42 -07:00
kelly
62be464ebe feat(analytics): Add tracking integration to views and layouts from PR #42
This commit adds the missing view files with analytics tracking integration:

1. Layouts with Auto-Tracking (3 files):
   - layouts/buyer-app-with-sidebar.blade.php - Auto buyer tracking
   - layouts/guest.blade.php - Guest page tracking
   - layouts/app.blade.php - App-wide analytics

2. Product & Brand Views (3 files):
   - buyer/marketplace/product.blade.php - Product view tracking
   - seller/brands/preview.blade.php - Brand preview tracking
   - seller/dashboard.blade.php - Dashboard with analytics widgets

3. Components & Partials (2 files):
   - components/buyer-sidebar.blade.php - Buyer navigation
   - partials/analytics.blade.php - Tracking script include

4. Supporting Files (4 files):
   - Services/SellerNotificationService - Notification system
   - Models/Component - Component model with analytics
   - Filament UserResource - User management
   - config/filesystems.php - File storage config

These views integrate automatic analytics tracking throughout the application.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 23:00:49 -07:00
kelly
3b245b421f feat(analytics): Integrate analytics tracking from PR #42 (analytics-system)
This commit completes the analytics integration by adding tracking to
existing controllers and models from origin/feature/analytics-system.

1. Documentation (3 new files):
   - ANALYTICS_IMPLEMENTATION.md - System architecture
   - ANALYTICS_QUICK_START.md - Quick start guide
   - ANALYTICS_TRACKING_EXAMPLES.md - Code examples

2. Controllers with Analytics Tracking (7 modified):
   - DashboardController - Analytics widgets
   - ProductController - Product view tracking
   - BrandController - Brand engagement tracking
   - BrandPreviewController - Preview action tracking
   - SettingsController - Settings analytics
   - UserController - User management analytics
   - BrandBrowseController - Buyer browsing analytics

3. Models with Analytics Relationships (4 modified):
   - Brand - Analytics data relationships
   - Business - has_analytics flag, relationships
   - Product - View/engagement tracking methods
   - Contact - Buyer intelligence connections

This integrates the comprehensive analytics tracking system into the
existing application flow, enabling automatic tracking of buyer behavior.

Closes remote PR #42 (feature/analytics-system)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 22:54:11 -07:00
kelly
8f45d86315 feat(analytics): Add missing analytics views and admin module management
This commit completes the analytics migration by adding:

1. Missing Analytics Views (9 files):
   - buyer-detail, buyers, campaign-detail, dashboard
   - marketing, product-detail, products, sales, index
   - analytics-tracking partial

2. Admin Module Management:
   - BusinessResource: Added "Modules" tab with toggles for premium features
   - BusinessResource: Added module badges to table showing active modules
   - Migration: Added has_analytics, has_marketing, has_manufacturing columns

3. Premium Feature UX:
   - Sidebar: Shows "Buyer Analytics" as locked premium feature when disabled
   - Sidebar: Shows full menu when has_analytics enabled
   - Lock icon + "Premium Feature" subtitle for upsell visibility

Now includes complete comprehensive analytics system from PR #39.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 22:40:43 -07:00
kelly
629831cdd8 fix(analytics): Separate basic seller analytics from premium buyer intelligence module
PROBLEM:
Premium buyer analytics (intensive buyer tracking system) was overwriting
basic seller analytics, causing route conflicts at /analytics.

SOLUTION:
Separated into two distinct systems:

1. **Basic Seller Analytics** (always available)
   - Route: /s/{business}/analytics
   - Controller: AnalyticsController
   - Shows: Orders, revenue, top products, top customers
   - Always visible in sidebar

2. **Premium Buyer Intelligence Module** (optional, requires super admin activation)
   - Route: /s/{business}/buyer-intelligence/*
   - Controllers: Seller/Marketing/Analytics/*
   - Shows: Buyer engagement scores, intent signals, RFDI scoring, email campaigns
   - Only visible if business.has_analytics flag is enabled

CHANGES:
- Renamed route prefix: analytics → buyer-intelligence
- Updated route names: analytics.* → buyer-intelligence.*
- Created CheckAnalyticsModule middleware to check has_analytics flag
- Registered module.analytics middleware alias
- Updated sidebar:
  * "Analytics" link → basic seller analytics (always shown)
  * "Buyer Intelligence" collapsible menu → premium module (conditional)
- Added Alpine.js menuBuyerIntelligence state variable

FILES MODIFIED:
- routes/seller.php - Changed premium analytics route prefix
- app/Http/Middleware/CheckAnalyticsModule.php - NEW middleware
- bootstrap/app.php - Registered middleware alias
- resources/views/components/seller-sidebar.blade.php - Added conditional menu

ROUTES:
- /s/{business}/analytics → Basic seller analytics (AnalyticsController)
- /s/{business}/buyer-intelligence/* → Premium buyer intelligence (requires has_analytics flag)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 21:59:36 -07:00
kelly
3ac21c22ec feat: Add category-tree-item component for hierarchical category display
This component renders nested product/component categories in a tree
structure, used in the settings pages for category management.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 21:34:13 -07:00
kelly
60362f5792 feat(analytics): Add missing permission and view-as routes
Added routes that were missing from the permission system:

**web.php:**
- POST /view-as/end - End impersonation session
- GET /view-as/status - Check impersonation status

**seller.php (business context routes):**
- POST /users/{user}/permissions - Update user permissions
- POST /users/{user}/apply-template - Apply permission role template
- POST /users/{user}/view-as - Start viewing as another user

These routes connect the permission UI to the backend controllers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 21:08:16 -07:00
kelly
078e4f380c feat(analytics): Add missing permission/view-as controllers, views, and traits
Fifth audit revealed additional permission system components:

**Controllers:**
- app/Http/Controllers/ViewAsController.php
  → Start/end user impersonation sessions
- app/Http/Controllers/ViewSwitcherController.php
  → Module view switching functionality
- app/Http/Controllers/Business/UserPermissionsController.php
  → Update user permissions, apply role templates

**Views:**
- resources/views/business/users/permissions-modal.blade.php
  → Permission editing UI modal
- resources/views/components/view-as-banner.blade.php
  → Shows active impersonation banner
- resources/views/components/view-switcher.blade.php
  → Module view switcher component

**Traits:**
- app/Traits/HasHashid.php
  → Hashid generation for models (used by Product, Brand, etc.)

These complete the permission and impersonation system that analytics
controllers depend on for access control.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 21:07:44 -07:00
kelly
2457d81061 feat(analytics): Add missing PermissionService required by BusinessHelper
PermissionService is used by BusinessHelper::hasPermission() to check
user permissions against the business_user pivot table.

This service is essential for all permission checks in analytics controllers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:46:11 -07:00
kelly
dec35f9eea feat(analytics): Add missing infrastructure files for permissions and helpers
Fourth comprehensive audit revealed critical missing infrastructure files
that analytics controllers depend on. These files provide:

**Helper Functions & Business Context:**
- app/Helpers/BusinessHelper.php
  → Core business context helper (currentBusiness, hasPermission, etc.)
- app/Helpers/helpers.php
  → Global helper functions (currentBusiness(), hasBusinessPermission())
- composer.json updated to autoload app/Helpers/helpers.php

**Permission System:**
- config/permissions.php (321 lines)
  → Complete permission categories, role templates, audit settings
  → Defines all analytics.* permissions used by controllers
- app/Models/PermissionAuditLog.php
  → Tracks permission changes for security audit trail
- app/Models/ViewAsSession.php
  → Supports user impersonation feature
- database/migrations/2024_11_08_100002_create_permission_audit_logs_table.php
- database/migrations/2024_11_08_100003_create_view_as_sessions_table.php

**Middleware:**
- app/Http/Middleware/ViewAsMiddleware.php
  → Handles "View As" user impersonation sessions
- app/Http/Middleware/UpdateLastLogin.php
  → Tracks last login timestamps

**Console Commands:**
- app/Console/Commands/CleanupPermissionAuditLogs.php
  → Artisan command to clean up expired audit logs

These files are essential for:
- Analytics controllers using hasBusinessPermission() checks
- Permission-based access control throughout the analytics module
- Audit trail for security and compliance
- User management and impersonation features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:45:55 -07:00
kelly
6840f0a583 fix(analytics): Add missing TrackingController for public API endpoints
The TrackingController needs to exist in both namespaces:
- app/Http/Controllers/Analytics/TrackingController.php
  → For public tracking API endpoints in web.php (used by frontend JS)
- app/Http/Controllers/Seller/Marketing/Analytics/TrackingController.php
  → For seller-specific analytics dashboard features

The web.php routes reference the root Analytics namespace for the
public tracking endpoints (/analytics/track and /analytics/session)
which are called by frontend JavaScript from all user types.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:40:04 -07:00
kelly
759bbe90b0 fix(analytics): Add missing analytics migrations and resolve duplicates
This commit completes the analytics database schema migration that was
partially done in previous commits. Discovered and resolved critical
migration issues:

**Missing Migrations Added (9 files):**
- 2025_11_08_000002_create_product_views_table.php
- 2025_11_08_000003_create_click_tracking_table.php
- 2025_11_08_000004_create_email_campaigns_table.php
- 2025_11_08_000005_create_email_interactions_table.php
- 2025_11_08_000009_create_email_clicks_table.php
- 2025_11_08_000015_create_user_sessions_table.php
- 2025_11_08_000016_create_buyer_engagement_scores_table.php
- 2025_11_08_000018_create_intent_signals_table.php
- 2025_11_08_194230_add_module_flags_to_businesses_table.php

**Duplicate Migrations Removed (2 files):**
- 2025_11_08_000007_create_email_tracking_tables.php
  (combined migration that created email_campaigns, email_interactions,
   email_clicks - superseded by separate 000004, 000005, 000009)
- 2025_11_08_000014_create_user_sessions_and_intent_tables.php
  (combined migration that created user_sessions, intent_signals,
   buyer_engagement_scores - superseded by 000015, 000016, 000018)

**Why Separate Migrations Are Better:**
The separate migrations have more detailed schemas with timestamps(),
better indexes, and additional tracking columns compared to the
combined versions.

**Final State:**
12 analytics migrations with no duplicate table definitions:
- analytics_events, product_views, click_tracking
- email_campaigns, email_interactions, email_clicks
- user_sessions, intent_signals, buyer_engagement_scores
- analytics jobs table, permissions, module flags

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:20:23 -07:00
kelly
3a7e49f176 test(analytics): Add analytics security test
Adds security test from feature/analytics-implementation:

- AnalyticsSecurityTest.php - Ensures business_id scoping works correctly
  Tests: Cross-tenant data access prevention, permission checks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:04:29 -07:00
kelly
ca661b8649 feat(analytics): Add frontend JavaScript tracking files
Adds the missing JavaScript files for client-side analytics tracking:

- analytics-tracker.js (7KB) - Main frontend tracking script
  Handles: Page views, session tracking, click events, engagement signals

- reverb-analytics-listener.js (4KB) - Real-time analytics via Reverb
  Handles: WebSocket connection for live analytics updates

These files are referenced by resources/views/partials/analytics.blade.php
and resources/views/partials/analytics-tracking.blade.php

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 20:02:42 -07:00
kelly
430f7efe5c feat(analytics): Add missing events, jobs, and documentation
Adds remaining analytics components from feature/analytics-implementation:

## Events (1):
- HighIntentBuyerDetected - Real-time event when buyer shows high purchase intent
  Triggered by: Multiple product views, long page time, cart additions
  Used for: Sales team notifications, priority lead alerts

## Jobs (2):
- Analytics/CalculateEngagementScore - Background job to calculate buyer scores
  Metrics: Recency, Frequency, Depth, Intent (RFDI model)
  Schedule: Can run hourly or on-demand

- ProcessAnalyticsEvent - Queue handler for analytics event processing
  Handles: Batching events, detecting patterns, triggering alerts

## Documentation (2):
- 01-analytics-system.md (51KB) - Complete technical implementation guide
  Covers: Database schema, models, services, queue jobs, controllers, views

- QUICK-HANDOFF-CLAUDE-CODE.md (13KB) - Quick reference for developers
  Covers: Architecture differences, permission patterns, helper functions

These complete the analytics system migration to the marketing module.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:55:01 -07:00
kelly
d06c66f703 feat(marketing): Integrate comprehensive analytics system into marketing module
Migrated complete analytics implementation from feature/analytics-implementation
branch into the marketing module structure under Seller/Marketing/Analytics.

## New Controllers (6):
- AnalyticsDashboardController - Overview metrics and KPIs
- BuyerIntelligenceController - Buyer engagement scores & intent signals
- MarketingAnalyticsController - Email campaign analytics
- ProductAnalyticsController - Product performance tracking
- SalesAnalyticsController - Sales pipeline metrics
- TrackingController - Frontend event tracking API

## Models (9):
- AnalyticsEvent, BuyerEngagementScore, ClickTracking
- EmailCampaign, EmailClick, EmailInteraction
- IntentSignal, ProductView, UserSession

## Services:
- AnalyticsTracker - Centralized analytics tracking service

## Views (8):
All views under resources/views/seller/marketing/analytics/
- dashboard.blade.php - Main analytics overview
- buyers.blade.php, buyer-detail.blade.php - Buyer intelligence
- products.blade.php, product-detail.blade.php - Product analytics
- marketing.blade.php, campaign-detail.blade.php - Campaign analytics
- sales.blade.php - Sales funnel analytics

## Routes:
Added to /s/{business}/analytics/:
- / - Dashboard
- /products, /products/{product} - Product analytics
- /marketing, /marketing/campaigns/{id} - Campaign analytics
- /sales - Sales analytics
- /buyers, /buyers/{buyer} - Buyer intelligence
- /track, /track/session - Frontend tracking API

## Migrations (3):
- create_analytics_events_table
- add_analytics_permissions_to_business_user
- create_analytics_jobs_table

## Features:
 Real-time buyer intent detection
 Product engagement tracking (time, scroll, zooms, videos)
 Email campaign performance metrics
 Buyer engagement scoring (R/F/D/I model)
 Sales funnel visualization
 Permission-based access control

## Integration:
- Permissions: Uses hasBusinessPermission('analytics.buyers', etc.)
- Namespace: App\Http\Controllers\Seller\Marketing\Analytics
- Views: seller.marketing.analytics.*
- Module flag: has_analytics (optional feature)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 19:48:47 -07:00
kelly
c5878de5d2 fix: Update conversion views to use correct layout and add idle fresh frozen page
This commit fixes layout issues and adds missing ProcessingController functionality.

Changes:
- Updated all conversion views to use layouts.app-with-sidebar instead of non-existent layouts.seller
- Fixed ConversionController yields() method to include 'id' column in query
- Created ProcessingController with idleFreshFrozen(), pressing(), and washing() methods
- Added idle-fresh-frozen.blade.php view to display fresh frozen inventory

Files changed:
- app/Http/Controllers/Seller/Processing/ConversionController.php (removed select that excluded id)
- app/Http/Controllers/Seller/Processing/ProcessingController.php (new)
- resources/views/seller/processing/conversions/*.blade.php (5 views - layout fix)
- resources/views/seller/processing/idle-fresh-frozen.blade.php (new)

Fixes:
- "View [layouts.seller] not found" error
- "Missing required parameter [conversion]" error on yields page
- "ProcessingController does not exist" error

All conversion pages now load correctly with proper sidebar and navigation.
2025-11-14 17:50:09 -07:00
kelly
1c3f0e1efb feat(processing): Implement department-based conversion tracking system
This commit implements a complete department-based conversion tracking system
with nested menus, component inventory management, and comprehensive testing.

Major Features Added:
1. Department-based conversion filtering
   - Added department_id column to conversions table (nullable)
   - Created forUserDepartments() scope on Conversion model
   - All conversion queries filter by user's assigned departments
   - Security: Users can only create conversions for their departments

2. Nested department menu structure
   - Processing → Solventless (Washing, Pressing, Yields, Waste)
   - Processing → BHO (Extraction, Distillation, Yields, Waste)
   - Menus dynamically show/hide based on user department assignments
   - Replaces previous flat menu with department-specific nested structure

3. Component-based inventory tracking
   - Generic conversion form selects input/output components
   - Automatically reduces input component quantity_on_hand
   - Automatically increases output component quantity_on_hand
   - Validates sufficient inventory before creating conversion
   - Stores component names in metadata for display

4. Wash batch integration
   - WashReportController now creates Conversion records
   - Type changed from 'hash_wash' to 'washing' (supports both)
   - Auto-calculates yield_percentage for wash batches
   - Assigns department_id based on user's solventless department
   - All wash queries filter by user's departments

5. Fixed all conversion views
   - index.blade.php: Fixed to use metadata, started_at, actual_output_quantity
   - show.blade.php: Fixed component names, weights, waste from metadata
   - yields.blade.php: Fixed date and output weight field names
   - waste.blade.php: Fixed all field references to match model structure
   - Removed invalid eager loading (inputBatches, batchCreated)

6. Architecture documentation
   - .claude/DEPARTMENTS.md: Department system, codes, access control
   - .claude/ROUTING.md: Business slug routing, subdivision architecture
   - .claude/PROCESSING.md: Solventless vs BHO operations, conversion flow
   - .claude/MODELS.md: Key models, relationships, query patterns
   - CLAUDE.md: Updated to reference new architecture docs

7. Session tracking system
   - claude.kelly.md: Personal preferences and session workflow
   - SESSION_ACTIVE: Current session state tracker
   - .claude/commands/start-day.md: Start of day workflow
   - .claude/commands/end-day.md: End of day workflow

8. Local test data seeder
   - LocalConversionTestDataSeeder: ONLY runs in local environment
   - Creates 7 components (3 flower input, 4 concentrate output)
   - Creates 6 sample conversions with department assignments
   - Test user: maria@leopardaz.local (LAZ-SOLV department)
   - Business: Canopy AZ LLC (ID: 7, slug: leopard-az)

Technical Details:
- Migration adds nullable department_id with foreign key constraint
- 371 existing conversions have NULL department_id (backward compatible)
- New conversions require department_id assignment
- Views gracefully handle missing metadata with null coalescing
- Component type field uses existing values: 'flower', 'concentrate', 'packaging'
- Cost_per_unit is required field on components table

Testing:
- All 5 conversion pages tested via controller and pass: 
  * Index (history list)
  * Create (new conversion form)
  * Show (conversion details)
  * Yields (analytics dashboard)
  * Waste (tracking dashboard)
- Sample data verified in database
- Department filtering verified with test user

Files changed:
- database/migrations/2025_11_14_170129_add_department_id_to_conversions_table.php (new)
- database/seeders/LocalConversionTestDataSeeder.php (new)
- app/Models/Conversion.php (added department relationship, scope)
- app/Http/Controllers/Seller/Processing/ConversionController.php (all methods updated)
- app/Http/Controllers/Seller/WashReportController.php (integrated with conversions)
- resources/views/components/seller-sidebar.blade.php (nested menus)
- resources/views/seller/processing/conversions/*.blade.php (all 4 views fixed)
- .claude/DEPARTMENTS.md, ROUTING.md, PROCESSING.md, MODELS.md (new docs)
- SESSION_ACTIVE, claude.kelly.md, CLAUDE.md (session tracking)
- .claude/commands/start-day.md, end-day.md (new workflows)

Breaking Changes: None (nullable department_id maintains backward compatibility)

Known Issues:
- 371 existing conversions have NULL department_id
- These won't show for users with department restrictions
- Optional data migration could assign departments based on business/type
2025-11-14 13:07:29 -07:00
kelly
a7a0ee9ce8 fix(dashboard): Apply security and error fixes after manufacturing merge
After merging feature/manufacturing-module, applied the following fixes:

1. Fixed TypeError in DashboardController when quality data missing
   - Made quality grade extraction defensive (handles missing data)
   - Returns null for avg_hash_quality when no quality grades exist
   - Iterates all yield types instead of assuming specific keys

2. Removed owner override from dashboard block visibility
   - Dashboard blocks now determined ONLY by department assignments
   - No longer shows sales metrics to owners/admins if not in sales dept
   - Enforces architectural principle: department groups control access

3. Updated dashboard view to handle null quality gracefully
   - Shows "Not tracked" when wash history exists but no quality data
   - Shows "—" when no wash history exists
   - Prevents displaying null in quality badge

4. Registered FilamentAdminAuthenticate middleware
   - Fixes 403 errors requiring manual cookie deletion
   - Auto-logs out users without panel access
   - Redirects to login with helpful message

Files changed:
- app/Http/Controllers/DashboardController.php:56-60, 514-546
- app/Providers/Filament/AdminPanelProvider.php:8, 72
- resources/views/seller/dashboard.blade.php:538-553

Plus Pint formatting fixes across 22 files.
2025-11-14 08:18:43 -07:00
kelly
c8538e155c Merge feature/manufacturing-module into develop
Brings in manufacturing features from Nov 13 session:
- Wash reports and hash processing system
- Work orders and purchase orders
- Department-based access control
- Quick switch (impersonation) feature
- Executive dashboard for parent companies
- Complete seeder architecture with demo data

This merge brings all the code that today's fixes were addressing.
2025-11-14 08:15:23 -07:00
kelly
37db77cbb2 fix(dashboard): Fix quality calculation error and enforce department-based visibility
This commit addresses critical errors and security issues from the Nov 13 session:

1. Fixed TypeError in DashboardController when quality data missing
   - Made quality grade extraction defensive (handles missing data)
   - Returns null for avg_hash_quality when no quality grades exist
   - Iterates all yield types instead of assuming specific keys

2. Removed owner override from dashboard block visibility
   - Dashboard blocks now determined ONLY by department assignments
   - No longer shows sales metrics to owners/admins if not in sales dept
   - Enforces architectural principle: department groups control access

3. Updated dashboard view to handle null quality gracefully
   - Shows "Not tracked" when wash history exists but no quality data
   - Shows "—" when no wash history exists
   - Prevents displaying null in quality badge

4. Registered FilamentAdminAuthenticate middleware
   - Fixes 403 errors requiring manual cookie deletion
   - Auto-logs out users without panel access
   - Redirects to login with helpful message

5. Enhanced parent company cross-division security documentation
   - Clarified existing route binding prevents URL manipulation
   - Documents that users must be explicitly assigned via pivot table
   - Prevents cross-division access by changing URL slug

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed:
- app/Http/Controllers/DashboardController.php:56-60, 513-545
- app/Providers/Filament/AdminPanelProvider.php:8, 72
- resources/views/seller/dashboard.blade.php:538-553
- routes/seller.php:11-19
- SESSION_SUMMARY_2025-11-14.md (new)

Fixes issues from SESSION_SUMMARY_2025-11-13.md

Note: Test failures are pre-existing (duplicate column migration issue)
not caused by these changes. Tests need migration fix separately.
2025-11-14 08:10:24 -07:00
kelly
e2f4667818 Add comprehensive documentation and missing dashboard component
- Add EXECUTIVE_ACCESS_GUIDE.md: Department-based permissions and subdivision access control
- Add PARENT_COMPANY_SUBDIVISIONS.md: Technical implementation of parent company hierarchy
- Add MISSING_FILES_REPORT.md: Comparison between main repo and worktrees
- Add strain-performance dashboard component

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 00:09:54 -07:00
kelly
2ca5cb048b Fix: Correct operator relationship in Conversion model to use operator_user_id column 2025-11-13 23:30:45 -07:00
kelly
6426016c2e Fix: Use correct column name operator_user_id instead of operator_id in DashboardController 2025-11-13 23:28:06 -07:00
kelly
d08d080937 Add missing ComponentCategory model and migration required by DashboardController 2025-11-13 23:27:01 -07:00
kelly
8c7beccdc8 Other Claude's UI improvements and features (for review)
This branch contains work from another Claude instance that was
working on the develop branch. Saved for later review.

Includes:
- BatchController, BrandController, BrandPreviewController
- Analytics module controllers (7 files)
- Marketing module controllers (4 files)
- Fleet management controllers (2 files)
- Enhanced Dashboard and Settings views
- Batch, Brand, Analytics, Marketing views
- Modified sidebar navigation
- Settings page improvements

Status: FOR REVIEW - Not tested, may conflict with manufacturing module
Action: Review later and cherry-pick desired features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 23:15:47 -07:00
kelly
0584111357 Show parent company name with subdivision in header
Display parent company name (Canopy AZ LLC) in main header
with division/subdivision name (Leopard AZ) in smaller text below.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 22:50:28 -07:00
kelly
87174f80c5 Comment out Sales Config and Brand Kit settings - routes not available 2025-11-13 22:46:08 -07:00
kelly
bd01908b52 Comment out batches menu - routes not available, keep working manufacturing links 2025-11-13 22:45:14 -07:00
kelly
af8666bd42 Comment out brands menu - routes not available in this version 2025-11-13 22:44:05 -07:00
kelly
4f5faa5d39 Remove broken seller-account-dropdown reference from sidebar 2025-11-13 22:42:21 -07:00
kelly
2831def53a WIP: Manufacturing module with departments, work orders, and executive features
- Add Department and WorkOrder models with full CRUD
- Add PurchaseOrder management
- Add hierarchical business structure (parent company + divisions)
- Add Executive Dashboard and Corporate Settings controllers
- Add business isolation and access control
- Add demo seeders for testing (protected from production)
- Add Quick Switch tool for user testing

Related to session summary: SESSION_SUMMARY_2025-11-13.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 21:00:15 -07:00
kelly
a0baf3ad39 Add marketing module: Email campaigns and templates
Marketing Features:
- BroadcastController: Create and send email campaigns to customers
- TemplateController: Manage reusable email templates

2 controllers, 8 views:
Broadcasts:
- index.blade.php: List all campaigns
- create.blade.php: Create new campaign
- show.blade.php: View campaign details
- analytics.blade.php: Campaign performance metrics

Templates:
- index.blade.php: Template library
- create.blade.php: Create template
- edit.blade.php: Edit template
- show.blade.php: Preview template

Routes:
- /s/{business}/marketing/broadcasts/* (7 routes)
- /s/{business}/marketing/templates/* (7 routes)

Email marketing automation for sellers!

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 14:56:55 -07:00
kelly
16e002ccb9 Remove settings files - moved to feature/settings-enhancements PR
Settings-related files removed:
- SettingsController enhancements (reverted to develop)
- PermissionService, AuditLog models
- CategoryController and category models
- ViewSwitcherController
- Account dropdown and view switcher components
- All enhanced settings views (profile, sales-config, integrations, webhooks, audit-logs, brand-kit, categories)
- Settings documentation files

These features are now in the feature/settings-enhancements branch/PR.

This worktree now contains only:
- Manufacturing module (batches, wash reports)
- Brands management
- Analytics module
- Marketing module
2025-11-12 14:49:18 -07:00
Claude Code Assistant
bf0dea6ee3 Move manufacturing routes under /manufacturing prefix for module isolation
- Wrap batches and wash-reports under manufacturing prefix
- Update all route names: seller.business.wash-reports.* -> seller.business.manufacturing.wash-reports.*
- Update all route names: seller.business.batches.* -> seller.business.manufacturing.batches.*
- Update all view references to new route names
- Manufacturing now completely isolated from sales routes
2025-11-12 14:23:29 -07:00
Claude Code Assistant
602c060a0a Add wash report enhancements with equipment tracking and printable forms
- Add washer selection (1-4) and freeze dryer selection (A-F) fields
- Add timing fields: wash start, wash end, into dryer time
- Add drying trays by micron size (160u, 90u, 45u, 25u)
- Add lb/g weight conversion display throughout
- Add hash ready time field in Stage 2
- Add auto-calculated metrics (dry time, total cycle time)
- Add printable blank form for manual data entry
- Update controller validation for all new fields
- Store all data in conversion metadata
- Create fresh frozen inventory (Blue Dream, Wedding Cake, Gelato, OG Kush)
- Fix batch query to use product->brand->business relationship
- Add module flags (has_manufacturing, has_compliance) to businesses table
2025-11-12 14:21:47 -07:00
Kelly
2c0d1d5658 Restore Analytics & Marketing controllers from PR worktrees + fix slug routing bug
This commit restores the working develop branch by:

1. Restored Analytics controllers from PR39 worktree:
   - AnalyticsDashboardController (comprehensive metrics dashboard)
   - TrackingController (event tracking with product view signals)
   - BuyerIntelligenceController, MarketingAnalyticsController, ProductAnalyticsController, SalesAnalyticsController
   - All 8 analytics blade view files

2. Restored Marketing controllers from PR44 worktree:
   - BroadcastController, TemplateController
   - All marketing blade view files (broadcasts, templates)

3. Restored additional Seller controllers:
   - DashboardController, OrderController
   - Fleet controllers (DriverController, VehicleController)
   - BrandPreviewController

4. Fixed pre-existing slug routing bug in routes/seller.php:124
   - Changed redirect from using $business (ID) to $business->slug
   - Fixes /s/cannabrands redirecting to /s/5/dashboard instead of /s/cannabrands/dashboard

These controllers were removed in commit 44793c2 which caused all seller routes to 404.
This restoration brings develop back to working state with all route handlers present.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 14:20:49 -07:00
Kelly
f8d1f9dc91 Fix wash reports validation, restore manufacturing menu, and protect settings
- Disabled Stage 1 validation in WashReportController to allow empty submissions
- Added missing fillable fields to Conversion model (internal_name, operator_user_id, started_at, completed_at)
- Fixed menuManufacturing and menuBrands initialization in seller-sidebar
- Added wash-reports show route
- Restored wash-reports show.blade.php view
- Created work protection guide documentation

All settings (15 routes), manufacturing (batches, wash reports), and brands features tested and working.
2025-11-12 14:20:49 -07:00
Jon
7887a695f7 Merge pull request 'Add module isolation structure for parallel development' (#49) from feature/manufacturing-route-isolation into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/49
Reviewed-by: Jon <contact@jonleopard.com>
2025-11-12 20:52:12 +00:00
kelly
654a76c5db Clarify core analytics vs Analytics module distinction
- Core analytics built into sales platform (always available)
- Analytics module for advanced BI and cross-module reporting
- Document permission structure for both
- Add examples showing when to use each
- Emphasize core B2B platform is NOT a "sales module"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 12:53:16 -07:00
kelly
a339d8fc75 Document complete route isolation architecture across /admin, /b, and /s
- Add comprehensive ROUTE_ISOLATION.md documentation
- Document /admin as separate isolated area (Filament Resources)
- Document buyer settings as required module at /b/{business}/settings/*
- Document seller settings as required module at /s/{business}/settings/*
- Clarify distinction between optional modules (flags) and required modules (permissions)
- Add examples of parallel development workflow
- Document module naming conventions and access control patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 12:37:39 -07:00
kelly
482789ca41 Add Settings as a formal module in isolation structure
Settings is now formally documented as a module alongside Manufacturing, Compliance, Marketing, and Analytics.

Key differences:
- Settings is always enabled (no has_settings flag needed)
- Controlled by role-based permissions rather than business flag
- Already has existing routes (company-information, users, brands, payments, etc.)

This provides:
- Development isolation for settings team
- Clear module boundary documentation
- Consistent pattern with other modules
- Permission control without feature flag overhead
2025-11-12 12:25:12 -07:00
kelly
28a66fba92 Add complete module isolation for B2B marketplace
Establishes route namespace isolation for all optional modules:

CORE SALES (always enabled):
- /s/{business}/* - Orders, products, brands, customers

OPTIONAL MODULES:
- /s/{business}/manufacturing/* - Production tracking (batches, wash-reports, conversions, work-orders)
- /s/{business}/compliance/* - Regulatory tracking (metrc, incoming-materials, lab-results)
- /s/{business}/marketing/* - Social media management, campaigns, email marketing
- /s/{business}/analytics/* - Business intelligence, cross-module reporting, executive dashboards

Adds module flags to businesses table:
- has_manufacturing (default: false)
- has_compliance (default: false)
- has_marketing (default: false)
- has_analytics (default: false)

KEY DISTINCTION:
- Each module has operational reports (e.g., manufacturing production reports)
- Analytics module is executive/BI layer that aggregates data across ALL modules
  (sales trends, product performance, customer insights, manufacturing costs, marketing ROI)

This prevents route collisions when multiple devs work on different modules
and allows per-business feature enablement in the B2B marketplace.
2025-11-12 12:18:53 -07:00
Jon
84f364de74 Merge pull request 'Cleanup product PR: Remove debug files and add tests' (#32) from fix/cleanup-product-pr-v2 into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/32
2025-11-06 23:39:28 +00:00
Jon Leopard
39c955cdc4 Fix ProductLineController route names
Changed all redirects from 'seller.business.products.index1' to
'seller.business.products.index' to match the actual route definition.

The index1 route doesn't exist in origin/develop, causing test failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:31:46 -07:00
Jon Leopard
e02ca54187 Update drop shadow values to match dashboard styling
Changed all card shadows from shadow-xl to shadow to be consistent
with the dashboard page styling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
ac46ee004b Fix product edit header: theme support and remove breadcrumb
Fixed top header container styling issues:
- Changed hard-coded bg-white/gray colors to theme-aware DaisyUI classes
- Restored proper shadow (shadow-xl instead of shadow-sm)
- Updated all color classes to use base-* theme variables
- Converted buttons to proper DaisyUI btn components
- Removed breadcrumb navigation element

Container now properly respects theme switcher (light/dark mode).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
17a6eb260d Add comprehensive tests for ProductLineController
Added test coverage for all ProductLineController methods:
- Store: validates required name, uniqueness per business, cross-business duplicates OK
- Update: validates name, uniqueness, business isolation
- Destroy: deletes product line, business isolation

Tests verify business_id scoping prevents cross-tenant access.

Note: Tests use standard HTTP methods (not JSON) which may have CSRF token issues
in current test environment (project-wide issue).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
5ea80366be Add comprehensive tests for ProductImageController
Added test coverage for all ProductImageController methods:
- Upload: validates dimensions, file type, max 6 images, business isolation
- Delete: handles primary image reassignment, business isolation
- Reorder: updates sort_order, sets first as primary, business isolation
- SetPrimary: updates is_primary flag, cross-product validation

Also fixed ProductImage model to include sort_order in fillable/casts.

Note: Tests currently fail with 419 CSRF errors (project-wide test issue affecting
PUT/POST/DELETE requests). Tests are correctly structured and will pass once CSRF
handling is fixed in the test environment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon Leopard
99aa0cb980 Remove development and test artifacts from product PR
Removed debugging tools and test files that should not be in production:
- check_blade.php and check_blade.js (Blade syntax checkers)
- StorageTestController and storage-test view (MinIO testing scaffolds)
- edit.blade.php.backup and edit1.blade.php (development iterations)
- Storage test routes from web.php

These files were used during development but are not needed in the codebase.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 16:25:28 -07:00
Jon
3de53a76d0 Merge pull request 'docs: add comprehensive guide for keeping feature branches up-to-date' (#30) from docs/add-feature-branch-sync-guide-clean into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/30
2025-11-06 22:17:08 +00:00
Jon Leopard
7fa9b6aff8 docs: add comprehensive guide for keeping feature branches up-to-date
Added new section "Keeping Your Feature Branch Up-to-Date" covering:
- Daily start-of-work routine for syncing with develop
- Merge vs rebase best practices for teams
- Step-by-step conflict resolution guide
- When and how to ask for help with complex conflicts
- Real-world example of multi-day feature work

This addresses common questions from contributors about branch
management and helps prevent large merge conflicts by encouraging
regular syncing with develop.
2025-11-06 15:05:27 -07:00
498 changed files with 73584 additions and 8803 deletions

132
.claude/DEPARTMENTS.md Normal file
View File

@@ -0,0 +1,132 @@
# Department & Permission System
## Department Structure
### Curagreen Departments
- **CRG-SOLV** - Solventless (ice water extraction, pressing, rosin)
- **CRG-BHO** - BHO/Hydrocarbon (butane/propane extraction, distillation)
- **CRG-CULT** - Cultivation (growing operations)
- **CRG-DELV** - Delivery (fleet management, drivers)
### Lazarus Departments
- **LAZ-SOLV** - Solventless (ice water extraction, pressing, rosin)
- **LAZ-PKG** - Packaging (finished product packaging)
- **LAZ-MFG** - Manufacturing (product assembly)
### CBD Sales Departments
- **CBD-SALES** - Sales & Ecommerce
- **CBD-MKTG** - Marketing
## How Departments Work
### Assignment Model
```php
// Users are assigned to departments via pivot table: department_user
// Pivot columns: user_id, department_id, role (member|manager)
// Users can be in MULTIPLE departments across MULTIPLE divisions
// Example: User can be in CRG-SOLV and LAZ-SOLV simultaneously
$user->departments // Returns all departments across all businesses
$user->departments()->wherePivot('role', 'manager') // Just manager roles
```
### Access Control Levels
**1. Business Owner** (`$business->owner_user_id === $user->id`)
- Full access to everything in their business
- Can manage all departments
- Can see all reports and settings
- Only ones who see business onboarding banners
**2. Super Admin** (`$user->hasRole('super-admin')`)
- Access to everything across all businesses
- Access to Filament admin panel
- Bypass all permission checks
**3. Department Managers** (pivot role = 'manager')
- Manage their department's operations
- Assign work within department
- View department reports
**4. Department Members** (pivot role = 'member')
- Access their department's work orders
- View only their department's conversions/data
- Cannot manage other users
### Department-Based Data Filtering
**Critical Pattern:**
```php
// ALWAYS filter by user's departments for conversion data
$userDepartments = auth()->user()->departments->pluck('id');
$conversions = Conversion::where('business_id', $business->id)
->whereIn('department_id', $userDepartments)
->get();
// Or use the scope
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments(auth()->user())
->get();
```
## Menu Visibility Logic
```php
// Ecommerce Section
$isSalesDept = $userDepartments->whereIn('code', ['CBD-SALES', 'CBD-MKTG'])->isNotEmpty();
$hasEcommerceAccess = $isSalesDept || $isBusinessOwner || $isSuperAdmin;
// Processing Section
$hasSolventless = $userDepartments->whereIn('code', ['CRG-SOLV', 'LAZ-SOLV'])->isNotEmpty();
$hasBHO = $userDepartments->where('code', 'CRG-BHO')->isNotEmpty();
$hasAnyProcessing = $hasSolventless || $hasBHO || $hasCultivation || ...;
// Fleet Section
$hasDeliveryAccess = $userDepartments->where('code', 'CRG-DELV')->isNotEmpty();
$canManageFleet = $hasDeliveryAccess || $isBusinessOwner;
```
## Nested Menu Structure
Processing menu is nested by department:
```
Processing
├─ My Work Orders (all departments)
├─ Solventless (if user has CRG-SOLV or LAZ-SOLV)
│ ├─ Idle Fresh Frozen
│ ├─ Conversion history
│ ├─ Washing
│ ├─ Pressing
│ ├─ Yield percentages
│ └─ Waste tracking
└─ BHO (if user has CRG-BHO)
├─ Conversion history
├─ Extraction
├─ Distillation
├─ Yield percentages
└─ Waste tracking
```
## Key Principles
1. **Data Isolation**: Users ONLY see data for their assigned departments
2. **Cross-Division**: Users can be in departments across multiple subdivisions
3. **Business Scoping**: Department filtering happens AFTER business_id check
4. **Owner Override**: Business owners see everything in their business
5. **Explicit Assignment**: Department access must be explicitly granted via pivot table
## Common Checks
```php
// Check if user has access to department
if (!$user->departments->contains($departmentId)) {
abort(403, 'Not assigned to this department');
}
// Check if business owner
$isOwner = $business->owner_user_id === $user->id;
// Check if can manage business settings
$canManageBusiness = $isOwner || $user->hasRole('super-admin');
```

274
.claude/MODELS.md Normal file
View File

@@ -0,0 +1,274 @@
# Key Models & Relationships
## Business Hierarchy
```php
Business (Parent Company)
├─ id, name, slug, parent_business_id, owner_user_id
├─ hasMany: departments
├─ hasMany: divisions (where parent_business_id = this id)
└─ belongsTo: parentBusiness (nullable)
// Methods:
$business->isParentCompany() // true if parent_business_id is null
```
## Departments
```php
Department
├─ id, name, code (e.g., 'CRG-SOLV'), business_id
├─ belongsTo: business
└─ belongsToMany: users (pivot: department_user with 'role' column)
// Pivot relationship:
department_user
├─ user_id
├─ department_id
├─ role ('member' | 'manager')
// Usage:
$user->departments // All departments across all businesses
$user->departments()->wherePivot('role', 'manager')->get()
$department->users
```
## Users
```php
User
├─ id, name, email, user_type ('buyer'|'seller'|'admin')
├─ belongsToMany: departments (pivot: department_user)
├─ hasMany: businesses (where owner_user_id = this user)
└─ hasRoles via Spatie permissions
// Key checks:
$user->hasRole('super-admin')
$user->departments->contains($departmentId)
$user->departments->pluck('id')
```
## Conversions
```php
Conversion
├─ id, business_id, department_id
├─ conversion_type ('washing', 'pressing', 'extraction', etc.)
├─ internal_name (unique identifier)
├─ operator_user_id (who performed it)
├─ started_at, completed_at
├─ status ('pending'|'in_progress'|'completed'|'cancelled')
├─ input_weight, input_unit
├─ actual_output_quantity, actual_output_unit
├─ yield_percentage (auto-calculated)
├─ metadata (JSON - stores stages, waste_weight, etc.)
├─ belongsTo: business
├─ belongsTo: department
├─ belongsTo: operator (User)
├─ belongsToMany: inputBatches (pivot: conversion_inputs)
└─ hasOne: batchCreated
// Scopes:
Conversion::forBusiness($businessId)
Conversion::ofType('washing')
Conversion::forUserDepartments($user) // Filters by user's dept assignments
```
## Batches
```php
Batch
├─ id, business_id, component_id, conversion_id
├─ quantity, unit
├─ batch_number (metrc tracking)
├─ belongsTo: business
├─ belongsTo: component
├─ belongsTo: conversion (nullable - which conversion created this)
└─ belongsToMany: conversions (as inputs via conversion_inputs)
// Flow:
// Input batches → Conversion → Output batch
$conversion->inputBatches() // Used in conversion
$conversion->batchCreated() // Created by conversion
```
## Components
```php
Component
├─ id, business_id, name, type
├─ belongsTo: business
└─ hasMany: batches
// Examples:
- Fresh Frozen (type: raw_material)
- Hash 6star (type: intermediate)
- Rosin (type: finished_good)
// CRITICAL: Always scope by business
Component::where('business_id', $business->id)->findOrFail($id)
```
## Products
```php
Product
├─ id, brand_id, name, sku
├─ belongsTo: brand
├─ belongsTo: business (through brand)
└─ hasMany: orderItems
// Products are sold to buyers
// Different from Components (internal inventory)
```
## Orders
```php
Order
├─ id, business_id (buyer's business)
├─ belongsTo: business (buyer)
├─ hasMany: items
└─ Each item->product->brand->business is the seller
// Cross-business relationship:
// Buyer's order → contains items → from seller's products
```
## Work Orders
```php
WorkOrder
├─ id, business_id, department_id
├─ assigned_to (user_id)
├─ related_conversion_id (nullable)
├─ belongsTo: business
├─ belongsTo: department
├─ belongsTo: assignedUser
└─ belongsTo: conversion (nullable)
// Work orders can trigger conversions
// Users see work orders for their departments only
```
## Critical Relationships
### User → Business → Department Flow
```php
// User can access multiple businesses via department assignments
$user->departments->pluck('business_id')->unique()
// User can be in multiple departments across multiple businesses
$user->departments()->where('business_id', $business->id)->get()
```
### Conversion → Batch Flow
```php
// Creating a conversion:
1. Select input batches (existing inventory)
2. Perform conversion
3. Create output batch(es)
4. Link everything together
$conversion->inputBatches()->attach($batchId, [
'quantity_used' => 500,
'unit' => 'g',
'role' => 'primary_input',
]);
$outputBatch = Batch::create([
'business_id' => $business->id,
'component_id' => $outputComponentId,
'conversion_id' => $conversion->id,
'quantity' => 75,
'unit' => 'g',
]);
```
### Department-Based Access Pattern
```php
// Always check three things for data access:
1. Business scoping: where('business_id', $business->id)
2. Department scoping: whereIn('department_id', $user->departments->pluck('id'))
3. User authorization: $user->departments->contains($departmentId)
// Example:
$conversions = Conversion::where('business_id', $business->id)
->whereIn('department_id', $user->departments->pluck('id'))
->get();
// Or using scope:
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->get();
```
## Model Scopes Reference
```php
// Business
Business::isParentCompany()
// Conversion
Conversion::forBusiness($businessId)
Conversion::ofType($type)
Conversion::forUserDepartments($user)
// Always combine scopes:
Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->ofType('washing')
->get();
```
## Common Query Patterns
```php
// Get all conversions for user's departments
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments(auth()->user())
->with(['department', 'operator', 'inputBatches', 'batchCreated'])
->latest('started_at')
->paginate(25);
// Get user's departments in current business
$userDepartments = auth()->user()
->departments()
->where('business_id', $business->id)
->get();
// Check if user can access department
$canAccess = auth()->user()
->departments
->contains($departmentId);
// Get all departments user manages
$managedDepts = auth()->user()
->departments()
->wherePivot('role', 'manager')
->get();
```
## Validation Patterns
```php
// When creating conversion, validate user has dept access
$validated = $request->validate([
'department_id' => 'required|exists:departments,id',
// ...
]);
// Security check
if (!auth()->user()->departments->contains($validated['department_id'])) {
abort(403, 'You are not assigned to this department.');
}
// Ensure conversion belongs to business AND user's departments
if ($conversion->business_id !== $business->id) {
abort(404);
}
if (!auth()->user()->departments->contains($conversion->department_id)) {
abort(403, 'Not authorized to view this conversion.');
}
```

251
.claude/PROCESSING.md Normal file
View File

@@ -0,0 +1,251 @@
# Processing Operations Architecture
## Processing vs Manufacturing
**Processing** = Biomass transformation (fresh frozen → hash → rosin)
- Ice water extraction (washing)
- Pressing (hash → rosin)
- BHO extraction
- Distillation
- Winterization
**Manufacturing** = Component assembly into finished products
- Packaging
- Product creation from components
- BOMs (Bill of Materials)
## Department Types
### Solventless Departments (CRG-SOLV, LAZ-SOLV)
**Processes:**
- Ice water extraction (washing) - Fresh Frozen → Hash grades
- Pressing - Hash → Rosin
- Dry sifting - Flower → Kief
- Flower rosin - Flower → Rosin
**Materials:**
- Input: Fresh Frozen, Cured Flower, Trim
- Output: Hash (6star, 5star, 4star, 3star, 73-159u, 160-219u), Rosin, Kief
**No solvents used** - purely mechanical/physical separation
### BHO Departments (CRG-BHO)
**Processes:**
- Closed-loop extraction - Biomass → Crude/BHO
- Distillation - Crude → Distillate
- Winterization - Extract → Refined
- Filtration
**Materials:**
- Input: Biomass (fresh or cured), Crude
- Output: BHO, Crude, Distillate, Wax, Shatter, Live Resin
**Uses chemical solvents** - butane, propane, CO2, ethanol
## Conversions System
**Core Model: `Conversion`**
```php
$conversion = Conversion::create([
'business_id' => $business->id,
'department_id' => $department->id, // REQUIRED
'conversion_type' => 'washing', // washing, pressing, extraction, distillation, etc.
'internal_name' => 'Blue Dream Hash Wash #20251114-143000',
'started_at' => now(),
'status' => 'in_progress',
'input_weight' => 1000.00, // grams
'output_weight' => 150.00, // grams (calculated from batches)
'yield_percentage' => 15.00, // auto-calculated
'metadata' => [
'waste_weight' => 50.00,
'stages' => [
['stage_type' => 'extraction', 'temperature' => 32, ...],
],
],
]);
```
**Relationships:**
- `inputBatches()` - Many-to-many via `conversion_inputs` pivot
- `batchCreated()` - Has one output batch
- `department()` - Belongs to department
- `operator()` - Belongs to user (who performed it)
## Wash Batches vs Conversions
**Current State:** Two systems doing the same thing
**Wash System** (existing):
- Fresh Frozen → Multiple hash grades
- Stage 1 and Stage 2 tracking
- Multiple outputs with quality grades
- Very specialized UI
**Conversions System** (new):
- Generic input → output tracking
- Works for ALL conversion types
- Department-scoped
- Yield and waste analytics
**Integration Plan:**
- Wash batches ARE conversions with `conversion_type = 'washing'`
- Keep specialized wash UI
- When wash batch created, also creates conversion record
- Wash batches show up in conversion history
- Conversions dashboard shows wash yields alongside other yields
## Data Filtering Rules
**CRITICAL:** Conversions MUST be filtered by department
```php
// Get conversions for user's departments
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments(auth()->user())
->get();
// Vinny (CRG-SOLV) only sees solventless conversions
// BHO tech only sees BHO conversions
```
**In Controllers:**
```php
public function index(Business $business)
{
$user = auth()->user();
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->with(['department', 'operator', 'inputBatches', 'batchCreated'])
->orderBy('started_at', 'desc')
->paginate(25);
return view('seller.processing.conversions.index', compact('business', 'conversions'));
}
```
## Conversion Types by Department
**Solventless:**
- `washing` - Fresh Frozen → Hash
- `pressing` - Hash → Rosin
- `sifting` - Flower → Kief
- `flower_rosin` - Flower → Rosin
**BHO:**
- `extraction` - Biomass → Crude/BHO
- `distillation` - Crude → Distillate
- `winterization` - Extract → Refined
- `filtration` - Various refinement
**Shared:**
- `other` - Custom processes
## Yield Tracking
**Yield Percentage:**
```php
$yield = ($output_weight / $input_weight) * 100;
```
**Performance Thresholds:**
- Excellent: ≥80%
- Good: 60-79%
- Below Average: <60%
**Waste Tracking:**
```php
$waste = $input_weight - $output_weight;
$wastePercent = ($waste / $input_weight) * 100;
```
**Stored in metadata:**
```php
$conversion->metadata = [
'waste_weight' => 50.00,
// Other metadata...
];
```
## Stage Tracking
Conversions can have multiple processing stages stored in metadata:
```php
$conversion->metadata = [
'stages' => [
[
'stage_name' => 'Initial Extraction',
'stage_type' => 'extraction',
'start_time' => '2025-11-14 10:00:00',
'end_time' => '2025-11-14 12:00:00',
'temperature' => 32,
'pressure' => 1500,
'notes' => 'Used 5 micron bags',
],
[
'stage_name' => 'Refinement',
'stage_type' => 'filtration',
// ...
],
],
];
```
## Components vs Batches
**Components:** Inventory types (Fresh Frozen, Hash 6star, Rosin, etc.)
- Master data
- Defines what can be used in conversions
**Batches:** Actual inventory units
- Specific quantities
- Traceability
- Created from conversions
**Example Flow:**
```
1. Start with Fresh Frozen batch (500g)
2. Create conversion (washing)
3. Conversion creates new batches:
- Hash 6star batch (50g)
- Hash 5star batch (75g)
- Hash 4star batch (100g)
4. Each batch linked back to conversion
```
## Menu Structure
**Nested by department in sidebar:**
```
Processing
├─ My Work Orders (all departments)
├─ Solventless (if has CRG-SOLV or LAZ-SOLV)
│ ├─ Idle Fresh Frozen
│ ├─ Conversion history
│ ├─ Washing
│ ├─ Pressing
│ ├─ Yield percentages
│ └─ Waste tracking
└─ BHO (if has CRG-BHO)
├─ Conversion history
├─ Extraction
├─ Distillation
├─ Yield percentages
└─ Waste tracking
```
## Key Controllers
- `ConversionController` - CRUD for conversions, yields, waste dashboards
- `WashReportController` - Specialized wash batch interface
- `WorkOrderController` - Work order management (separate from conversions)
## Important Notes
1. **Department is required** - Every conversion must have a department_id
2. **User must be assigned** - Can only create conversions for departments they're in
3. **Business scoping** - Always filter by business_id first
4. **Department filtering** - Then filter by user's departments
5. **Yield auto-calculation** - Happens on save, based on input/output weights

177
.claude/ROUTING.md Normal file
View File

@@ -0,0 +1,177 @@
# Routing & Business Architecture
## Business Slug Routing Pattern
**All seller routes use business slug:**
```
/s/{business_slug}/dashboard
/s/{business_slug}/orders
/s/{business_slug}/processing/conversions
/s/{business_slug}/settings/users
```
**Route Binding:**
```php
Route::prefix('s/{business}')->name('seller.business.')->group(function () {
// Laravel automatically resolves {business} to Business model via slug column
Route::get('dashboard', [DashboardController::class, 'show'])->name('dashboard');
});
```
**In Controllers:**
```php
public function show(Business $business)
{
// $business is already resolved from slug
// User authorization already checked by middleware
// CRITICAL: Always scope queries by this business
$orders = Order::where('business_id', $business->id)->get();
}
```
## Business Hierarchy
### Parent Companies
```
Creationshop (parent_business_id = null)
├─ Curagreen (parent_business_id = creationshop_id)
├─ Lazarus (parent_business_id = creationshop_id)
└─ CBD Supply Co (parent_business_id = creationshop_id)
```
**Check if parent:**
```php
$business->isParentCompany() // Returns true if parent_business_id is null
```
**Parent companies see:**
- Executive Dashboard (cross-division)
- Manage Divisions menu item
- Aggregate analytics
### Subdivisions (Divisions)
Each subdivision has its own:
- Business record with unique slug
- Set of departments
- Users (can be shared across divisions)
- Products, inventory, components
**Key Principle:** Users can work in MULTIPLE subdivisions by being assigned to departments in each.
Example:
- User Vinny is in CRG-SOLV (Curagreen Solventless)
- Same user could also be in LAZ-SOLV (Lazarus Solventless)
- When Vinny views `/s/curagreen/processing/conversions`, he sees only Curagreen's solventless conversions
- When Vinny views `/s/lazarus/processing/conversions`, he sees only Lazarus's solventless conversions
## Cross-Division Security
**CRITICAL: Business slug in URL determines scope**
```php
// URL: /s/curagreen/processing/conversions/123
// Route binding resolves to Curagreen business
public function show(Business $business, Conversion $conversion)
{
// MUST verify conversion belongs to THIS business
if ($conversion->business_id !== $business->id) {
abort(404, 'Conversion not found.');
}
// Also verify user has department access
if (!auth()->user()->departments->contains($conversion->department_id)) {
abort(403, 'Not authorized.');
}
}
```
**Why this matters:** Prevents URL manipulation attacks:
- User tries to access `/s/curagreen/processing/conversions/456`
- Conversion 456 belongs to Lazarus
- Check fails, returns 404
- User cannot enumerate other division's data
## Route Middleware Patterns
```php
// All seller routes need authentication + seller type
Route::middleware(['auth', 'verified', 'seller', 'approved'])->group(function () {
// Business-scoped routes
Route::prefix('s/{business}')->group(function () {
// Routes here...
});
});
```
## Department-Scoped Routes
**Pattern for department-filtered data:**
```php
Route::get('processing/conversions', function (Business $business) {
$user = auth()->user();
// Get conversions for THIS business AND user's departments
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->get();
return view('seller.processing.conversions.index', compact('business', 'conversions'));
});
```
## Common Route Parameters
- `{business}` - Business model (resolved via slug)
- `{conversion}` - Conversion model (resolved via ID)
- `{order}` - Order model
- `{product}` - Product model
- `{component}` - Component model
**Always verify related model belongs to business:**
```php
// WRONG - security hole
$component = Component::findOrFail($id);
// RIGHT - scoped to business first
$component = Component::where('business_id', $business->id)
->findOrFail($id);
```
## URL Structure Examples
```
# Dashboard
/s/curagreen/dashboard
/s/lazarus/dashboard
# Processing (department-filtered)
/s/curagreen/processing/conversions
/s/curagreen/processing/conversions/create
/s/curagreen/processing/conversions/yields
/s/curagreen/processing/idle-fresh-frozen
# Settings (owner-only)
/s/curagreen/settings/company-information
/s/curagreen/settings/users
/s/curagreen/settings/notifications
# Ecommerce (sales dept or owner)
/s/curagreen/orders
/s/curagreen/products
/s/curagreen/customers
# Parent company features
/s/creationshop/executive/dashboard
/s/creationshop/corporate/divisions
```
## Business Switcher
Users can switch between businesses they have access to:
- Component: `<x-brand-switcher />`
- Shows in sidebar
- Changes URL slug to switch context
- User must have department assignment in target business

View File

@@ -0,0 +1,21 @@
---
description: End the coding session by updating the session tracker and preparing for next time
---
# Ending Session
Please do the following:
1. Check for uncommitted changes with `git status`
2. If there are uncommitted changes, ask me if I want to commit them
3. Update the `SESSION_ACTIVE` file with:
- What was completed today
- What task we're currently on (if unfinished)
- What to do next session
- Any important context or decisions made
4. Note any known issues or blockers
5. If I want to commit:
- Stage the SESSION_ACTIVE file
- Create a descriptive commit message
- Commit the changes
6. Give me a summary of what we accomplished today

View File

@@ -0,0 +1,16 @@
---
description: Start a new coding session by reading the session tracker and continuing where we left off
---
# Starting New Session
Please do the following:
1. Read the `SESSION_ACTIVE` file to understand current state
2. Check git status and current branch
3. Summarize where we left off:
- What was completed in the last session
- What task we were working on
- What's next on the list
4. Ask me what I want to work on today
5. Create a todo list for today's work using the TodoWrite tool

View File

@@ -1,27 +0,0 @@
{
"permissions": {
"allow": [
"Bash(test:*)",
"Bash(docker exec:*)",
"Bash(docker stats:*)",
"Bash(docker logs:*)",
"Bash(docker-compose down:*)",
"Bash(docker-compose up:*)",
"Bash(php --version:*)",
"Bash(docker-compose build:*)",
"Bash(docker-compose restart:*)",
"Bash(find:*)",
"Bash(docker ps:*)",
"Bash(php -l:*)",
"Bash(curl:*)",
"Bash(cat:*)",
"Bash(docker update:*)",
"Bash(grep:*)",
"Bash(sed:*)",
"Bash(php artisan:*)",
"Bash(php check_blade.php:*)"
],
"deny": [],
"ask": []
}
}

3
.gitignore vendored
View File

@@ -30,6 +30,9 @@ yarn-error.log
# Node symlink (for ARM-based machines)
/node
# Git worktrees directory
/.worktrees/
# Database backups
*.gz
*.sql.gz

1710
01-analytics-system.md Normal file

File diff suppressed because it is too large Load Diff

337
ANALYTICS_IMPLEMENTATION.md Normal file
View File

@@ -0,0 +1,337 @@
# Analytics System Implementation - Complete
## Overview
Comprehensive analytics system for Cannabrands B2B marketplace with multi-tenancy security, real-time notifications, and advanced buyer intelligence.
## IMPORTANT: Automatic Tracking for All Buyer Pages
**Analytics tracking is AUTOMATIC and requires NO CODE in individual views.**
- Tracking is included in the buyer layout file: `layouts/buyer-app-with-sidebar.blade.php`
- Any page that extends this layout automatically gets tracking (page views, scroll depth, time on page, sessions)
- When creating new buyer pages, simply extend the layout - tracking is automatic
- NO need to add analytics code to individual views
```blade
{{-- Example: Any new buyer page --}}
@extends('layouts.buyer-app-with-sidebar')
@section('content')
{{-- Your content here - tracking happens automatically --}}
@endsection
```
**Optional:** Add `data-track-click` attributes to specific elements for granular click tracking, but basic analytics work without any additional code.
## Features Implemented
### 1. Multi-Tenancy Security
- **BusinessScope**: All analytics models use global scope for automatic data isolation
- **Auto-scoping**: business_id automatically set on model creation
- **Permission System**: Granular analytics permissions via business_user.permissions JSON
- **Cross-Business Protection**: Users cannot access other businesses' analytics data
### 2. Analytics Models (10 Total)
All models in `app/Models/Analytics/`:
1. **AnalyticsEvent** - Raw event stream (all interactions)
2. **ProductView** - Product engagement tracking
3. **EmailCampaign** - Email campaign management
4. **EmailInteraction** - Individual recipient tracking
5. **EmailClick** - Email link click tracking
6. **ClickTracking** - General click event tracking
7. **UserSession** - Session tracking and conversion funnel
8. **IntentSignal** - High-intent buyer detection
9. **BuyerEngagementScore** - Calculated engagement metrics
10. **EmailCampaign** - (already mentioned above)
### 3. Database Migrations (7 Tables)
All in `database/migrations/2025_11_08_*`:
- `analytics_events` - Event stream
- `product_views` - Product engagement
- `email_campaigns`, `email_interactions`, `email_clicks` - Email analytics
- `click_tracking` - Click events
- `user_sessions`, `intent_signals`, `buyer_engagement_scores` - Buyer intelligence
**Important**: All composite indexes start with `business_id` for query performance.
### 4. Services & Jobs
**AnalyticsTracker Service** (`app/Services/AnalyticsTracker.php`):
- `trackProductView()` - Track product page views with signals
- `trackClick()` - Track general click events
- `trackEmailInteraction()` - Track email actions
- `startSession()` - Initialize/update user sessions
- `detectIntentSignals()` - Automatic high-intent detection
**Queue Jobs**:
- `CalculateEngagementScore` - Compute buyer engagement scores
- `ProcessAnalyticsEvent` - Async event processing
### 5. Real-Time Features
**Reverb Event** (`app/Events/HighIntentBuyerDetected.php`):
- Broadcasts when high-intent signals detected
- Channel: `business.{id}.analytics`
- Event: `high-intent-buyer-detected`
### 6. Controllers (5 Total)
All in `app/Http/Controllers/Analytics/`:
1. **AnalyticsDashboardController** - Overview dashboard
2. **ProductAnalyticsController** - Product performance
3. **MarketingAnalyticsController** - Email campaigns
4. **SalesAnalyticsController** - Sales funnel
5. **BuyerIntelligenceController** - Buyer engagement
### 7. Views (4 Main Pages)
All in `resources/views/seller/analytics/`:
- `dashboard.blade.php` - Analytics overview
- `products.blade.php` - Product analytics
- `marketing.blade.php` - Marketing analytics
- `sales.blade.php` - Sales analytics
- `buyers.blade.php` - Buyer intelligence
**Design**:
- DaisyUI/Nexus components
- ApexCharts for visualizations
- Anime.js for counter animations
- Responsive grid layouts
### 8. Navigation
Updated `resources/views/components/seller-sidebar.blade.php`:
- Dashboard - Single top-level item
- Analytics - Parent with subsections (Products, Marketing, Sales, Buyers)
- Reports - Separate future section
- Permission-based visibility
### 9. Permissions System
**Available Permissions**:
- `analytics.overview` - Main dashboard
- `analytics.products` - Product analytics
- `analytics.marketing` - Marketing analytics
- `analytics.sales` - Sales analytics
- `analytics.buyers` - Buyer intelligence
- `analytics.export` - Data export
**Permission UI** (`resources/views/business/users/index.blade.php`):
- Modal for managing user permissions
- Available to business owners only
- Real-time updates via AJAX
### 10. Client-Side Tracking
**analytics-tracker.js**:
- Automatic page view tracking
- Scroll depth tracking
- Time on page tracking
- Click tracking via `data-track-click` attributes
- ProductPageTracker for enhanced product tracking
**reverb-analytics-listener.js**:
- Real-time high-intent buyer notifications
- Toast notifications
- Auto-navigation to buyer details
- Notification badge updates
### 11. Security Tests
Comprehensive test suite in `tests/Feature/Analytics/AnalyticsSecurityTest.php`:
- ✓ Data scoped to business
- ✓ Permission enforcement
- ✓ Cross-business access prevention
- ✓ Auto business_id assignment
- ✓ forBusiness scope functionality
## Installation Steps
### 1. Run Composer Autoload
```bash
docker compose exec laravel.test composer dump-autoload
```
### 2. Run Migrations
```bash
docker compose exec laravel.test php artisan migrate
```
### 3. Include JavaScript Files
Add to your layout file:
```html
<script src="{{ asset('js/analytics-tracker.js') }}"></script>
<script src="{{ asset('js/reverb-analytics-listener.js') }}"></script>
<!-- Add business ID meta tag -->
<meta name="business-id" content="{{ currentBusinessId() }}">
```
### 4. Queue Configuration
Ensure Redis queues are configured in `.env`:
```env
QUEUE_CONNECTION=redis
```
Start queue worker:
```bash
docker compose exec laravel.test php artisan queue:work --queue=analytics
```
### 5. Reverb Configuration
Reverb should already be configured. Verify broadcasting is enabled:
```env
BROADCAST_DRIVER=reverb
```
## Usage
### Assigning Analytics Permissions
1. Navigate to Business > Users
2. Click "Permissions" button on user card (owner only)
3. Select analytics permissions
4. Save
### Tracking Product Views (Server-Side)
```php
use App\Services\AnalyticsTracker;
$tracker = new AnalyticsTracker();
$tracker->trackProductView($product, [
'zoomed_image' => true,
'time_on_page' => 120,
'added_to_cart' => true
]);
```
### Tracking Product Views (Client-Side)
```html
<!-- Product page -->
<script>
const productTracker = new ProductPageTracker({{ $product->id }});
</script>
<!-- Mark trackable elements -->
<button data-product-add-cart>Add to Cart</button>
<a data-product-spec-download href="/spec.pdf">Download Spec</a>
<div data-product-image-zoom>
<img src="/product.jpg">
</div>
```
### Tracking Clicks
```html
<button data-track-click="cta_button"
data-track-id="123"
data-track-label="Buy Now">
Buy Now
</button>
```
### Listening to Real-Time Events
```javascript
window.addEventListener('analytics:high-intent-buyer', (event) => {
console.log('High intent buyer:', event.detail);
// Update UI, show notification, etc.
});
```
### Calculating Engagement Scores
```php
use App\Jobs\CalculateEngagementScore;
CalculateEngagementScore::dispatch($sellerBusinessId, $buyerBusinessId)
->onQueue('analytics');
```
## API Endpoints
### Analytics Routes
All routes prefixed with `/s/{business}/analytics`:
- `GET /` - Analytics dashboard
- `GET /products` - Product analytics
- `GET /products/{product}` - Product detail
- `GET /marketing` - Marketing analytics
- `GET /marketing/campaigns/{campaign}` - Campaign detail
- `GET /sales` - Sales analytics
- `GET /buyers` - Buyer intelligence
- `GET /buyers/{buyer}` - Buyer detail
### Permission Management
- `POST /s/{business}/users/{user}/permissions` - Update user permissions
## Database Indexes
All analytics tables have composite indexes starting with `business_id`:
- `idx_business_created` - (business_id, created_at)
- `idx_business_type_created` - (business_id, event_type, created_at)
- `idx_business_product_time` - (business_id, product_id, viewed_at)
This ensures optimal query performance for multi-tenant queries.
## Helper Functions
Global helpers available everywhere:
```php
// Get current business
$business = currentBusiness();
// Get current business ID
$businessId = currentBusinessId();
// Check permission
if (hasBusinessPermission('analytics.overview')) {
// User has permission
}
// Get business from product
$sellerBusiness = \App\Helpers\BusinessHelper::fromProduct($product);
```
## Git Commits
Total of 6 commits:
1. **Foundation** - Helpers, migrations, base models
2. **Backend Logic** - Remaining models, services, jobs, events, controllers, routes
3. **Navigation** - Updated seller sidebar
4. **Views** - 4 analytics dashboards
5. **Permissions** - User permission management UI
6. **JavaScript** - Client-side tracking and Reverb listeners
7. **Tests** - Security test suite
## Testing
Run analytics security tests:
```bash
docker compose exec laravel.test php artisan test tests/Feature/Analytics/AnalyticsSecurityTest.php
```
## Notes
- All analytics data is scoped to business_id automatically via BusinessScope
- Permission checks use `hasBusinessPermission()` helper
- High-intent signals trigger real-time Reverb events
- Engagement scores calculated asynchronously via queue
- Client-side tracking uses sendBeacon for reliability
- All views use DaisyUI components (no inline styles)
## Next Steps (Optional Enhancements)
1. Add more granular permissions (view vs export)
2. Implement scheduled engagement score recalculation
3. Add email templates for high-intent buyer alerts
4. Create analytics export functionality (CSV/PDF)
5. Add custom date range selectors
6. Implement analytics API for third-party integrations
7. Add more chart types and visualizations
8. Create analytics widgets for main dashboard
## Support
For issues or questions:
- Check migration files for table structure
- Review controller methods for query patterns
- Examine test file for usage examples
- Check JavaScript console for client-side errors

196
ANALYTICS_QUICK_START.md Normal file
View File

@@ -0,0 +1,196 @@
# Analytics System - Quick Start Guide
## ✅ What's Already Done
### 1. Global Tracking (Automatic)
- Analytics tracker loaded on every page via `app-with-sidebar.blade.php`
- Automatically tracks:
- Page views
- Time spent on page
- Scroll depth
- User sessions
- All clicks with `data-track-click` attributes
### 2. Product Page Tracking (Implemented!)
- **File**: `buyer/marketplace/product.blade.php`
- **Tracks**:
- Product views (automatic on page load)
- Image zoom clicks (gallery images)
- Lab report downloads
- Add to cart button clicks
- Related product clicks
### 3. Real-Time Notifications (For Sellers)
- Reverb integration for high-intent buyer alerts
- Automatically enabled for sellers
- Toast notifications appear when buyers show buying signals
## 🚀 How to See It Working
### Step 1: Visit a Product Page
```
http://yoursite.com/b/{business}/brands/{brand}/products/{product}
```
### Step 2: Open Browser DevTools
- Press `F12`
- Go to **Network** tab
- Filter by: `track`
### Step 3: Interact with the Page
- Scroll down
- Click gallery images
- Click "Add to Cart"
- Click "View Report" on lab results
- Click related products
### Step 4: Check Network Requests
Look for POST requests to `/api/analytics/track` with payloads like:
```json
{
"event_type": "product_view",
"product_id": 123,
"session_id": "abc-123-def",
"timestamp": 1699123456789
}
```
### Step 5: View Analytics Dashboard
```
http://yoursite.com/s/{business-slug}/analytics
```
## 📊 What Data You'll See
### Product Analytics
Navigate to: **Analytics > Products**
View:
- Most viewed products
- Product engagement rates
- Image zoom rates
- Lab report download counts
- Add to cart conversion rates
### Buyer Intelligence
Navigate to: **Analytics > Buyers**
View:
- Active buyers this week
- High-intent buyers (with real-time alerts)
- Engagement scores
- Buyer activity timelines
### Click Heatmap Data
- All clicks tracked with element type, ID, and label
- Position tracking for understanding UX
- Referrer and UTM campaign tracking
## 🎯 Next Steps
### 1. Add Tracking to More Pages
**Marketplace/Catalog Pages:**
```blade
<div class="product-card">
<a href="{{ route('products.show', $product) }}"
data-track-click="product-card"
data-track-id="{{ $product->id }}"
data-track-label="{{ $product->name }}">
{{ $product->name }}
</a>
</div>
```
**Navigation Links:**
```blade
<a href="{{ route('brands.index') }}"
data-track-click="navigation"
data-track-label="Browse Brands">
Browse All Brands
</a>
```
**Filter/Sort Controls:**
```blade
<select data-track-click="filter"
data-track-id="category-filter"
data-track-label="Product Category">
<option>All Categories</option>
</select>
```
### 2. Grant Analytics Permissions
1. Go to: `Settings > Users`
2. Click "Permissions" on a user
3. Check analytics permissions:
- ✅ `analytics.overview` - Dashboard
- ✅ `analytics.products` - Product stats
- ✅ `analytics.buyers` - Buyer intelligence
- ✅ `analytics.marketing` - Email campaigns
- ✅ `analytics.sales` - Sales pipeline
- ✅ `analytics.export` - Export data
### 3. Test Real-Time Notifications (Sellers)
**Trigger Conditions:**
- View same product 3+ times
- View 5+ products from same brand
- Download lab reports
- Add items to cart
- Watch product videos (when implemented)
**Where Alerts Appear:**
- Toast notification (top-right)
- Bell icon badge (topbar)
- Buyer Intelligence page
## 🔧 Troubleshooting
### Not Seeing Tracking Requests?
1. Check browser console for errors
2. Verify `/js/analytics-tracker.js` loads correctly
3. Check CSRF token is present in page meta tags
### Analytics Dashboard Empty?
1. Ensure you've visited product pages as a buyer
2. Check database: `select * from analytics_events limit 10;`
3. Verify background jobs are running: `php artisan queue:work`
### No Real-Time Notifications?
1. Ensure Reverb server is running: `php artisan reverb:start`
2. Check business ID meta tag in page source
3. Verify Laravel Echo is initialized
## 📁 Key Files
### JavaScript
- `/public/js/analytics-tracker.js` - Main tracker
- `/public/js/reverb-analytics-listener.js` - Real-time listener
### Blade Templates
- `layouts/app-with-sidebar.blade.php` - Global setup
- `buyer/marketplace/product.blade.php` - Example implementation
### Controllers
- `app/Http/Controllers/Analytics/AnalyticsDashboardController.php`
- `app/Http/Controllers/Analytics/ProductAnalyticsController.php`
- `app/Http/Controllers/Analytics/BuyerIntelligenceController.php`
### Models
- `app/Models/Analytics/AnalyticsEvent.php`
- `app/Models/Analytics/ProductView.php`
- `app/Models/Analytics/ClickTracking.php`
- `app/Models/Analytics/BuyerEngagementScore.php`
## 📚 Documentation
- **Full Implementation Guide**: `ANALYTICS_IMPLEMENTATION.md`
- **Tracking Examples**: `ANALYTICS_TRACKING_EXAMPLES.md`
- **This Quick Start**: `ANALYTICS_QUICK_START.md`
## 🎉 You're Ready!
The analytics system is now live and collecting data. Start browsing product pages and watch the data flow into your analytics dashboard!

View File

@@ -0,0 +1,216 @@
# Analytics Tracking Implementation Examples
This guide shows you how to implement analytics tracking on your pages to start collecting data.
## 1. Auto-Tracking (Already Working!)
The analytics tracker is now automatically loaded on all pages via `app-with-sidebar.blade.php`. It already tracks:
- Page views
- Time on page
- Scroll depth
- Session management
- Any element with `data-track-click` attribute
## 2. Product Detail Page Tracking
Add this script to the **bottom** of your product blade file (`buyer/marketplace/product.blade.php`):
```blade
@push('scripts')
<script>
// Initialize product page tracker
const productTracker = new ProductPageTracker({{ $product->id }});
// The tracker automatically tracks:
// - Product views (done on initialization)
// - Image zoom clicks
// - Video plays
// - Spec downloads
// - Add to cart
// - Add to wishlist
</script>
@endpush
```
### Add Tracking Attributes to Product Page Elements
Update your product page HTML to include tracking attributes:
```blade
<!-- Image Gallery (for zoom tracking) -->
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-75"
data-product-image-zoom>
<img src="{{ asset('storage/' . $image->path) }}" alt="{{ $product->name }}">
</div>
<!-- Product Videos (for video tracking) -->
<video controls data-product-video>
<source src="{{ asset('storage/' . $video->path) }}" type="video/mp4">
</video>
<!-- Download Spec Sheet Button -->
<a href="{{ route('buyer.products.spec-download', $product) }}"
data-product-spec-download
class="btn btn-outline">
<span class="icon-[lucide--download] size-4"></span>
Download Spec Sheet
</a>
<!-- Add to Cart Button -->
<button type="submit"
data-product-add-cart
class="btn btn-primary btn-block">
<span class="icon-[lucide--shopping-cart] size-5"></span>
Add to Cart
</button>
<!-- Add to Wishlist Button -->
<button type="button"
data-product-add-wishlist
class="btn btn-ghost">
<span class="icon-[lucide--heart] size-5"></span>
Save for Later
</button>
```
## 3. Generic Click Tracking
Track any button or link by adding the `data-track-click` attribute:
```blade
<!-- Track navigation clicks -->
<a href="{{ route('buyer.brands.index') }}"
data-track-click="navigation"
data-track-id="brands-link"
data-track-label="View All Brands">
Browse Brands
</a>
<!-- Track CTA buttons -->
<button data-track-click="cta"
data-track-id="contact-seller"
data-track-label="Contact Seller Button">
Contact Seller
</button>
<!-- Track filters -->
<select data-track-click="filter"
data-track-id="category-filter"
data-track-label="Category Filter">
<option>All Categories</option>
</select>
```
### Click Tracking Attributes
- `data-track-click="type"` - Element type (required): `navigation`, `cta`, `filter`, `button`, etc.
- `data-track-id="unique-id"` - Unique identifier for this element (optional)
- `data-track-label="Label"` - Human-readable label (optional, defaults to element text)
- `data-track-url="url"` - Destination URL (optional, auto-detected for links)
## 4. Dashboard/Catalog Page Tracking
For product listings and catalogs, add click tracking to product cards:
```blade
@foreach($products as $product)
<div class="card">
<!-- Track product card clicks -->
<a href="{{ route('buyer.products.show', $product) }}"
data-track-click="product-card"
data-track-id="{{ $product->id }}"
data-track-label="{{ $product->name }}"
class="card-body">
<img src="{{ $product->image_url }}" alt="{{ $product->name }}">
<h3>{{ $product->name }}</h3>
<p>${{ $product->wholesale_price }}</p>
</a>
<!-- Track quick actions -->
<button data-track-click="quick-add"
data-track-id="{{ $product->id }}"
data-track-label="Quick Add - {{ $product->name }}"
class="btn btn-sm">
Quick Add
</button>
</div>
@endforeach
```
## 5. Viewing Analytics Data
Once tracking is implemented, data will flow to:
1. **Analytics Dashboard**: `https://yoursite.com/s/{business-slug}/analytics`
2. **Product Analytics**: `https://yoursite.com/s/{business-slug}/analytics/products`
3. **Buyer Intelligence**: `https://yoursite.com/s/{business-slug}/analytics/buyers`
## 6. Backend API Endpoint
The tracker sends events to: `/api/analytics/track`
This endpoint is already set up and accepts:
```json
{
"event_type": "product_view|click|product_signal",
"product_id": 123,
"element_type": "button",
"element_id": "add-to-cart",
"element_label": "Add to Cart",
"session_id": "uuid",
"timestamp": 1234567890
}
```
## 7. Real-Time Notifications (Sellers Only)
For sellers with Reverb enabled, high-intent buyer alerts will automatically appear when:
- A buyer views multiple products from your brand
- A buyer repeatedly views the same product
- A buyer downloads spec sheets
- A buyer adds items to cart
- A buyer watches product videos
Notifications appear as toast messages and in the notification dropdown.
## 8. Permission Setup
To grant users access to analytics:
1. Go to **Users** page in your business dashboard
2. Click "Permissions" on a user card
3. Check the analytics permissions you want to grant:
- `analytics.overview` - Dashboard access
- `analytics.products` - Product performance
- `analytics.marketing` - Email campaigns
- `analytics.sales` - Sales intelligence
- `analytics.buyers` - Buyer insights
- `analytics.export` - Export data
## Quick Start Checklist
- [x] Analytics scripts loaded in layout ✅ (Done automatically)
- [ ] Add product page tracker to product detail pages
- [ ] Add tracking attributes to product images/videos/buttons
- [ ] Add click tracking to navigation and CTAs
- [ ] Add tracking to product cards in listings
- [ ] Grant analytics permissions to team members
- [ ] Visit analytics dashboard to see data
## Testing
To test if tracking is working:
1. Open browser DevTools (F12)
2. Go to Network tab
3. Navigate to a product page
4. Look for POST requests to `/api/analytics/track`
5. Check the payload to see what data is being sent
## Need Help?
Check `ANALYTICS_IMPLEMENTATION.md` for full technical documentation.

120
CLAUDE.local.md Normal file
View File

@@ -0,0 +1,120 @@
# Local Workflow Notes (NOT COMMITTED TO GIT)
## 🚨 MANDATORY WORKTREES WORKFLOW
**CRITICAL:** Claude MUST use worktrees for ALL feature work. NO exceptions.
### Worktrees Location
- **Main repo:** `C:\Users\Boss Man\Documents\GitHub\hub`
- **Worktrees folder:** `C:\Users\Boss Man\Documents\GitHub\Work Trees\`
---
## Claude's Proactive Workflow Guide
### When User Requests a Feature
**Claude MUST immediately say:**
> "Let me create a worktree for this feature"
**Then Claude MUST:**
1. **Create worktree with descriptive name:**
```bash
cd "C:\Users\Boss Man\Documents\GitHub\hub"
git worktree add "../Work Trees/feature-descriptive-name" -b feature/descriptive-name
```
2. **Switch to worktree:**
```bash
cd "C:\Users\Boss Man\Documents\GitHub\Work Trees/feature-descriptive-name"
```
3. **Work on that ONE feature only:**
- Keep focused on single feature
- Commit regularly with clear messages
- Run tests frequently
### When Feature is Complete
**Claude MUST prompt user:**
> "Feature complete! Ready to create a PR?"
**Then Claude MUST:**
1. **Run tests first:**
```bash
./vendor/bin/pint
php artisan test --parallel
```
2. **Push branch:**
```bash
git push -u origin feature/descriptive-name
```
3. **Create PR with good description:**
```bash
gh pr create --title "Feature: description" --body "$(cat <<'EOF'
## Summary
- Bullet point summary
## Changes
- What was added/modified
## Test Plan
- How to test
🤖 Generated with Claude Code
EOF
)"
```
### After PR is Merged
**Claude MUST help cleanup:**
1. **Return to main repo:**
```bash
cd "C:\Users\Boss Man\Documents\GitHub\hub"
```
2. **Remove worktree:**
```bash
git worktree remove "../Work Trees/feature-descriptive-name"
```
3. **Delete local branch:**
```bash
git branch -d feature/descriptive-name
```
4. **Pull latest develop:**
```bash
git checkout develop
git pull origin develop
```
---
## Why Worktrees are Mandatory
- ✅ **Isolation:** Each feature has its own directory
- ✅ **No conflicts:** Work on multiple features safely
- ✅ **Clean commits:** No mixing of changes
- ✅ **Safety:** Main repo stays clean on develop
- ✅ **Easy PR workflow:** One worktree = one PR
---
## Emergency: Uncommitted Work in Main Repo
If there are uncommitted changes in main repo:
1. **Best:** Commit to feature branch first
2. **Alternative:** Stash them: `git stash`
3. **Last resort:** Ask user what to do
---
**Note:** This file is in `.gitignore` and will never be committed or pushed to remote.

View File

@@ -1,5 +1,11 @@
# Claude Code Context
## 📌 IMPORTANT: Check Personal Context Files
**ALWAYS read `claude.kelly.md` first** - Contains personal preferences and session tracking workflow
---
## 🚨 Critical Mistakes You Make
### 1. Business Isolation (MOST COMMON!)
@@ -104,8 +110,15 @@ Product::where('is_active', true)->get(); // No business_id filter!
---
## External Docs (Read When Needed)
## Architecture Docs (Read When Needed)
**Custom Architecture:**
- `.claude/DEPARTMENTS.md` - Department system, permissions, access control
- `.claude/ROUTING.md` - Business slug routing, subdivision architecture
- `.claude/PROCESSING.md` - Processing operations (Solventless vs BHO, conversions, wash batches)
- `.claude/MODELS.md` - Key models, relationships, query patterns
**Standard Docs:**
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
- `docs/DATABASE.md` - **READ BEFORE** migrations
- `docs/DEVELOPMENT.md` - Local setup

View File

@@ -239,6 +239,163 @@ git push origin feature/my-feature
git push --no-verify
```
### Keeping Your Feature Branch Up-to-Date
**Best practice for teams:** Sync your feature branch with `develop` regularly to avoid large merge conflicts.
#### Daily Start-of-Work Routine
```bash
# 1. Get latest changes from develop
git checkout develop
git pull origin develop
# 2. Update your feature branch
git checkout feature/my-feature
git merge develop
# 3. If there are conflicts (see below), resolve them
# 4. Continue working
```
**How often?**
- Minimum: Once per day (start of work)
- Better: Multiple times per day if develop is active
- Always: Before creating your Pull Request
#### Merge vs Rebase: Which to Use?
**For teams of 5+ developers, use `merge` (not `rebase`):**
```bash
git checkout feature/my-feature
git merge develop
```
**Why merge over rebase?**
- ✅ Safer: Preserves your commit history
- ✅ Collaborative: Works when multiple people work on the same feature branch
- ✅ Transparent: Shows when you integrated upstream changes
- ✅ No force-push: Once you've pushed to origin, merge won't require `--force`
**When to use rebase:**
- ⚠️ Only if you haven't pushed yet
- ⚠️ Only if you're the sole developer on the branch
- ⚠️ You want a cleaner, linear history
```bash
# Only do this if you haven't pushed yet!
git checkout feature/my-feature
git rebase develop
```
**Never rebase after pushing** - it rewrites history and breaks collaboration.
#### Handling Merge Conflicts
When you run `git merge develop` and see conflicts:
```bash
$ git merge develop
Auto-merging app/Http/Controllers/OrderController.php
CONFLICT (content): Merge conflict in app/Http/Controllers/OrderController.php
Automatic merge failed; fix conflicts and then commit the result.
```
**Step-by-step resolution:**
1. **See which files have conflicts:**
```bash
git status
# Look for "both modified:" files
```
2. **Open conflicted files** - look for conflict markers:
```php
<<<<<<< HEAD
// Your code
=======
// Code from develop
>>>>>>> develop
```
3. **Resolve conflicts** - edit the file to keep what you need:
```php
// Choose your code, their code, or combine both
// Remove the <<<, ===, >>> markers
```
4. **Mark as resolved:**
```bash
git add app/Http/Controllers/OrderController.php
```
5. **Complete the merge:**
```bash
git commit -m "merge: resolve conflicts with develop"
```
6. **Run tests to ensure nothing broke:**
```bash
./vendor/bin/sail artisan test
```
7. **Push the merge commit:**
```bash
git push origin feature/my-feature
```
#### When Conflicts Are Too Complex
If conflicts are extensive or you're unsure:
1. **Abort the merge:**
```bash
git merge --abort
```
2. **Ask for help** in #engineering Slack:
- "I'm merging develop into feature/X and have conflicts in OrderController"
- Someone might have context on the upstream changes
3. **Pair program the resolution** - screen share with the person who made the conflicting changes
4. **Alternative: Start fresh** (last resort):
```bash
# Create new branch from latest develop
git checkout develop
git pull origin develop
git checkout -b feature/my-feature-v2
# Cherry-pick your commits
git cherry-pick <commit-hash>
```
#### Example: Multi-Day Feature Work
```bash
# Monday morning
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Get latest changes
# Work all day, make commits
# Tuesday morning
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Sync again (someone added auth changes)
# Continue working
# Wednesday
git checkout develop && git pull origin develop
git checkout feature/payment-integration
git merge develop # Final sync before PR
git push origin feature/payment-integration
# Create Pull Request
```
**Result:** Small, manageable syncs instead of one huge conflict on PR day.
### When to Test Locally
**Always run tests before pushing if you:**

587
EXECUTIVE_ACCESS_GUIDE.md Normal file
View File

@@ -0,0 +1,587 @@
# Executive Access Guide: Subdivisions & Department-Based Permissions
## Overview
This guide explains how Cannabrands handles multi-division organizations with department-based access control. It covers:
- **Parent Companies** with multiple **Subdivisions** (Divisions)
- **Department-Based Access Control** - users see only their department's data
- **Executive Override** - executives can view all divisions and departments
- **Cross-Department Visibility** for shared resources
## How Companies Work with Subdivisions
### Organizational Structure
**Parent Company** → Multiple **Divisions** → Multiple **Departments** → Multiple **Users**
**Example: Canopy AZ Group**
```
Canopy AZ Group (Parent Company)
├── Hash Factory AZ (Division)
│ ├── Executive (Department)
│ ├── Manufacturing (Department)
│ ├── Sales (Department)
│ └── Compliance (Department)
├── Leopard AZ (Division)
│ ├── Executive (Department)
│ ├── Manufacturing (Department)
│ ├── Sales (Department)
│ └── Fleet Management (Department)
└── Canopy Retail (Division)
├── Executive (Department)
├── Sales (Department)
└── Compliance (Department)
```
### Business Relationships
**Database Schema:**
- Each subdivision has `parent_id` pointing to the parent company
- Each subdivision has a `division_name` (e.g., "Hash Factory AZ")
- Parent company has `is_parent_company = true`
**Code Reference:** `app/Models/Business.php`
```php
// Check if this is a parent company
$business->isParentCompany()
// Get all divisions under this parent
$business->divisions()
// Get parent company (from a division)
$business->parent
```
## Department-Based Access Control
### Core Principle
**Users only see data related to their assigned department(s).**
This means:
- Manufacturing users see only manufacturing batches, wash reports, and conversions
- Sales users see only sales orders, customers, and invoices
- Compliance users see only compliance tracking and lab results
- Fleet users see only drivers, vehicles, and delivery routes
### User-Department Assignments
**Database:** `department_user` pivot table
```
department_user
├── department_id (Foreign Key → departments)
├── user_id (Foreign Key → users)
├── is_admin (Boolean) - Department administrator flag
└── timestamps
```
**A user can be assigned to multiple departments:**
- John works in both Manufacturing AND Compliance
- Sarah is Sales admin for Division A and Division B
- Mike is Executive (sees all departments)
### How Data Filtering Works
**File:** `app/Http/Controllers/DashboardController.php:416-424`
```php
// Step 1: Get user's assigned departments
$userDepartments = auth()->user()->departments()
->where('business_id', $business->id)
->pluck('departments.id');
// Step 2: Find operators in those departments
$allowedOperatorIds = User::whereHas('departments', function($q) use ($userDepartments) {
$q->whereIn('departments.id', $userDepartments);
})->pluck('id');
// Step 3: Filter data by those operators
$activeWashes = Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'in_progress')
->whereIn('operator_user_id', $allowedOperatorIds) // ← Department filtering
->get();
```
**Result:** Manufacturing users only see wash reports from other manufacturing users.
### Department Isolation Examples
#### Scenario 1: Manufacturing User
**User:** Mike (Department: Manufacturing)
**Can See:**
- ✅ Batches created by manufacturing team
- ✅ Wash reports from manufacturing operators
- ✅ Work orders assigned to manufacturing
- ✅ Purchase orders for manufacturing materials
- ❌ Sales orders (Sales department)
- ❌ Fleet deliveries (Fleet department)
#### Scenario 2: Sales User
**User:** Sarah (Department: Sales)
**Can See:**
- ✅ Customer orders and invoices
- ✅ Product catalog
- ✅ Sales reports and analytics
- ❌ Manufacturing batches (Manufacturing department)
- ❌ Compliance tracking (Compliance department)
#### Scenario 3: Multi-Department User
**User:** John (Departments: Manufacturing + Compliance)
**Can See:**
- ✅ Manufacturing batches and wash reports
- ✅ Compliance tracking and lab results
- ✅ COA (Certificate of Analysis) for batches
- ✅ Quarantine holds and releases
- ❌ Sales orders (not in Sales department)
- ❌ Fleet operations (not in Fleet department)
## Executive Access Override
### Who Are Executives?
**Executives** are users with special permissions to view ALL data across ALL departments and divisions.
**Common Executive Roles:**
- CEO / Owner
- CFO / Finance Director
- COO / Operations Director
- Corporate Administrator
### Executive Permissions
**File:** `app/Http/Controllers/DashboardController.php:408-411`
```php
// Executives bypass department filtering
if (auth()->user()->hasRole('executive')) {
// Get ALL departments in the business
$userDepartments = $business->departments->pluck('id');
}
```
**What This Means:**
- Executives see data from Manufacturing, Sales, Compliance, and ALL other departments
- Executives can access Executive Dashboard with consolidated metrics
- Executives can view corporate settings for all divisions
### Executive-Only Features
**1. Executive Dashboard**
- **Route:** `/s/{business}/executive/dashboard`
- **Controller:** `app/Http/Controllers/Seller/ExecutiveDashboardController.php`
- **View:** `resources/views/seller/executive/dashboard.blade.php`
**Shows:**
- Consolidated metrics across ALL divisions
- Division-by-division performance comparison
- Corporate-wide production analytics
- Cross-division resource utilization
**2. Corporate Settings**
- **Controller:** `app/Http/Controllers/Seller/CorporateSettingsController.php`
- **View:** `resources/views/seller/corporate/divisions.blade.php`
**Manage:**
- Division list and configuration
- Corporate resource allocation
- Cross-division policies
**3. Consolidated Analytics**
- **Controller:** `app/Http/Controllers/Seller/ConsolidatedAnalyticsController.php`
**Reports:**
- Total production across all manufacturing divisions
- Combined sales performance
- Corporate inventory levels
- Cross-division compliance status
## Permission Levels
### 1. Regular User (Department-Scoped)
**Access:** Only data from assigned department(s)
**Example:** Manufacturing operator sees only manufacturing batches
### 2. Department Administrator
**Access:** All data in department + department management
**Example:** Manufacturing Manager can assign users to Manufacturing department
**Flag:** `department_user.is_admin = true`
```php
// Check if user is department admin
$user->departments()
->where('department_id', $departmentId)
->wherePivot('is_admin', true)
->exists();
```
### 3. Division Owner
**Access:** All departments within their division
**Example:** "Hash Factory AZ" owner sees Manufacturing, Sales, Compliance for Hash Factory AZ only
**Implementation:**
```php
// Division owners have 'owner' role scoped to their business
if ($user->hasRole('owner') && $user->business_id === $business->id) {
// See all departments in this division
}
```
### 4. Corporate Executive
**Access:** All divisions + all departments + corporate features
**Example:** Canopy AZ Group CEO sees everything across Hash Factory AZ, Leopard AZ, and Canopy Retail
**Implementation:**
```php
// Corporate executives have 'executive' role at parent company level
if ($user->hasRole('executive') && $business->isParentCompany()) {
// Access to all divisions and departments
}
```
## Shared Departments Across Divisions
### Use Case: Shared Manufacturing Facility
**Scenario:** Hash Factory AZ and Leopard AZ share the same physical manufacturing facility with the same equipment and operators.
**Solution:** Users can be assigned to departments in MULTIPLE divisions.
**Example:**
**User:** Carlos (Manufacturing Operator)
**Department Assignments:**
- Hash Factory AZ → Manufacturing Department
- Leopard AZ → Manufacturing Department
**Result:**
- Carlos sees batches from both Hash Factory AZ and Leopard AZ
- Carlos can create wash reports for either division
- Dashboard shows combined data from both divisions
**Database:**
```
department_user table
├── id: 1, department_id: 5 (Hash Factory Manufacturing), user_id: 10 (Carlos)
└── id: 2, department_id: 12 (Leopard Manufacturing), user_id: 10 (Carlos)
```
**Code Implementation:**
```php
// Get all departments across all divisions for this user
$userDepartments = auth()->user()->departments()->pluck('departments.id');
// This returns: [5, 12] - departments from BOTH divisions
```
### Use Case: Corporate Fleet Shared Across Divisions
**Scenario:** All divisions share the same delivery fleet.
**Solution:** Create a Fleet department at parent company level, assign drivers to it.
**Users in Fleet Department:**
- See deliveries for all divisions
- Manage vehicles shared across divisions
- Track routes spanning multiple division locations
## Data Visibility Rules
### Rule 1: Business Isolation (Always Enforced)
**Users can ONLY see data from businesses they have access to.**
```php
// ALWAYS scope by business_id first
$query->where('business_id', $business->id);
```
**Example:**
- Hash Factory AZ users cannot see Competitor Company's data
- Each business is completely isolated
### Rule 2: Department Filtering (Enforced for Non-Executives)
**Regular users see only data from their assigned departments.**
```php
if (!auth()->user()->hasRole('executive')) {
$query->whereHas('operator.departments', function($q) use ($userDepts) {
$q->whereIn('departments.id', $userDepts);
});
}
```
**Example:**
- Manufacturing user sees batches from manufacturing operators only
- Sales user sees orders assigned to sales team only
### Rule 3: Executive Override (Top Priority)
**Executives bypass department filtering and see ALL data.**
```php
if (auth()->user()->hasRole('executive')) {
// No department filtering - see everything
$userDepartments = $business->departments->pluck('id');
}
```
**Example:**
- CEO sees manufacturing, sales, compliance, and all other data
- CFO sees financial data across all departments
### Rule 4: Multi-Division Access
**Users can be assigned to departments in MULTIPLE divisions.**
```php
// User's departments span multiple businesses
$userDepartments = auth()->user()->departments()
->pluck('departments.id'); // Includes departments from all divisions
```
**Example:**
- Shared resource (operator, driver) sees data from all assigned divisions
- Corporate admin manages users across multiple divisions
## Permission System Integration
### Spatie Permissions
Cannabrands uses **Spatie Laravel Permission** for role-based access control.
**Permissions Structure:**
- `view-dashboard` - See basic dashboard
- `view-batches` - See batch tracking (Manufacturing)
- `create-batches` - Create batches (Manufacturing Admin)
- `view-orders` - See sales orders (Sales)
- `manage-fleet` - Manage vehicles/drivers (Fleet)
- `view-executive-dashboard` - Access executive features (Executive)
### Combining Permissions + Departments
**Two-Layer Security:**
1. **Permission Check:** Does user have permission to access this feature?
2. **Department Check:** Is the data from user's department?
**Example:**
```php
// Layer 1: Permission check
if (!auth()->user()->can('view-batches')) {
abort(403, 'Unauthorized');
}
// Layer 2: Department filtering
$batches = Batch::where('business_id', $business->id)
->whereHas('operator.departments', function($q) use ($userDepartments) {
$q->whereIn('departments.id', $userDepartments);
})
->get();
```
**Result:**
- User must have `view-batches` permission AND be in Manufacturing department
- Even with permission, they only see batches from their department
## Navigation & Menu Filtering
### Department-Based Menu Items
**File:** `resources/views/components/seller-sidebar.blade.php`
```blade
{{-- Manufacturing Section - Only for Manufacturing department users --}}
@if(auth()->user()->departments()->where('code', 'MFG')->exists())
<li class="menu-title">Manufacturing</li>
<li><a href="/batches">Batches</a></li>
<li><a href="/wash-reports">Wash Reports</a></li>
<li><a href="/work-orders">Work Orders</a></li>
@endif
{{-- Sales Section - Only for Sales department users --}}
@if(auth()->user()->departments()->where('code', 'SALES')->exists())
<li class="menu-title">Sales</li>
<li><a href="/orders">Orders</a></li>
<li><a href="/customers">Customers</a></li>
@endif
{{-- Executive Section - Only for executives --}}
@if(auth()->user()->hasRole('executive'))
<li class="menu-title">Executive</li>
<li><a href="/executive/dashboard">Executive Dashboard</a></li>
<li><a href="/corporate/divisions">Manage Divisions</a></li>
@endif
```
**Result:**
- Users only see menu items relevant to their departments
- Executives see all sections + executive-only items
## Common Access Scenarios
### Scenario 1: New Manufacturing Operator
**Setup:**
1. Create user account
2. Assign to "Hash Factory AZ" business
3. Assign to "Manufacturing" department
4. Give permissions: `view-batches`, `create-wash-reports`
**Access:**
- ✅ Dashboard shows manufacturing metrics
- ✅ Can view batches from manufacturing team
- ✅ Can create wash reports
- ❌ Cannot see sales orders
- ❌ Cannot see executive dashboard
### Scenario 2: Sales Manager for Multiple Divisions
**Setup:**
1. Create user account
2. Assign to "Hash Factory AZ" → Sales Department (is_admin = true)
3. Assign to "Leopard AZ" → Sales Department (is_admin = true)
4. Give permissions: `view-orders`, `manage-customers`, `view-sales-reports`
**Access:**
- ✅ See orders from both Hash Factory AZ and Leopard AZ
- ✅ Manage customers across both divisions
- ✅ View combined sales reports
- ✅ Assign users to Sales department (dept admin)
- ❌ Cannot see manufacturing data
- ❌ Cannot see executive dashboard
### Scenario 3: Corporate CFO
**Setup:**
1. Create user account
2. Assign to "Canopy AZ Group" (parent company)
3. Assign role: `executive`
4. Give permissions: `view-executive-dashboard`, `view-financial-reports`, `manage-billing`
**Access:**
- ✅ Executive dashboard with all divisions
- ✅ Financial reports across all divisions
- ✅ Manufacturing data from all divisions
- ✅ Sales data from all divisions
- ✅ Compliance data from all divisions
- ✅ Corporate settings and division management
### Scenario 4: Shared Equipment Operator
**Setup:**
1. Create user account
2. Assign to "Hash Factory AZ" → Manufacturing Department
3. Assign to "Leopard AZ" → Manufacturing Department
4. Give permissions: `operate-equipment`, `create-wash-reports`
**Access:**
- ✅ See batches from both divisions
- ✅ Create wash reports for either division
- ✅ View shared equipment schedule
- ❌ Cannot see sales or compliance data
- ❌ Cannot manage departments (not admin)
## Security Considerations
### Always Check Business ID First
```php
// CORRECT
$batch = Batch::where('business_id', $business->id)
->findOrFail($id);
// WRONG - Allows cross-business access!
$batch = Batch::findOrFail($id);
if ($batch->business_id !== $business->id) abort(403);
```
### Always Apply Department Filtering
```php
// CORRECT
$batches = Batch::where('business_id', $business->id)
->whereHas('operator.departments', function($q) use ($depts) {
$q->whereIn('departments.id', $depts);
})
->get();
// WRONG - Shows all batches in business!
$batches = Batch::where('business_id', $business->id)->get();
```
### Check Permissions Before Department Filtering
```php
// CORRECT ORDER
if (!$user->can('view-batches')) abort(403);
$batches = Batch::forUserDepartments($user)->get();
// WRONG - Filtered data still requires permission!
$batches = Batch::forUserDepartments($user)->get();
if (!$user->can('view-batches')) abort(403);
```
## Implementation Checklist
When adding new features with department access:
- [ ] Check business_id isolation first
- [ ] Check user permissions (Spatie)
- [ ] Apply department filtering for non-executives
- [ ] Allow executive override
- [ ] Test with multi-department users
- [ ] Test with shared department across divisions
- [ ] Update navigation to show/hide menu items
- [ ] Add department filtering to all queries
- [ ] Document which departments can access the feature
## Related Documentation
- `docs/features/PARENT_COMPANY_SUBDIVISIONS.md` - Technical implementation details
- `docs/ROUTE_ISOLATION.md` - Module routing and isolation
- `CLAUDE.md` - Common security mistakes to avoid
- `app/Models/Business.php` - Parent/division relationships
- `app/Models/Department.php` - Department structure
- `database/migrations/2025_11_13_020000_add_hierarchy_to_businesses_table.php` - Business hierarchy schema
- `database/migrations/2025_11_13_010000_create_departments_table.php` - Department schema
## Key Files Reference
**Controllers:**
- `app/Http/Controllers/DashboardController.php:408-424` - Department filtering logic
- `app/Http/Controllers/Seller/ExecutiveDashboardController.php` - Executive dashboard
- `app/Http/Controllers/Seller/CorporateSettingsController.php` - Corporate settings
**Models:**
- `app/Models/User.php` - Department relationships
- `app/Models/Department.php` - Department model
- `app/Models/Business.php` - Parent/division methods
**Views:**
- `resources/views/components/seller-sidebar.blade.php` - Department-based navigation
- `resources/views/layouts/app-with-sidebar.blade.php` - Division name display
## Questions & Troubleshooting
**Q: User can't see data they should have access to?**
A: Check department assignments in `department_user` table. User must be assigned to the correct department.
**Q: Executive sees only one department's data?**
A: Check role assignment. User needs `executive` role, not just department assignment.
**Q: Shared operator sees data from only one division?**
A: Check `department_user` table. User needs assignments to departments in BOTH divisions.
**Q: How to give user access to all departments without making them executive?**
A: Assign user to ALL departments individually. Executive role bypasses this need.
**Q: Department admin can't manage department users?**
A: Check `department_user.is_admin` flag is set to `true` for that user's assignment.

195
MISSING_FILES_REPORT.md Normal file
View File

@@ -0,0 +1,195 @@
# Missing Files Report - Manufacturing Features Worktree
**Date:** 2025-11-13
**Comparison:** Main repo vs `/home/kelly/git/hub-worktrees/manufacturing-features`
## Summary
The main repository contains **significantly more files** than the manufacturing-features worktree. These files represent work from commits:
- `2831def` (9:00 PM Nov 13) - Manufacturing module with departments and executive features
- `812fb20` (9:39 PM Nov 13) - UI improvements and enhanced dashboard
---
## Controllers Missing from Worktree (22 files)
### Admin Controllers (1):
- `app/Http/Controllers/Admin/QuickSwitchController.php`
### Analytics Controllers (6):
- `app/Http/Controllers/Analytics/AnalyticsDashboardController.php`
- `app/Http/Controllers/Analytics/BuyerIntelligenceController.php`
- `app/Http/Controllers/Analytics/MarketingAnalyticsController.php`
- `app/Http/Controllers/Analytics/ProductAnalyticsController.php`
- `app/Http/Controllers/Analytics/SalesAnalyticsController.php`
- `app/Http/Controllers/Analytics/TrackingController.php`
### Seller Controllers (13):
- `app/Http/Controllers/Seller/BatchController.php`
- `app/Http/Controllers/Seller/BrandController.php`
- `app/Http/Controllers/Seller/BrandPreviewController.php`
- `app/Http/Controllers/Seller/ConsolidatedAnalyticsController.php`
- `app/Http/Controllers/Seller/CorporateSettingsController.php`
- `app/Http/Controllers/Seller/DashboardController.php`
- `app/Http/Controllers/Seller/ExecutiveDashboardController.php`
- `app/Http/Controllers/Seller/OrderController.php`
- `app/Http/Controllers/Seller/PurchaseOrderController.php`
- `app/Http/Controllers/Seller/WashReportController.php`
- `app/Http/Controllers/Seller/WorkOrderController.php`
### Fleet Controllers (2):
- `app/Http/Controllers/Seller/Fleet/DriverController.php`
- `app/Http/Controllers/Seller/Fleet/VehicleController.php`
### Marketing Controllers (2):
- `app/Http/Controllers/Seller/Marketing/BroadcastController.php`
- `app/Http/Controllers/Seller/Marketing/TemplateController.php`
---
## Models Missing from Worktree (5 files)
- `app/Models/ComponentCategory.php`
- `app/Models/Conversion.php`
- `app/Models/Department.php`
- `app/Models/PurchaseOrder.php`
- `app/Models/WorkOrder.php`
---
## Views Missing from Worktree (30+ files)
### Admin Views (1):
- `resources/views/admin/quick-switch.blade.php`
### Component Views (3):
- `resources/views/components/back-to-admin-button.blade.php`
- `resources/views/components/dashboard/strain-performance.blade.php`
- `resources/views/components/user-session-info.blade.php`
### Analytics Views (8):
- `resources/views/seller/analytics/buyer-detail.blade.php`
- `resources/views/seller/analytics/buyers.blade.php`
- `resources/views/seller/analytics/campaign-detail.blade.php`
- `resources/views/seller/analytics/dashboard.blade.php`
- `resources/views/seller/analytics/marketing.blade.php`
- `resources/views/seller/analytics/product-detail.blade.php`
- `resources/views/seller/analytics/products.blade.php`
- `resources/views/seller/analytics/sales.blade.php`
### Batch Views (3):
- `resources/views/seller/batches/create.blade.php`
- `resources/views/seller/batches/edit.blade.php`
- `resources/views/seller/batches/index.blade.php`
### Brand Views (5):
- `resources/views/seller/brands/create.blade.php`
- `resources/views/seller/brands/edit.blade.php`
- `resources/views/seller/brands/index.blade.php`
- `resources/views/seller/brands/preview.blade.php`
- `resources/views/seller/brands/show.blade.php`
### Corporate/Executive Views (2):
- `resources/views/seller/corporate/divisions.blade.php`
- `resources/views/seller/executive/dashboard.blade.php`
### Marketing Views (8):
- `resources/views/seller/marketing/broadcasts/analytics.blade.php`
- `resources/views/seller/marketing/broadcasts/create.blade.php`
- `resources/views/seller/marketing/broadcasts/index.blade.php`
- `resources/views/seller/marketing/broadcasts/show.blade.php`
- `resources/views/seller/marketing/templates/create.blade.php`
- `resources/views/seller/marketing/templates/edit.blade.php`
- `resources/views/seller/marketing/templates/index.blade.php`
- `resources/views/seller/marketing/templates/show.blade.php`
### Purchase Order Views (4):
- `resources/views/seller/purchase-orders/create.blade.php`
- `resources/views/seller/purchase-orders/edit.blade.php`
- `resources/views/seller/purchase-orders/index.blade.php`
- `resources/views/seller/purchase-orders/show.blade.php`
### Wash Report Views (4):
- `resources/views/seller/wash-reports/active-dashboard.blade.php`
- `resources/views/seller/wash-reports/daily-performance.blade.php`
- `resources/views/seller/wash-reports/print.blade.php`
- `resources/views/seller/wash-reports/search.blade.php`
### Work Order Views (5):
- `resources/views/seller/work-orders/create.blade.php`
- `resources/views/seller/work-orders/edit.blade.php`
- `resources/views/seller/work-orders/index.blade.php`
- `resources/views/seller/work-orders/my-work-orders.blade.php`
- `resources/views/seller/work-orders/show.blade.php`
---
## Migrations Missing from Worktree (10 files)
- `database/migrations/2025_10_29_135618_create_component_categories_table.php`
- `database/migrations/2025_11_12_033522_add_role_template_to_business_user_table.php`
- `database/migrations/2025_11_12_035044_add_module_flags_to_businesses_table.php`
- `database/migrations/2025_11_12_201000_create_conversions_table.php`
- `database/migrations/2025_11_12_201100_create_conversion_inputs_table.php`
- `database/migrations/2025_11_12_202000_create_purchase_orders_table.php`
- `database/migrations/2025_11_13_010000_create_departments_table.php`
- `database/migrations/2025_11_13_010100_create_department_user_table.php`
- `database/migrations/2025_11_13_010200_create_work_orders_table.php`
- `database/migrations/2025_11_13_020000_add_hierarchy_to_businesses_table.php`
---
## Seeders Missing from Worktree
- `database/seeders/CanopyAzBusinessRestructureSeeder.php`
- `database/seeders/CanopyAzDepartmentsSeeder.php`
- `database/seeders/CompleteManufacturingSeeder.php`
- `database/seeders/ManufacturingSampleDataSeeder.php`
- `database/seeders/ManufacturingStructureSeeder.php`
---
## Documentation Missing from Worktree
- `EXECUTIVE_ACCESS_GUIDE.md` (root)
- `docs/features/PARENT_COMPANY_SUBDIVISIONS.md`
---
## Action Items
To sync the manufacturing-features worktree with main repo:
```bash
cd /home/kelly/git/hub-worktrees/manufacturing-features
git fetch origin
git merge origin/feature/manufacturing-module
```
Or copy specific files:
```bash
# Copy controllers
cp -r /home/kelly/git/hub/app/Http/Controllers/Analytics /home/kelly/git/hub-worktrees/manufacturing-features/app/Http/Controllers/
cp /home/kelly/git/hub/app/Http/Controllers/Admin/QuickSwitchController.php /home/kelly/git/hub-worktrees/manufacturing-features/app/Http/Controllers/Admin/
# Copy models
cp /home/kelly/git/hub/app/Models/{ComponentCategory,Conversion,Department,PurchaseOrder,WorkOrder}.php /home/kelly/git/hub-worktrees/manufacturing-features/app/Models/
# Copy views
cp -r /home/kelly/git/hub/resources/views/seller/{analytics,batches,brands,marketing,corporate,executive,purchase-orders,wash-reports,work-orders} /home/kelly/git/hub-worktrees/manufacturing-features/resources/views/seller/
# Copy migrations
cp /home/kelly/git/hub/database/migrations/2025_*.php /home/kelly/git/hub-worktrees/manufacturing-features/database/migrations/
# Copy seeders
cp /home/kelly/git/hub/database/seeders/{CanopyAz*,Complete*,Manufacturing*}.php /home/kelly/git/hub-worktrees/manufacturing-features/database/seeders/
# Copy docs
cp /home/kelly/git/hub/EXECUTIVE_ACCESS_GUIDE.md /home/kelly/git/hub-worktrees/manufacturing-features/
cp /home/kelly/git/hub/docs/features/PARENT_COMPANY_SUBDIVISIONS.md /home/kelly/git/hub-worktrees/manufacturing-features/docs/features/
```
---
**Total Missing Files:** ~80+ files across controllers, models, views, migrations, seeders, and documentation

View File

@@ -1,31 +1,26 @@
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-setup k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
# Default target
.DEFAULT_GOAL := help
# ==================== K8s Variables ====================
# K3d cluster must be created with dual volume mounts:
# k3d cluster create dev \
# --api-port 6443 \
# --port "80:80@loadbalancer" \
# --port "443:443@loadbalancer" \
# --volume /Users/jon/projects/cannabrands/cannabrands_new/.worktrees:/worktrees \
# --volume /Users/jon/projects/cannabrands/cannabrands_new:/project-root \
# --volume k3d-dev-images:/k3d/images
# Detect if we're in a worktree or project root
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
IS_WORKTREE := $(shell echo "$(GIT_DIR)" | grep -q ".worktrees" && echo "true" || echo "false")
# Set paths based on location
# Find project root (handles both worktree and main repo)
ifeq ($(IS_WORKTREE),true)
# In a worktree - use worktree-specific path
# In a worktree - project root is two levels up
PROJECT_ROOT := $(shell cd ../.. && pwd)
WORKTREE_NAME := $(shell basename $(CURDIR))
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
else
# In project root - use root path
# In project root
PROJECT_ROOT := $(shell pwd)
WORKTREE_NAME := root
K8S_VOLUME_PATH := /project-root
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
endif
# Generate namespace from branch name (feat-branch-name)
@@ -69,6 +64,28 @@ dev-vite: ## Start Vite dev server (run after 'make dev')
./vendor/bin/sail npm run dev
# ==================== K8s Local Development ====================
k-setup: ## One-time setup: Create K3d cluster with auto-detected volume mounts
@echo "🔧 Setting up K3d cluster 'dev' with auto-detected paths"
@echo " Project Root: $(PROJECT_ROOT)"
@echo " Worktrees Path: $(HOST_WORKTREE_PATH)"
@echo ""
@# Check if cluster already exists
@if k3d cluster list | grep -q "^dev "; then \
echo "⚠️ Cluster 'dev' already exists!"; \
echo " To recreate, run: k3d cluster delete dev && make k-setup"; \
exit 1; \
fi
@# Create cluster with dynamic volume mounts
k3d cluster create dev \
--api-port 6443 \
--port "80:80@loadbalancer" \
--port "443:443@loadbalancer" \
--volume $(HOST_WORKTREE_PATH):/worktrees \
--volume $(PROJECT_ROOT):/project-root
@echo ""
@echo "✅ K3d cluster created successfully!"
@echo " Next step: Run 'make k-dev' to start your environment"
k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
@echo "🚀 Starting k8s environment"
@echo " Location: $(if $(filter true,$(IS_WORKTREE)),Worktree ($(WORKTREE_NAME)),Project Root)"

340
NEXT_STEPS.md Normal file
View File

@@ -0,0 +1,340 @@
# Next Steps: QR Code Feature & K8s Local Dev Setup
This document outlines the next steps for both features that were separated into different git worktrees.
---
## Summary of Work Done
### ✅ QR Code Feature (feature/batch-tracking-coa-qr branch)
**Location:** `.worktrees/labs-batch-qr-codes/`
**Status:** Committed and ready for testing/PR
**Changes:**
- QR code generation endpoints in BatchController
- Filament actions for QR code management
- UI for QR code display in batch views and public COA
- Comprehensive test suite
- Routes for single and bulk operations
### ✅ K8s Local Dev Setup (feature/k8s-local-dev branch)
**Location:** `.worktrees/infra-k8s-local-dev/`
**Status:** Committed and ready for setup/testing
**Changes:**
- K8s manifests for local development (k8s/local/)
- Makefile targets with `k-` prefix
- Complete documentation (docs/K8S_*.md)
---
## Next Steps
### For QR Code Feature
**1. Test the Implementation**
```bash
cd /Users/jon/projects/cannabrands/cannabrands_new/.worktrees/labs-batch-qr-codes
# Start services if not already running
docker-compose up -d pgsql redis
# Run migrations (via Sail if Redis extension issue persists)
./vendor/bin/sail up -d
./vendor/bin/sail artisan migrate
# Run tests
./vendor/bin/sail artisan test --filter=QrCodeGenerationTest
# Or run all tests
./vendor/bin/sail artisan test
```
**2. Manual Testing**
```bash
# Start Sail environment
./vendor/bin/sail up -d
# Access app
open http://localhost
# Test:
# - Navigate to a batch in Filament
# - Generate QR code
# - Download QR code
# - View public COA (should show QR)
# - Test bulk generation
```
**3. Create Pull Request**
```bash
# Push branch to remote
git push origin feature/batch-tracking-coa-qr
# Create PR via GitHub CLI or web interface
gh pr create --title "feat: Add QR code generation for batches" \
--body "$(cat <<EOF
## Summary
Implements QR code generation for batches with download and bulk operations.
## Features
- Single and bulk QR code generation
- Download functionality
- Public COA display
- Business ownership validation
- Comprehensive test suite
## Testing
- [ ] All tests pass
- [ ] Manual testing completed
- [ ] QR codes generate correctly
- [ ] Download works
- [ ] Public COA displays QR
## Screenshots
[Add screenshots of QR generation and COA display]
EOF
)"
```
---
### For K8s Local Dev Setup
**1. One-Time K3d Setup**
Follow the complete guide in `docs/K8S_LOCAL_SETUP.md`:
```bash
# Install tools (macOS)
brew install k3d kubectl mkcert dnsmasq
# Create k3d cluster with volume access
k3d cluster create dev \
--agents 2 \
-p "80:80@loadbalancer" \
-p "443:443@loadbalancer" \
--volume "$HOME/projects/cannabrands_new/.worktrees:/worktrees@all"
# Setup wildcard DNS (*.cannabrands.test)
echo 'address=/.cannabrands.test/127.0.0.1' | \
sudo tee /opt/homebrew/etc/dnsmasq.d/cannabrands.conf
sudo brew services start dnsmasq
sudo mkdir -p /etc/resolver
echo 'nameserver 127.0.0.1' | \
sudo tee /etc/resolver/cannabrands.test
# Test DNS
ping -c 1 anything.cannabrands.test
# Should resolve to 127.0.0.1
```
**2. Test K8s Setup in This Worktree**
```bash
cd /Users/jon/projects/cannabrands/cannabrands_new/.worktrees/infra-k8s-local-dev
# Copy .env from another worktree
cp ../ labs-batch-qr-codes/.env .
# Install dependencies (or link vendor from main)
composer install
# Start k8s environment
make k-dev
# Expected output:
# - Namespace created: feat-k8s-local-dev
# - Services deployed: PostgreSQL, Redis, Laravel app
# - URL: http://k8s-local-dev.cannabrands.test
# Check status
make k-status
# View logs
make k-logs
# Open shell
make k-shell
# Cleanup
make k-down
```
**3. Test with Multiple Worktrees**
```bash
# Terminal 1: QR Code feature
cd /Users/jon/projects/cannabrands/cannabrands_new/.worktrees/labs-batch-qr-codes
make k-dev
# → http://labs-batch-qr-codes.cannabrands.test
# Terminal 2: K8s dev feature
cd /Users/jon/projects/cannabrands/cannabrands_new/.worktrees/infra-k8s-local-dev
make k-dev
# → http://k8s-local-dev.cannabrands.test
# Both running simultaneously with isolated namespaces!
```
**4. Create Pull Request**
```bash
cd /Users/jon/projects/cannabrands/cannabrands_new/.worktrees/infra-k8s-local-dev
# Push branch
git push origin feature/k8s-local-dev
# Create PR
gh pr create --title "feat: Add K8s local development with worktree support" \
--body "$(cat <<EOF
## Summary
Adds Kubernetes local development environment that mirrors Laravel Sail
workflow with namespace isolation per git worktree.
## Features
- K8s manifests for local dev (Sail-like approach)
- Makefile targets with \`k-\` prefix
- Complete documentation
- Namespace isolation per worktree
- Wildcard domain routing (*.cannabrands.test)
## Setup Required
- One-time k3d cluster setup (see docs/K8S_LOCAL_SETUP.md)
- DNS configuration for *.cannabrands.test
## Testing
- [ ] k3d cluster created
- [ ] DNS resolves *.cannabrands.test
- [ ] \`make k-dev\` starts successfully
- [ ] App accessible at custom domain
- [ ] Multiple worktrees work in parallel
- [ ] Code changes are instant (no rebuild)
## Documentation
- docs/K8S_LOCAL_SETUP.md - Complete setup guide
- docs/K8S_LIKE_SAIL.md - Philosophy and details
EOF
)"
```
**5. Update Team Documentation**
After PR is merged:
- Add k8s setup instructions to main README
- Announce new workflow to team
- Consider team demo/walkthrough
---
## Workflow After Both Features Are Merged
### Standard Development Flow
```bash
# Create new feature
cd /Users/jon/projects/cannabrands/cannabrands_new
git worktree add .worktrees/feature-orders feature/orders
cd .worktrees/feature-orders
# Option 1: Sail (existing workflow)
make dev
make dev-vite
# Option 2: K8s (new workflow with isolation)
make k-dev
make k-vite
# Develop with instant code changes...
# Cleanup
make dev-down # or make k-down
cd ../..
git worktree remove .worktrees/feature-orders
```
### When to Use Each Mode
**Use Sail (`make dev`):**
- Quick single-feature development
- Familiar workflow
- Lower resource usage
**Use K8s (`make k-dev`):**
- Multiple features in parallel
- Testing k8s manifests
- Production-like environment
- Need custom domain per feature
---
## Troubleshooting
### QR Code Feature Issues
**Redis extension error:**
```bash
# Solution: Use Sail instead of host PHP
./vendor/bin/sail artisan migrate
./vendor/bin/sail artisan test
```
**Tests fail:**
```bash
# Ensure services are running
docker ps | grep postgres
docker ps | grep redis
# Check .env configuration
DB_HOST=127.0.0.1 # NOT pgsql (hybrid mode)
REDIS_HOST=127.0.0.1 # NOT redis
```
### K8s Setup Issues
**DNS not working:**
```bash
# Flush DNS cache
sudo killall -HUP mDNSResponder
sudo brew services restart dnsmasq
# Test
dig k8s-local-dev.cannabrands.test
```
**Pod crashes:**
```bash
# Check logs
make k-logs
# Common issues:
# - Volume mount path incorrect
# - Missing dependencies (run composer install in pod)
# - DB connection (check PostgreSQL is ready)
```
**Port conflicts:**
```bash
# Check what's using ports 80/443
lsof -ti:80
lsof -ti:443
# Stop conflicting services
./vendor/bin/sail down # if Sail is running
```
---
## Questions?
- Check documentation: docs/K8S_LOCAL_SETUP.md and docs/K8S_LIKE_SAIL.md
- Review git worktree workflow: docs/GIT_WORKTREE_WORKFLOW.md
- Ask in team chat
---
**Generated:** 2025-10-31
**Author:** Claude Code with Human Partner

336
PUSH_NOTIFICATIONS_SETUP.md Normal file
View File

@@ -0,0 +1,336 @@
# Push Notifications & Laravel Horizon Setup
## Overview
This feature adds browser push notifications for high-intent buyer signals as part of the Premium Buyer Analytics module.
## Prerequisites
- HTTPS (production/staging) or localhost (development)
- Browser that supports Web Push API (Chrome, Firefox, Edge, Safari 16+)
---
## Installation Steps
### 1. Install Dependencies
```bash
composer update
```
This will install:
- `laravel-notification-channels/webpush: ^10.2` - Web push notifications
- `laravel/horizon: ^5.39` - Queue management dashboard
### 2. Install Horizon Assets
```bash
php artisan horizon:install
```
This publishes Horizon's dashboard assets to `public/vendor/horizon`.
### 3. Run Database Migrations
```bash
php artisan migrate
```
This creates:
- `push_subscriptions` table - Stores browser push subscriptions
### 4. Generate VAPID Keys
```bash
php artisan webpush:vapid
```
This generates VAPID keys for web push authentication and adds them to your `.env`:
```
VAPID_PUBLIC_KEY=...
VAPID_PRIVATE_KEY=...
```
**⚠️ IMPORTANT**:
- Never commit VAPID keys to git
- Generate different keys for each environment (local, staging, production)
- Keys are environment-specific and can't be shared between environments
---
## Configuration
### 1. Register HorizonServiceProvider
Add to `bootstrap/providers.php`:
```php
<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class, // Add this line
];
```
### 2. Register Event Listener
In `app/Providers/AppServiceProvider.php` boot method:
```php
use App\Events\HighIntentBuyerDetected;
use App\Listeners\Analytics\SendHighIntentSignalPushNotification;
use Illuminate\Support\Facades\Event;
public function boot(): void
{
Event::listen(
HighIntentBuyerDetected::class,
SendHighIntentSignalPushNotification::class
);
}
```
### 3. Environment Variables
Ensure these are in your `.env`:
```env
# Queue Configuration
QUEUE_CONNECTION=redis
# Redis Configuration
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Horizon Configuration
HORIZON_DOMAIN=your-domain.com
HORIZON_PATH=horizon
# Web Push (generated by webpush:vapid)
VAPID_PUBLIC_KEY=your-public-key
VAPID_PRIVATE_KEY=your-private-key
VAPID_SUBJECT=mailto:your-email@example.com
```
---
## Local Development Setup
### 1. Start Required Services
```bash
# Start Laravel Sail (includes Redis)
./vendor/bin/sail up -d
# OR if using local Redis
redis-server
```
### 2. Start Horizon Queue Worker
```bash
php artisan horizon
# OR with Sail
./vendor/bin/sail artisan horizon
```
**⚠️ Horizon must be running** for push notifications to be sent!
### 3. Seed Test Data (Local Only)
```bash
php artisan db:seed --class=PushNotificationTestDataSeeder
```
This creates:
- ✅ 5 repeated product views
- ✅ High engagement buyer score (95%)
- ✅ 4 intent signals (various types)
- ✅ Test notification event
### 4. Access Dashboards
- **Horizon Dashboard**: http://localhost/horizon
- Monitor queued jobs
- View failed jobs
- See job metrics
- **Analytics Dashboard**: http://localhost/s/cannabrands/buyer-intelligence/buyers
- View buyer engagement scores
- See intent signals
- Test push notifications
---
## Testing Push Notifications
### Browser Setup
1. **Navigate to your site** (must be HTTPS or localhost)
2. **Grant notification permission** when prompted
3. Browser will create a push subscription automatically
### Trigger Test Notification
Option 1: Use the seeder (creates test event):
```bash
php artisan db:seed --class=PushNotificationTestDataSeeder
```
Option 2: Manually trigger via Tinker:
```bash
php artisan tinker
```
```php
use App\Events\HighIntentBuyerDetected;
event(new HighIntentBuyerDetected(
sellerBusinessId: 1,
buyerBusinessId: 2,
signalType: 'high_engagement',
signalStrength: 'very_high',
metadata: ['engagement_score' => 95]
));
```
### Verify Notification Delivery
1. Check Horizon dashboard: `/horizon` - Job should show as processed
2. Check browser - Should receive push notification
3. Check Laravel logs: `storage/logs/laravel.log`
---
## Production/Staging Deployment
### Deployment Checklist
1. ✅ Run `composer install --no-dev --optimize-autoloader`
2. ✅ Run `php artisan horizon:install`
3. ✅ Run `php artisan migrate --force`
4. ✅ Run `php artisan webpush:vapid` (generates environment-specific keys)
5. ✅ Configure supervisor to keep Horizon running
6. ✅ Set `HORIZON_DOMAIN` in `.env`
7. ✅ **DO NOT** run test data seeder
### Supervisor Configuration
Create `/etc/supervisor/conf.d/horizon.conf`:
```ini
[program:horizon]
process_name=%(program_name)s
command=php /path/to/artisan horizon
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/path/to/storage/logs/horizon.log
stopwaitsecs=3600
```
Then:
```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start horizon
```
---
## Troubleshooting
### Notifications Not Sending
1. **Check Horizon is running**: Visit `/horizon` dashboard
2. **Check queue connection**: `php artisan queue:monitor`
3. **Check Redis**: `redis-cli ping` (should return PONG)
4. **Check logs**: `tail -f storage/logs/laravel.log`
### VAPID Key Issues
```bash
# Regenerate keys
php artisan webpush:vapid --force
# Then restart Horizon
php artisan horizon:terminate
php artisan horizon
```
### Browser Not Receiving Notifications
1. Check browser permissions: Allow notifications for your site
2. Check HTTPS: Must be HTTPS or localhost
3. Check subscription exists: `SELECT * FROM push_subscriptions;`
4. Check browser console for errors
---
## What Triggers Push Notifications
Notifications are automatically sent when:
| Trigger | Threshold | Signal Type |
|---------|-----------|-------------|
| Repeated product views | 3+ views | `repeated_view` |
| High engagement score | ≥ 60% | `high_engagement` |
| Spec download | Any | `spec_download` |
| Contact button click | Any | `contact_click` |
All triggers require `has_analytics = true` on the business.
---
## Architecture
```
User Action (e.g., views product 3x)
Analytics Tracking System
CalculateEngagementScore Job
HighIntentBuyerDetected Event fired
SendHighIntentSignalPushNotification Listener (queued)
Horizon Queue Processing
Push Notification Sent to Browser
```
---
## Files Added
- `app/Notifications/Analytics/HighIntentSignalNotification.php`
- `app/Models/Analytics/PushSubscription.php`
- `app/Listeners/Analytics/SendHighIntentSignalPushNotification.php`
- `app/Providers/HorizonServiceProvider.php`
- `database/migrations/2025_11_09_003106_create_push_subscriptions_table.php`
- `database/seeders/PushNotificationTestDataSeeder.php` (test data only)
- `config/webpush.php`
- `config/horizon.php`
---
## Security Notes
- ✅ Push notifications only sent to users with permission
- ✅ VAPID keys are environment-specific
- ✅ Subscriptions tied to user accounts
- ✅ All triggers respect `has_analytics` module flag
- ⚠️ Never commit VAPID keys to version control
- ⚠️ Never run test seeders in production
---
## Support
- **Laravel Horizon Docs**: https://laravel.com/docs/horizon
- **Web Push Package**: https://github.com/laravel-notification-channels/webpush
- **Web Push Protocol**: https://web.dev/push-notifications/

View File

@@ -0,0 +1,501 @@
# Analytics Implementation - Quick Handoff for Claude Code
## 🎯 Implementation Guide Location
**Main Technical Guide:** `/mnt/user-data/outputs/analytics-implementation-guide-REVISED.md`
This is a **REVISED** implementation that matches your ACTUAL Cannabrands architecture.
---
## ⚠️ CRITICAL ARCHITECTURAL DIFFERENCES
Your setup is different from typical Laravel multi-tenant apps:
### 1. **business_id is bigInteger (not UUID)**
```php
// Migration
$table->unsignedBigInteger('business_id')->index();
$table->foreign('business_id')->references('id')->on('businesses');
// NOT UUID like:
$table->uuid('tenant_id');
```
### 2. **NO Global Scopes - Explicit Scoping Pattern**
```php
// ❌ WRONG - Security vulnerability!
ProductView::findOrFail($id)
// ✅ RIGHT - Your pattern
ProductView::where('business_id', $business->id)->findOrFail($id)
// All queries MUST explicitly scope by business_id
```
### 3. **Permissions in business_user.permissions JSON Column**
```php
// NOT using Spatie permission routes yet
// Permissions stored in: business_user pivot table
// Column: 'permissions' => 'array' (JSON)
// Check permissions via helper:
hasBusinessPermission('analytics.overview')
// NOT via:
auth()->user()->can('analytics.overview') // ❌ Don't use this yet
```
### 4. **Multi-Business Users**
```php
// Users can belong to MULTIPLE businesses
auth()->user()->businesses // BelongsToMany
// Get current business:
auth()->user()->primaryBusiness()
// Or use helper:
currentBusiness()
currentBusinessId()
```
### 5. **Products → Brand → Business Hierarchy**
```php
// Products DON'T have direct business_id
// They go through Brand:
$product->brand->business_id
// For tracking product views, get seller's business:
$sellerBusiness = BusinessHelper::fromProduct($product);
```
### 6. **User Types via Middleware**
```php
// Routes use user_type middleware:
Route::middleware(['auth', 'verified', 'buyer']) // Buyers
Route::middleware(['auth', 'verified', 'seller']) // Sellers
Route::middleware(['auth', 'admin']) // Admins
// user_type values:
'buyer' => 'Buyer/Retailer'
'seller' => 'Seller/Brand'
'admin' => 'Super Admin'
```
### 7. **Reverb IS Configured (Horizon is NOT)**
```php
// ✅ Use Reverb for real-time updates
use App\Events\Analytics\HighIntentBuyerDetected;
event(new HighIntentBuyerDetected(...));
// ✅ Use Redis queues (already available)
CalculateEngagementScore::dispatch()->onQueue('analytics');
// ❌ Don't install Horizon (not needed yet)
```
---
## 📋 WHAT YOU'RE BUILDING
### Database Tables (7 migrations):
1. `analytics_events` - Raw event stream
2. `product_views` - Product engagement tracking
3. `email_campaigns` + `email_interactions` + `email_clicks` - Email tracking
4. `click_tracking` - General click events
5. `user_sessions` + `intent_signals` - Session & intent tracking
6. `buyer_engagement_scores` - Calculated buyer scores
7. `jobs` table for Redis queues
**Key Field:** Every table has `business_id` (bigInteger) with proper indexing
### Backend Components:
- **Helper Functions:** `currentBusiness()`, `hasBusinessPermission()`
- **AnalyticsTracker Service:** Main tracking service
- **Queue Jobs:** Async engagement score calculations
- **Events:** Reverb broadcasting for real-time updates
- **Controllers:** Dashboard, Products, Marketing, Sales, Buyers
- **Models:** 10 analytics models with explicit business scoping
### Frontend:
- Permission management UI in existing business/users section
- Analytics navigation (new top-level section)
- Dashboard views with KPIs and charts
- Real-time notifications via Reverb
---
## 🔐 SECURITY PATTERN (CRITICAL!)
**EVERY query MUST scope by business_id:**
```php
// ❌ NEVER do this - data leakage!
AnalyticsEvent::find($id)
ProductView::where('product_id', $productId)->get()
// ✅ ALWAYS do this - business isolated
AnalyticsEvent::where('business_id', $business->id)->find($id)
ProductView::where('business_id', $business->id)
->where('product_id', $productId)
->get()
// ✅ Or use scope helper in models
ProductView::forBusiness($business->id)->get()
```
---
## 🚀 IMPLEMENTATION STEPS
### 1. Create Helper Files First
```bash
# Create helpers
mkdir -p app/Helpers
# Copy BusinessHelper.php
# Copy helpers.php
# Update composer.json autoload.files
composer dump-autoload
```
### 2. Run Migrations
```bash
# Copy all 7 migration files
php artisan migrate
# Verify tables created
php artisan tinker
>>> DB::table('analytics_events')->count()
>>> DB::table('product_views')->count()
```
### 3. Create Models
```bash
mkdir -p app/Models/Analytics
# Copy all model files (10 models)
# Each model has explicit business scoping
```
### 4. Create Services
```bash
mkdir -p app/Services/Analytics
# Copy AnalyticsTracker service
```
### 5. Create Jobs
```bash
mkdir -p app/Jobs/Analytics
# Copy CalculateEngagementScore job
```
### 6. Create Events
```bash
mkdir -p app/Events/Analytics
# Copy HighIntentBuyerDetected event
# Update routes/channels.php for broadcasting
```
### 7. Create Controllers
```bash
mkdir -p app/Http/Controllers/Analytics
# Copy all controller files
```
### 8. Add Routes
```bash
# Update routes/web.php with analytics routes
# Use existing middleware patterns (auth, verified)
```
### 9. Update UI
```bash
# Add analytics navigation section
# Add permission management tile to business/users
# Create analytics dashboard views
```
### 10. Configure Queues
```bash
# Start queue worker
php artisan queue:work --queue=analytics
# (Reverb should already be running)
```
---
## 📊 TRACKING EXAMPLES
### Track Product View
```php
use App\Services\Analytics\AnalyticsTracker;
public function show(Product $product, Request $request)
{
$tracker = new AnalyticsTracker($request);
$view = $tracker->trackProductView($product);
// Queue engagement score calculation if buyer
if ($view && $view->buyer_business_id) {
\App\Jobs\Analytics\CalculateEngagementScore::dispatch(
$view->business_id,
$view->buyer_business_id
);
}
return view('products.show', compact('product'));
}
```
### JavaScript Click Tracking
```javascript
// Add to your main JS
document.addEventListener('click', function(e) {
const trackable = e.target.closest('[data-track-click]');
if (trackable) {
fetch('/api/analytics/track-click', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
element_type: trackable.dataset.trackClick,
element_id: trackable.dataset.trackId
})
});
}
});
```
### HTML Usage
```blade
<a href="{{ route('products.show', $product) }}"
data-track-click="product_link"
data-track-id="{{ $product->id }}">
{{ $product->name }}
</a>
```
### Real-Time Notifications
```javascript
// In analytics dashboard
const businessId = {{ $business->id }};
Echo.channel('analytics.business.' + businessId)
.listen('.high-intent-buyer', (e) => {
showNotification('🔥 Hot Lead!', `${e.buyer_name} showing high intent`);
});
```
---
## 🔍 TESTING BUSINESS ISOLATION
```php
// In php artisan tinker
// 1. Login as user
auth()->loginUsingId(1);
$business = currentBusiness();
// 2. Test helper
echo "Business ID: " . currentBusinessId();
// 3. Test permission
echo hasBusinessPermission('analytics.overview') ? "✅ HAS" : "❌ NO";
// 4. Test scoping - should only return current business data
$count = App\Models\Analytics\ProductView::where('business_id', $business->id)->count();
echo "My views: $count";
// 5. Test auto-set business_id
$event = App\Models\Analytics\AnalyticsEvent::create([
'event_type' => 'test'
]);
echo $event->business_id === $business->id ? "✅ PASS" : "❌ FAIL";
```
---
## 📝 PERMISSION SETUP
Add permissions to a user:
```php
// In tinker or seeder
$user = User::find(1);
$business = $user->businesses->first();
// Grant analytics permissions
$user->businesses()->updateExistingPivot($business->id, [
'permissions' => [
'analytics.overview',
'analytics.products',
'analytics.marketing',
'analytics.sales',
'analytics.buyers',
'analytics.export'
]
]);
// Verify
$pivot = $user->businesses()->find($business->id)->pivot;
print_r($pivot->permissions);
```
---
## ⚡ QUEUE CONFIGURATION
Make sure Redis is running and queue worker is started:
```bash
# Check Redis
redis-cli ping
# Start queue worker
php artisan queue:work --queue=analytics --tries=3
# Or with supervisor (production):
[program:cannabrands-analytics-queue]
command=php /path/to/artisan queue:work --queue=analytics --tries=3
```
---
## 🎨 NAVIGATION UPDATE
Add to your sidebar navigation:
```blade
<!-- Analytics Section (New Top-Level) -->
<div class="nav-section">
<div class="nav-header">
<svg>...</svg>
Analytics
</div>
@if(hasBusinessPermission('analytics.overview'))
<a href="{{ route('analytics.dashboard') }}" class="nav-item">
Overview
</a>
@endif
@if(hasBusinessPermission('analytics.products'))
<a href="{{ route('analytics.products.index') }}" class="nav-item">
Products
</a>
@endif
<!-- Marketing, Sales, Buyers... -->
</div>
```
---
## 🐛 COMMON ISSUES
### Issue: "business_id cannot be null"
**Solution:** Make sure `currentBusinessId()` returns a value. User must be logged in and have a business.
### Issue: "Seeing other businesses' data"
**Solution:** You forgot to scope by business_id! Check your query has `where('business_id', ...)`.
### Issue: "Permission check not working"
**Solution:** Check the permissions array in business_user pivot table. Make sure it's a JSON array.
### Issue: "Product has no business_id"
**Solution:** Products don't have direct business_id. Use `BusinessHelper::fromProduct($product)` to get seller's business.
---
## 📚 FILE STRUCTURE
```
app/
├── Events/Analytics/
│ └── HighIntentBuyerDetected.php
├── Helpers/
│ ├── BusinessHelper.php
│ └── helpers.php
├── Http/Controllers/Analytics/
│ ├── AnalyticsDashboardController.php
│ ├── ProductAnalyticsController.php
│ ├── MarketingAnalyticsController.php
│ ├── SalesAnalyticsController.php
│ └── BuyerIntelligenceController.php
├── Jobs/Analytics/
│ └── CalculateEngagementScore.php
├── Models/Analytics/
│ ├── AnalyticsEvent.php
│ ├── ProductView.php
│ ├── EmailCampaign.php
│ ├── EmailInteraction.php
│ ├── EmailClick.php
│ ├── ClickTracking.php
│ ├── UserSession.php
│ ├── IntentSignal.php
│ └── BuyerEngagementScore.php
└── Services/Analytics/
└── AnalyticsTracker.php
database/migrations/
├── 2024_01_01_000001_create_analytics_events_table.php
├── 2024_01_01_000002_create_product_views_table.php
├── 2024_01_01_000003_create_email_tracking_tables.php
├── 2024_01_01_000004_create_click_tracking_table.php
├── 2024_01_01_000005_create_user_sessions_and_intent_tables.php
├── 2024_01_01_000006_add_analytics_permissions_to_business_user.php
└── 2024_01_01_000007_create_analytics_jobs_table.php
resources/views/analytics/
├── dashboard.blade.php
├── products/
│ ├── index.blade.php
│ └── show.blade.php
├── marketing/
├── sales/
└── buyers/
routes/
├── channels.php (add broadcasting channel)
└── web.php (add analytics routes)
```
---
## ✅ DEFINITION OF DONE
- [ ] All 7 migrations run successfully
- [ ] BusinessHelper and helpers.php created and autoloaded
- [ ] All 10 analytics models created with business scoping
- [ ] AnalyticsTracker service working
- [ ] Queue jobs configured and tested
- [ ] Reverb events broadcasting
- [ ] All 5 controllers created
- [ ] Routes added with permission checks
- [ ] Navigation updated with Analytics section
- [ ] Permission UI tile added
- [ ] At least one dashboard view working
- [ ] Business isolation verified (no cross-business data)
- [ ] Permission checking works via business_user pivot
- [ ] Queue worker running for analytics jobs
- [ ] Test data can be created and viewed
---
## 🎉 READY TO IMPLEMENT!
Everything in the main guide is tailored to YOUR actual architecture:
- ✅ business_id (bigInteger) not UUID
- ✅ Explicit scoping, no global scopes
- ✅ business_user.permissions JSON
- ✅ Multi-business user support
- ✅ Product → Brand → Business hierarchy
- ✅ Reverb for real-time
- ✅ Redis queues (no Horizon needed)
**Estimated implementation time: 5-6 hours**
Start with helpers and migrations, then build up from there! 🚀

143
SESSION_ACTIVE Normal file
View File

@@ -0,0 +1,143 @@
# Active Session Tracker
**Session Date:** 2025-11-15 (Evening - ACTIVE)
**Branch:** feature/division-multi-tenancy
**Working Directory:** /home/kelly/git/hub
## 🟢 SESSION ACTIVE - Testing Merged Code
## Current Status
✅ **Merge COMPLETED:** origin/develop merged into feature/division-multi-tenancy (commit ec92385)
✅ **PR #53 Updated:** https://code.cannabrands.app/Cannabrands/hub/pulls/53
🔧 **Testing Phase:** Fixing issues from merge, preparing sample data
## What's Happening NOW
### ✅ Completed This Session
1. **Merged origin/develop** - Combined all changes from both branches
2. **Fixed User model** - Removed duplicate departments() method
3. **Fixed Filament auth** - /admin now redirects to /admin/login correctly
4. **Fixed Vite/CSS** - Login page now loads with full styling
- Added VITE_DEV_SERVER_URL=http://localhost:5173 to .env
- Fixed vite.config.js to preserve port numbers
5. **Implemented UUID generation** - 8-char UUIDs for all users
- Created UserObserver with creating() method
- Created migration: 2024_08_08_110000_add_uuid_to_users_table.php
- Converted 22 existing users to new format
6. **Fixed migrations** - Removed 7 duplicate migrations from merge
- Fixed businesses table date (2025 → 2024)
- All ~100+ migrations now passing successfully
7. **Fixed hashid column** - Created migration for products, components, brands, contacts
- Migration: 2025_10_08_000000_add_hashid_to_multiple_tables.php
- Adds 5-char hashid column (format: NNLLN like "26bf7")
- All 4 models using HasHashid trait now have the column
8. **DevSeeder completed** - All sample data loaded ✅
- 46 batches with COAs
- 4 test accounts (buyer, seller, admin, pending)
- 7 products across 3 brands
- 4 components + 1 assembly product
- 5 orders (full workflow: NEW → BUYER_APPROVED)
- 3 invoices (UNPAID, OVERDUE, PAID)
- 10 COA PDF files
### ✅ User Management Verified
**Both tasks already complete:**
1. **Owner filtering** - Already implemented in SettingsController.php:94-97
- Business owner excluded from user management list
- No changes needed
2. **User roles** - All users have roles assigned
- DevSeeder assigned roles to all test accounts
- No missing roles found
### 🔧 Quick-Switch Routes Issue (IN PROGRESS)
**Issue:** /admin/quick-switch forms submitting as GET instead of POST
**Routes:** ✅ Registered correctly in routes/web.php:184-187
**Root Cause:** Browser submits forms as GET on quick-switch page specifically
**Discovery:** Simple test form at /admin/test-form WORKS (POST succeeds)
**Problem:** Same exact form structure FAILS on quick-switch page (GET request)
**Suspect:** Tailwind CDN script or page-specific interference
**Status:** 🔄 Debugging - form structure works elsewhere but not on this page
**Next Steps:**
- Try removing Tailwind CDN
- Check for browser extensions
- Investigate what's different about quick-switch page
### 9. Restored Test Structure & Sample Data
**Created 8 missing migrations** to restore deleted columns:
- Parent/division structure (parent_id, division_name)
- Department features (code, capabilities, role pivot)
- Manufacturing work orders (14 new fields + constraints)
**Ran custom seeders:**
- ✅ CanopyAzBusinessRestructureSeeder - Created parent company with 3 divisions
- ✅ CanopyAzDepartmentsSeeder - 8 departments across divisions
- ✅ ManufacturingSampleDataSeeder - 9 test users, work orders, wash reports
**Test Users Created** (all have password: password)
- 3 Executives (kelly@, lonnie@, amy@canopyaz.com)
- 2 Cannabrands users (sarah@, michael@cannabrands.com)
- 1 Curagreen user (maria@curagreenllc.com)
- 3 Leopard AZ users (vinny@, james@, lisa@leopard-az.com)
### 📝 Still TODO
- [ ] Fix quick-switch form POST issue
- [ ] Clean up debug code when fixed:
- Remove /admin/test-post route
- Remove /admin/test-form route
- Remove DEBUG URL display
- Remove console.log script
- Remove CSRF meta tag
- [ ] Apply UUID pattern to orders/invoices (user mentioned wanting this from merge)
- [ ] Test merged features with sample data
## Files Modified This Session
**Configuration:**
- `.env` - Added VITE_DEV_SERVER_URL
- `vite.config.js` - Fixed port preservation
**Routes:**
- `routes/web.php` - Added quick-switch routes (lines 184-187)
**Models:**
- `app/Models/User.php` - Removed duplicate method
**Observers:**
- `app/Observers/UserObserver.php` - Added UUID generation
**Middleware:**
- `app/Http/Middleware/FilamentAdminAuthenticate.php` - Fixed redirect
**Migrations:**
- **Created:** `2024_08_08_110000_add_uuid_to_users_table.php`
- **Created:** `2025_10_08_000000_add_hashid_to_multiple_tables.php` (products, components, brands, contacts)
- **Created:** `2025_11_13_020000_add_hierarchy_to_businesses_table.php` (parent_id, division_name)
- **Created:** `2025_11_13_021000_add_code_to_departments_table.php`
- **Created:** `2025_11_13_022000_add_capabilities_to_departments_table.php`
- **Created:** `2025_11_13_023000_add_role_to_department_user_table.php`
- **Created:** `2025_11_13_024000_add_manufacturing_fields_to_work_orders_table.php` (14 columns)
- **Created:** `2025_11_13_025000_make_product_id_nullable_in_work_orders_table.php`
- **Created:** `2025_11_13_026000_make_quantity_fields_nullable_in_work_orders_table.php`
- **Created:** `2025_11_13_027000_update_work_orders_status_constraint.php` (added on_hold status)
- **Renamed:** businesses table (2025 → 2024)
- **Deleted:** 7 duplicates (module_flags x2, departments x2, work_orders, etc.)
## Session Documentation
**Full details:** See `SESSION_SUMMARY_2025-11-15.md`
## Vite Status
🟢 **Running** - http://localhost:5173/ (background process)
- Started with: `./vendor/bin/sail npm run dev &`
- Must be manually restarted after Sail restart
## Database Status
✅ **Migrations:** All passing (~180+ migrations)
✅ **Seeders:** All complete (RoleSeeder, SuperAdminSeeder, DevSeeder, DepartmentSeeder)
✅ **Sample Data:** Loaded (46 batches, 5 orders, 3 invoices, 4 test accounts)
## Important Notes
- **Merge conflicts:** 14 total, all resolved ✅
- **UUID format:** 8 characters (prevents ID enumeration)
- **Multi-division:** Our code wins on division/department topics
- **DevSeeder protection:** Only runs in local/staging ✅
---
**Last Updated:** 2025-11-15 Evening (while fixing hashid issue)

View File

@@ -0,0 +1,392 @@
# Session Summary - Dashboard Fixes & Security Improvements
**Date:** November 14, 2025
**Branch:** `feature/manufacturing-module`
**Location:** `/home/kelly/git/hub` (main repo)
---
## Overview
This session completed fixes from the previous session (Nov 13) and addressed critical errors in the dashboard and security vulnerabilities. All work was done in the main repository on `feature/manufacturing-module` branch.
---
## Completed Fixes
### 1. Dashboard TypeError Fix - Quality Calculation ✅
**Problem:** TypeError "Cannot access offset of type array on array" at line 526 in DashboardController
**Root Cause:** Code assumed quality data existed in Stage 2 wash reports, but WashReportController doesn't collect quality grades yet
**Files Changed:**
- `app/Http/Controllers/DashboardController.php` (lines 513-545)
**Solution:**
- Made quality grade extraction defensive
- Iterates through all yield types (works with both hash and rosin structures)
- Returns `null` for `avg_hash_quality` when no quality data exists
- Only calls `calculateAverageQuality()` when grades are available
**Code:**
```php
// Check all yield types for quality data (handles both hash and rosin structures)
foreach ($stage2['yields'] as $yieldType => $yieldData) {
if (isset($yieldData['quality']) && $yieldData['quality']) {
$qualityGrades[] = $yieldData['quality'];
}
}
// Only include quality if we have the data
if (empty($qualityGrades)) {
$component->past_performance = [
'has_data' => true,
'wash_count' => $pastWashes->count(),
'avg_yield' => round($avgYield, 1),
'avg_hash_quality' => null, // No quality data tracked
];
} else {
$avgQuality = $this->calculateAverageQuality($qualityGrades);
...
}
```
---
### 2. Department-Based Dashboard Visibility ✅
**Problem:** Owners and super admins saw sales metrics even when only in processing departments
**Architecture Violation:** Dashboard blocks should be determined by department groups, not role overrides
**Files Changed:**
- `app/Http/Controllers/DashboardController.php` (lines 56-60)
**Solution:**
- Removed owner and super admin overrides: `|| $isOwner || $isSuperAdmin`
- Dashboard blocks now determined ONLY by department assignments
- Added clear documentation explaining this architectural decision
**Before:**
```php
$showSalesMetrics = $hasSales || $isOwner || $isSuperAdmin;
```
**After:**
```php
// Dashboard blocks determined ONLY by department groups (not by ownership or admin role)
// Users see data for their assigned departments - add user to department for access
$showSalesMetrics = $hasSales;
$showProcessingMetrics = $hasSolventless;
$showFleetMetrics = $hasDelivery;
```
**Result:**
- Vinny (LAZ-SOLV) → sees ONLY processing blocks
- Sales team (CBD-SALES, CBD-MKTG) → sees ONLY sales blocks
- Multi-department users → see blocks for ALL their departments
- Ownership = business management, NOT data access
---
### 3. Dashboard View - Null Quality Handling ✅
**Problem:** View tried to display `null` quality in badge when quality data missing
**Files Changed:**
- `resources/views/seller/dashboard.blade.php` (lines 538-553)
**Solution:**
- Added check for both `has_data` AND `avg_hash_quality` before showing badge
- Shows "Not tracked" when wash history exists but no quality data
- Shows "—" when no wash history exists at all
**Code:**
```blade
@if($component->past_performance['has_data'] && $component->past_performance['avg_hash_quality'])
<div class="badge badge-sm ...">
{{ $component->past_performance['avg_hash_quality'] }}
</div>
@elseif($component->past_performance['has_data'])
<span class="text-xs text-base-content/40">Not tracked</span>
@else
<span class="text-xs text-base-content/40">—</span>
@endif
```
**Result:**
- Quality badges display correctly when data exists
- Graceful fallback when quality not tracked
- Clear distinction between "no history" vs "no quality data"
---
### 4. Filament Admin Middleware Registration ✅
**Problem:** Users with wrong user type getting 403 Forbidden when accessing `/admin`, requiring manual cookie deletion
**Files Changed:**
- `app/Providers/Filament/AdminPanelProvider.php` (lines 8, 72)
**Solution:**
- Imported custom middleware: `use App\Http\Middleware\FilamentAdminAuthenticate;`
- Registered in authMiddleware: `FilamentAdminAuthenticate::class`
- Middleware auto-logs out users without access and redirects to login
**Code:**
```php
// Added import
use App\Http\Middleware\FilamentAdminAuthenticate;
// Changed auth middleware
->authMiddleware([
FilamentAdminAuthenticate::class, // Instead of Authenticate::class
])
```
**How It Works:**
1. Detects when authenticated user lacks panel access
2. Logs them out completely (clears session)
3. Redirects to login with message: "Please login with an account that has access to this panel."
4. No more manual cookie deletion needed!
---
### 5. Parent Company Cross-Division Security ✅
**Problem:** Users could manually change URL slug to access divisions they're not assigned to
**Files Changed:**
- `routes/seller.php` (lines 11-19)
**Solution:**
- Enhanced route binding documentation
- Clarified that existing check already prevents cross-division access
- Check validates against `business_user` pivot table
**Security Checks:**
1. Unauthorized access to any business → 403
2. Parent company users accessing division URLs by changing slug → 403
3. Division users accessing other divisions' URLs by changing slug → 403
**Code:**
```php
// Security: Verify user is explicitly assigned to this business
// This prevents:
// 1. Unauthorized access to any business
// 2. Parent company users accessing division URLs by changing slug
// 3. Division users accessing other divisions' URLs by changing slug
// Users must be explicitly assigned via business_user pivot table
if (! auth()->check() || ! auth()->user()->businesses->contains($business->id)) {
abort(403, 'You do not have access to this business or division.');
}
```
---
## Files Modified
1. `app/Http/Controllers/DashboardController.php`
- Line 56-60: Removed owner override from dashboard visibility
- Lines 513-545: Fixed quality grade extraction to be defensive
2. `resources/views/seller/dashboard.blade.php`
- Lines 538-553: Added null quality handling in Idle Fresh Frozen table
3. `app/Providers/Filament/AdminPanelProvider.php`
- Line 8: Added FilamentAdminAuthenticate import
- Line 72: Registered custom middleware
4. `routes/seller.php`
- Lines 11-19: Enhanced security documentation for route binding
---
## Context from Previous Session (Nov 13)
This session addressed incomplete tasks from `SESSION_SUMMARY_2025-11-13.md`:
### Completed from Nov 13 Backlog:
1. ✅ Custom Middleware Registration (was created but not registered)
2. ✅ Parent Company Security Fix (documentation clarified)
### Already Complete from Nov 13:
- ✅ Manufacturing module implementation
- ✅ Seeder architecture with production protection
- ✅ Quick Switch impersonation feature
- ✅ Idle Fresh Frozen dashboard with past performance metrics
- ✅ Historical wash cycle data in Stage 1 form
### Low Priority (Not Blocking):
- Missing demo user "Kelly" - other demo users (Vinny, Maria) work fine
---
## Dashboard Block Visibility by Department
### Processing Department (LAZ-SOLV, CRG-SOLV):
**Shows:**
- ✅ Wash Reports, Average Yield, Active/Completed Work Orders stats
- ✅ Idle Fresh Frozen with past performance metrics
- ✅ Quick Actions: Start a New Wash, Review Wash Reports
- ✅ Recent Washes table
- ✅ Strain Performance section
**Hidden:**
- ❌ Revenue Statistics chart
- ❌ Low Stock Alerts (sales products)
- ❌ Recent Orders
- ❌ Top Performing Products
### Sales Department (CBD-SALES, CBD-MKTG):
**Shows:**
- ✅ Revenue Statistics chart
- ✅ Quick Actions: Add New Product, View All Orders
- ✅ Low Stock Alerts
- ✅ Recent Orders table
- ✅ Top Performing Products
**Hidden:**
- ❌ Processing metrics
- ❌ Idle Fresh Frozen
- ❌ Strain Performance
### Fleet Department (CRG-DELV):
**Shows:**
- ✅ Drivers, Active Vehicles, Fleet Size, Deliveries Today stats
- ✅ Quick Actions: Manage Drivers
**Hidden:**
- ❌ Sales and processing content
---
## Idle Fresh Frozen Display
### Dashboard Table (Processing Department)
| Material | Quantity | Past Avg Yield | Past Hash Quality | Action |
|----------|----------|----------------|-------------------|---------|
| Blue Dream - Fresh Frozen | 500g | **4.2%** (3 washes) | **Not tracked** | [Start Wash] |
| Cherry Pie - Fresh Frozen | 750g | **5.8%** (5 washes) | **Not tracked** | [Start Wash] |
**Notes:**
- "Past Avg Yield" calculates from historical wash data
- "Past Hash Quality" shows "Not tracked" because WashReportController doesn't collect quality grades yet
- "Start Wash" button links to Stage 1 form with strain pre-populated
---
## Testing Checklist
### Admin Panel 403 Fix
- [ ] Login as `seller@example.com` (non-admin)
- [ ] Navigate to `/admin`
- [ ] Expected: Auto-logout + redirect to login with message (no 403 error page)
### Cross-Division URL Protection
- [ ] Login as Vinny (Leopard AZ user)
- [ ] Go to `/s/leopard-az/dashboard` (should work)
- [ ] Change URL to `/s/cannabrands-az/dashboard`
- [ ] Expected: 403 error "You do not have access to this business or division."
### Dashboard Department Blocks
- [ ] Login as Vinny (LAZ-SOLV department)
- [ ] View dashboard
- [ ] Verify processing metrics show, sales metrics hidden
- [ ] Verify revenue chart is hidden
### Idle Fresh Frozen Performance Data
- [ ] View processing dashboard
- [ ] Check Idle Fresh Frozen section
- [ ] Verify Past Avg Yield shows percentages
- [ ] Verify Past Hash Quality shows "Not tracked"
### Dashboard TypeError Fix
- [ ] Access dashboard as any processing user
- [ ] Verify no TypeError when viewing Idle Fresh Frozen
- [ ] Verify quality column displays gracefully
---
## Architecture Decisions
### 1. Department-Based Access Control
**Decision:** Dashboard blocks determined ONLY by department assignments, not by roles or ownership.
**Rationale:**
- Clearer separation of concerns
- Easier to audit ("what does this user see?")
- Scales better for multi-department users
- Ownership = business management, not data access
**Implementation:**
- User assigned to LAZ-SOLV → sees processing data only
- User assigned to CBD-SALES → sees sales data only
- User assigned to both → sees both
### 2. Working in Main Repo (Not Worktree)
**Decision:** All work done in `/home/kelly/git/hub` on `feature/manufacturing-module` branch.
**Rationale:**
- More traditional workflow
- Simpler to understand and maintain
- Worktree added complexity without clear benefit
- Can merge/cherry-pick from worktree if needed later
---
## Known Issues / Future Enhancements
### 1. Quality Grade Collection Not Implemented
**Status:** Deferred - not blocking
**Issue:** WashReportController Stage 2 doesn't collect quality grades yet
**Impact:** Dashboard shows "Not tracked" for all quality data
**Future Work:** Update `WashReportController::storeStage2()` to:
- Accept quality inputs: `quality_fresh_press_120u`, `quality_cold_cure_90u`, etc.
- Store in `$metadata['stage_2']['yields'][...]['quality']`
- Then dashboard will automatically show quality badges
### 2. Worktree Branch Status
**Status:** Inactive but preserved
**Location:** `/home/kelly/git/hub-worktrees/manufacturing-features`
**Branch:** `feature/manufacturing-features`
**Decision:** Keep as reference, all new work in main repo
---
## Cache Commands Run
```bash
./vendor/bin/sail artisan view:clear
./vendor/bin/sail artisan cache:clear
./vendor/bin/sail artisan config:clear
./vendor/bin/sail artisan route:clear
```
---
## Next Steps (When Resuming)
1. **Test all fixes** using checklist above
2. **Run test suite:** `php artisan test --parallel`
3. **Run Pint:** `./vendor/bin/pint`
4. **Decide on worktree:** Keep as backup or merge/delete
5. **Future:** Implement quality grade collection in WashReportController
---
## Git Information
**Branch:** `feature/manufacturing-module`
**Location:** `/home/kelly/git/hub`
**Uncommitted Changes:** 4 files modified (ready to commit)
**Modified Files:**
- `app/Http/Controllers/DashboardController.php`
- `app/Providers/Filament/AdminPanelProvider.php`
- `resources/views/seller/dashboard.blade.php`
- `routes/seller.php`
---
**Session completed:** 2025-11-14
**All fixes tested:** Pending user testing
**Ready for commit:** Yes

View File

@@ -0,0 +1,237 @@
# Session Summary - November 15, 2025
**Branch:** `feature/division-multi-tenancy`
**Started:** Evening, November 15, 2025
**Status:** IN PROGRESS
---
## Session Goals
1. Complete merge of `origin/develop` into `feature/division-multi-tenancy`
2. Test merged code with sample data from DevSeeder
3. Fix multi-division/multi-department integration issues
---
## Work Completed
### 1. Merge Completion (from previous session)
**Merged `origin/develop`** into feature branch
- Combined manufacturing routes (Purchase Orders, Work Orders, Labs, Batches with QR)
- Merged settings routes (sales-config + delivery windows)
- Preserved all work from both branches
- **Commit:** `ec92385` - Merge develop into feature/division-multi-tenancy
- **PR #53** automatically updated
### 2. Fixed Duplicate User Model Methods
**Removed duplicate `departments()` method** - app/Models/User.php:182-186
- Kept multi-division aware version with `->withPivot('role')` (lines 188-193)
- Deleted simpler version without role tracking
- **Why:** Merge conflict created two identical relationship methods
### 3. Fixed Filament Admin Login Redirect
**Updated FilamentAdminAuthenticate middleware** - app/Http/Middleware/FilamentAdminAuthenticate.php:27-28
- Changed from throwing `AuthenticationException` to direct redirect
- Now redirects to `/admin/login` instead of `/login`
- **Before:** Exception → Laravel handler → `/login`
- **After:** Direct redirect → `/admin/login`
### 4. Fixed Vite/CSS Loading Issues
#### Problem
- Login page showed unstyled HTML (no CSS/images)
- Browser error: `Access to script at 'http://vite.localhost/...' blocked by CORS`
#### Root Cause
- Laravel generating `http://vite.localhost/` URLs
- Browser couldn't resolve `vite.localhost` hostname
- Vite dev server actually at `http://localhost:5173`
#### Solution
**Added VITE_DEV_SERVER_URL to .env** - line 6
```env
VITE_DEV_SERVER_URL=http://localhost:5173
```
**Fixed vite.config.js** - lines 16-31
- Preserved full URL including port when VITE_DEV_SERVER_URL is set
- Old logic stripped port: `localhost:5173``localhost`
- New logic preserves: `localhost:5173`
**Restarted Sail containers** to reload environment variables
### 5. Implemented User UUID Generation
#### Background
- User wants 8-character random UUIDs to prevent ID enumeration
- Pattern already used elsewhere in codebase (businesses, etc.)
- Security through obscurity for URLs
#### Implementation
**Created UserObserver** - app/Observers/UserObserver.php:13-24
- Added `creating()` method to auto-generate UUIDs
- Uses `strtoupper(Str::random(8))` format
- Observer already existed (for approval emails), just added UUID generation
**Removed failed boot() attempt** from User model
- boot() method wasn't triggering (conflict with HasRoles trait)
- Observer pattern more reliable
**Created UUID migration** - database/migrations/2024_08_08_110000_add_uuid_to_users_table.php
- Adds `uuid` column (string, 18 chars, nullable, unique, indexed)
- Positioned early in migration order (right after create_users_table)
**Updated existing UUIDs** to 8-character format
- 22 users had 18-char truncated UUIDs from previous implementation
- Converted all to 8-char format using tinker
### 6. Fixed Migration Conflicts
#### Renamed businesses table migration
**Fixed date:** `2025_08_08``2024_08_08`
- Was running AFTER `permission_audit_logs` which referenced it
- Now runs before (correct dependency order)
#### Removed duplicate migrations from merge
✅ **Deleted duplicates:**
- `2025_11_12_035044_add_module_flags_to_businesses_table.php`
- `2025_11_12_180802_add_module_flags_to_businesses_table.php`
- `2025_11_13_010000_create_departments_table.php`
- `2025_11_13_010100_create_department_user_table.php`
- `2025_11_13_010200_create_work_orders_table.php`
- `2025_11_13_020000_add_hierarchy_to_businesses_table.php`
- `2025_11_13_175338_create_order_cancellation_requests_table.php`
**Total:** 7 duplicate migrations removed
#### Kept originals
- `2025_11_07_000001_create_departments_table.php`
- `2025_11_07_000002_create_department_user_table.php`
- `2025_11_08_194230_add_module_flags_to_businesses_table.php`
- Work orders table from Nov 7 series ✅
### 7. Database Migration Success
**Ran migrate:fresh** - All ~100+ migrations completed
**RoleSeeder** - Created 9 roles
**SuperAdminSeeder** - Created admin@cannabrands.com with UUID
### 8. Fixed Hashid Column Issue
**Error:** DevSeeder failing with `column "hashid" does not exist`
**Location:** app/Traits/HasHashid.php:37 (Contact model)
#### Investigation
✅ **Found 4 models using HasHashid trait:**
- Product
- Component
- Brand
- Contact
**Created migration** - database/migrations/2025_10_08_000000_add_hashid_to_multiple_tables.php
- Adds `hashid` column (string, 5 chars, nullable, unique, indexed)
- Format: NNLLN (e.g., "26bf7", "83jk2")
- Trait auto-generates via bootHasHashid() on creation
- Added to all 4 tables in single migration
#### Results
**All migrations passed** (~180+ total)
**DevSeeder completed successfully** - Sample data loaded:
- 46 batches with COAs
- 4 test accounts (buyer@example.com, seller@example.com, admin@example.com, pending-buyer@example.com)
- 7 products across 3 brands (Desert Bloom Premium, Peak Extracts, Sunset Valley Edibles)
- 4 components + 1 assembly product (Blue Dream Pre-Roll with BOM)
- 5 orders covering full workflow (NEW → ACCEPTED → IN_PROGRESS → READY_FOR_DELIVERY → BUYER_APPROVED)
- 3 invoices (UNPAID, OVERDUE, PAID)
- 10 COA PDF files
### 9. Verified User Management Features
**User Request:**
1. "seller owner should never be listed in the user table"
2. "check the database if the user has a role and if not assign one where missing"
#### Findings
**Owner filtering already implemented** - SettingsController.php:94-97
```php
// Exclude the business owner from the list
if ($business->owner_user_id) {
$query->where('users.id', '!=', $business->owner_user_id);
}
```
- Business owners excluded from user management list
- No changes needed
**All users have roles** - Verified with database query
- DevSeeder assigned appropriate roles to all test accounts
- No users without roles found
- No action needed
---
## Files Modified
### Configuration
- `.env` - Added `VITE_DEV_SERVER_URL=http://localhost:5173`
- `vite.config.js` - Fixed port preservation logic (lines 16-31)
### Models
- `app/Models/User.php` - Removed duplicate departments() method
### Observers
- `app/Observers/UserObserver.php` - Added UUID generation (lines 13-24)
### Middleware
- `app/Http/Middleware/FilamentAdminAuthenticate.php` - Fixed redirect (line 28)
### Migrations Created
- `database/migrations/2024_08_08_110000_add_uuid_to_users_table.php` - NEW
### Migrations Renamed
- `2025_08_08_130000_create_businesses_table.php``2024_08_08_130000_create_businesses_table.php`
### Migrations Deleted
- 7 duplicate migrations removed (listed above)
---
## Testing Status
- ✅ `/login` page loads with full CSS/styling
- ✅ `/admin` redirects to `/admin/login` (Filament)
- ✅ User UUIDs generating correctly (8-char format)
- ✅ Migrations all passing
- ❌ DevSeeder incomplete (hashid column missing)
- ⏸️ Sample data not yet loaded (orders, invoices, customers)
---
## Outstanding Tasks
### High Priority
1. **Fix hashid column issue** - Create migration for Contact (and other models using HasHashid)
2. **Complete DevSeeder** - Get sample orders, invoices, customers for testing
3. **Test UUID pattern** - Verify URLs use UUID not ID
### Medium Priority
4. **Filter owner from user management** - Owner shouldn't be able to edit themselves
5. **Assign missing roles** - Some users lack role assignments
### Low Priority
6. **Apply UUID pattern to orders/invoices** - User wants to extend pattern from merge
---
## Notes
- **Merge quality:** Other dev didn't understand multi-division/multi-department architecture
- **Our code wins:** For division/department topics, keep our implementation
- **UUID format:** 8 characters (good balance of brevity + uniqueness)
- **Vite:** Must be manually started with `sail npm run dev` (doesn't auto-start)
- **DevSeeder protection:** Only runs in `local`/`staging` environments ✅
---
## Next Session
Continue from: **Fixing hashid column issue to complete DevSeeder**

373
WORKTREE_BOUNDARIES.md Normal file
View File

@@ -0,0 +1,373 @@
# Git Worktrees - Boundaries & Integration Guide
**Created:** 2025-10-30
**Purpose:** Define clear ownership boundaries between parallel development worktrees to prevent merge conflicts
---
## Worktree Overview
### 1. `labs-batch-qr-codes` (this worktree)
**Branch:** `feature/batch-tracking-coa-qr`
**Location:** `.worktrees/labs-batch-qr-codes`
**Focus:** Labs, Batch Management, COA Tracking, QR Code Generation
### 2. `order-flow-updates`
**Branch:** `feature/order-flow-updates`
**Location:** `.worktrees/order-flow-updates`
**Focus:** Order processing, fulfillment, workflow improvements
---
## Model Ownership
### Owned by `labs-batch-qr-codes` ⚠️ DO NOT MODIFY IN order-flow-updates
| Model | Responsibility | Key Changes |
|-------|----------------|-------------|
| `Batch.php` | **OWNER** - Batch tracking, lab results, COA management | Expanded from 4855 to 15784 bytes - major changes |
| `Lab.php` | **OWNER** - Lab entities, test results | Expanded from 6545 to 10440 bytes |
| `BatchCoaFile.php` | **OWNER** - NEW MODEL - Batch COA file attachments | New file |
| `LabCoaFile.php` | **OWNER** - NEW MODEL - Lab COA file attachments | New file |
| `WorkOrder.php` | **OWNER** - NEW MODEL - Work order tracking | New file |
### Shared Models (Coordinate Changes!)
| Model | Primary Owner | Secondary Use | Coordination Strategy |
|-------|---------------|---------------|----------------------|
| `Order.php` | order-flow-updates | labs-batch-qr-codes reads for batch allocation | ⚠️ order-flow modifies, labs reads only |
| `OrderItem.php` | order-flow-updates | labs-batch-qr-codes reads for allocation | ⚠️ order-flow modifies, labs reads only |
| `OrderItemBatchAllocation.php` | labs-batch-qr-codes | order-flow-updates may read | NEW MODEL - allocation tracking |
| `Product.php` | SHARED | Both may read/modify | ⚠️ Coordinate any schema changes |
### Owned by `order-flow-updates` ⚠️ DO NOT MODIFY IN labs-batch-qr-codes
| Model | Responsibility |
|-------|----------------|
| `Order.php` | Order workflow, status management, buyer/seller interactions |
| `OrderItem.php` | Order line items, pricing, quantities |
| `Invoice.php` | Invoicing, payment tracking |
| `Manifest.php` | Shipping manifests, delivery tracking |
---
## Service Layer Ownership
### Owned by `labs-batch-qr-codes`
```
app/Services/
├── QrCodeService.php ✅ OWNER - QR code generation for batches
├── BatchAllocationService.php ✅ OWNER - NEW - Batch inventory allocation
└── PickingTicketService.php ✅ OWNER - NEW - Generate picking tickets
```
### Owned by `order-flow-updates`
```
app/Services/
├── OrderProcessingService.php (create if needed - order workflow)
├── FulfillmentService.php (create if needed - order fulfillment)
└── ShippingService.php (create if needed - shipping logic)
```
---
## Controller Ownership
### Owned by `labs-batch-qr-codes`
```
app/Http/Controllers/
├── PublicCoaController.php ✅ OWNER - NEW - Public COA viewing
└── Seller/
├── BatchController.php ✅ OWNER - NEW - Batch CRUD
└── LabController.php ✅ OWNER - NEW - Lab CRUD
```
### Owned by `order-flow-updates`
```
app/Http/Controllers/
├── Buyer/OrderController.php (order placement, tracking)
├── Seller/OrderController.php (order fulfillment, shipping)
└── OrderFulfillmentController.php (create if needed)
```
---
## Route Ownership
### Owned by `labs-batch-qr-codes`
- `routes/web.php` - Public COA routes (`/coa/{batchNumber}`)
- `routes/seller.php` - Seller batch/lab management routes (`/s/batches/*`, `/s/labs/*`)
### Owned by `order-flow-updates`
- `routes/web.php` - Order-related public routes
- `routes/buyer.php` - Buyer order routes (`/b/orders/*`)
- `routes/seller.php` - Seller order fulfillment routes (`/s/orders/*`)
**⚠️ CONFLICT ZONE:** Both worktrees may modify `routes/seller.php` and `routes/web.php`
**Resolution Strategy:**
1. Add routes in alphabetical/logical groupings
2. Use clear comments to separate route groups
3. Person merging PRs will need to carefully merge both route files
---
## Database Migrations Ownership
### Owned by `labs-batch-qr-codes`
```
database/migrations/
├── 2025_10_30_000001_enhance_batches_table.php
├── 2025_10_30_000002_create_order_item_batch_allocations_table.php
├── 2025_10_30_000003_create_batch_components_table.php
├── 2025_10_30_000005_create_lab_coa_files_table.php
├── 2025_10_30_000006_add_barcode_to_products_table.php
├── 2025_10_30_000007_create_work_orders_table.php
├── 2025_10_30_000008_add_buyer_approval_to_orders_table.php
├── 2025_10_30_200000_add_old_crm_fields_to_labs_table.php
├── 2025_10_30_210000_add_lab_fields_to_batches_table.php
├── 2025_10_30_210001_create_batch_coa_files_table.php
└── 2025_10_30_223624_add_cannabinoid_unit_to_batches_table.php
```
### Owned by `order-flow-updates`
```
database/migrations/
└── 2025_10_30_*.php (any new migrations for order flow)
```
**⚠️ Migration Numbering:** Use timestamps to avoid conflicts. Both worktrees can create migrations independently.
---
## Filament Resources Ownership
### Owned by `labs-batch-qr-codes`
```
app/Filament/Resources/
├── BatchResource.php ✅ OWNER - NEW
├── BatchResource/ ✅ OWNER - NEW
├── LabResource.php ✅ OWNER - NEW
└── LabResource/ ✅ OWNER - NEW
```
---
## View Ownership
### Owned by `labs-batch-qr-codes`
```
resources/views/
├── public/coa/ ✅ OWNER - NEW - Public COA views
├── seller/batches/ ✅ OWNER - NEW - Batch management
├── seller/labs/ ✅ OWNER - NEW - Lab management
└── pdfs/picking-ticket.blade.php ✅ OWNER - NEW
```
### Owned by `order-flow-updates`
```
resources/views/
├── buyer/orders/ (order placement, tracking)
├── seller/orders/ (order fulfillment)
└── pdfs/ (order-related PDFs like invoices, manifests)
```
---
## Integration Points (Where Worktrees Interact)
### 1. Batch Allocation for Orders
**Interface:** `OrderItemBatchAllocation` model (owned by labs-batch-qr-codes)
**Contract:**
```php
// When order-flow needs to check batch availability
$allocation = OrderItemBatchAllocation::where('order_item_id', $orderItemId)->first();
$batch = $allocation->batch; // Access batch info
// Read-only access from order-flow worktree
$batch->quantity_available; // Check available inventory
$batch->batch_number; // Display in order details
$batch->qr_code_path; // Show QR code in order view
```
**Rules:**
- ✅ `order-flow-updates` can READ `OrderItemBatchAllocation` and `Batch`
- ❌ `order-flow-updates` should NOT WRITE to `Batch` directly
- ✅ Use `BatchAllocationService` if allocation logic is needed (coordinate with labs worktree)
### 2. Product Information
**Interface:** `Product` model (shared)
**Contract:**
```php
// Both worktrees can read product info
$product->name;
$product->barcode; // Added by labs-batch-qr-codes
$product->batches(); // Relationship to batches
```
**Rules:**
- ✅ Both can read `Product`
- ⚠️ Coordinate if either needs to add fields to products table
- ⚠️ If adding relationships, document them here
### 3. Order Information
**Interface:** `Order` and `OrderItem` models (owned by order-flow-updates)
**Contract:**
```php
// labs-batch-qr-codes reads order info for batch allocation
$order->items; // Order line items
$orderItem->product_id; // What product was ordered
$orderItem->quantity; // How much was ordered
$orderItem->batchAllocations(); // NEW relationship added by labs worktree
```
**Rules:**
- ✅ `labs-batch-qr-codes` can READ `Order` and `OrderItem`
- ❌ `labs-batch-qr-codes` should NOT modify order workflow/status
- ✅ `labs-batch-qr-codes` CAN add relationship methods (like `batchAllocations()`)
---
## QR Code Feature Implementation Status
### Already Implemented in `labs-batch-qr-codes`
`QrCodeService` with methods:
- `generateForBatch()` - Basic QR code generation
- `generateWithLogo()` - QR with brand logo overlay
- `bulkGenerate()` - Generate for multiple batches
- `regenerate()` - Replace existing QR code
- `delete()` - Remove QR code
- `download()` - Download QR code file
- `generateDataUrl()` - Inline base64 data URL
✅ Uses `simplesoftwareio/simple-qrcode` package (local generation)
### Still Needed (Port from Old CRM)
❌ QR Code Monkey API integration (for styled/branded QR codes)
❌ Controller endpoints for QR generation UI
❌ Routes for QR code actions
❌ UI integration in Batch management
❌ Bulk QR generation UI
❌ Format selection (PNG, SVG, PDF)
### Old CRM QR Code Features Reference
Location: `/Users/jon/projects/cannabrands/cannabrands_crm/vendor/venturedrake/laravel-crm/src/Http/Controllers/LabController.php`
**Key Features:**
- Lines 101-182: `generateQRCode()` - QR Code Monkey API integration
- Styled QR codes with brand logos
- Multiple format support
- Links to public COA pages
---
## Merge Strategy
### When Both Features Are Complete
1. **Merge `labs-batch-qr-codes` first** (has more foundational changes)
- Contains new models that `order-flow` may depend on
- Establishes batch tracking foundation
2. **Then merge `order-flow-updates`**
- Can build on top of batch allocation features
- Order flow can reference batch allocations
3. **Watch for conflicts in:**
- `routes/seller.php` - Both add seller routes
- `routes/web.php` - Both may add public routes
- `app/Models/Order.php` - Both modified
- `app/Models/OrderItem.php` - Both modified
- `app/Models/Product.php` - Both may modify
### Conflict Resolution Protocol
If you encounter a merge conflict:
1. **DO NOT** guess which code to keep
2. **DO** read both versions carefully
3. **DO** preserve functionality from both branches
4. **DO** test thoroughly after merge
5. **DO** ask the other developer if unsure
---
## Communication Protocol
### Before Starting Work on Shared Code
If you need to modify a shared model/controller:
1. Check this document first
2. If it's marked as "other worktree owns it" → coordinate first
3. Post in team chat: "I need to modify [file] in [worktree], heads up!"
4. Wait for acknowledgment before proceeding
### After Completing a Major Feature
1. Update this document if ownership changes
2. Notify the other worktree developer
3. Document any new integration points above
---
## Quick Reference
### I'm working in `labs-batch-qr-codes`, can I modify...?
| File/Model | Can Modify? | Notes |
|------------|-------------|-------|
| `Batch.php` | ✅ YES | You own this |
| `Lab.php` | ✅ YES | You own this |
| `Order.php` | ⚠️ READ ONLY | Add relationships OK, don't change workflow |
| `OrderItem.php` | ⚠️ READ ONLY | Add relationships OK, don't change schema |
| `Product.php` | ⚠️ COORDINATE | Shared - check with other worktree first |
| `QrCodeService.php` | ✅ YES | You own this |
| `routes/seller.php` | ⚠️ CAREFUL | Both worktrees use - add routes with clear comments |
### I'm working in `order-flow-updates`, can I modify...?
| File/Model | Can Modify? | Notes |
|------------|-------------|-------|
| `Order.php` | ✅ YES | You own workflow/business logic |
| `OrderItem.php` | ✅ YES | You own this |
| `Batch.php` | ⚠️ READ ONLY | Don't modify - use BatchAllocationService |
| `Lab.php` | ❌ NO | Owned by labs worktree |
| `Product.php` | ⚠️ COORDINATE | Shared - check with other worktree first |
| `OrderItemBatchAllocation.php` | ⚠️ READ ONLY | Read for display, don't modify allocation logic |
| `routes/seller.php` | ⚠️ CAREFUL | Both worktrees use - add routes with clear comments |
---
## Questions?
If this document doesn't answer your question:
1. Ask the developer working in the other worktree
2. Update this document with the answer
3. Commit the updated boundaries doc to your branch
---
**Last Updated:** 2025-10-30
**Maintained By:** Both worktree developers

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Console\Commands;
use App\Models\PermissionAuditLog;
use Illuminate\Console\Command;
class CleanupPermissionAuditLogs extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'permissions:cleanup-audit
{--dry-run : Show what would be deleted without actually deleting}
{--force : Skip confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete expired permission audit logs (non-critical logs past their expiration date)';
/**
* Execute the console command.
*/
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$isForced = $this->option('force');
$this->info('🔍 Scanning for expired permission audit logs...');
$this->newLine();
// Find expired logs
$expiredLogs = PermissionAuditLog::expired()->get();
if ($expiredLogs->isEmpty()) {
$this->info('✅ No expired audit logs found. Everything is up to date!');
return self::SUCCESS;
}
// Statistics
$totalCount = $expiredLogs->count();
$oldestLog = $expiredLogs->sortBy('created_at')->first();
$newestLog = $expiredLogs->sortByDesc('created_at')->first();
// Display summary
$this->table(
['Metric', 'Value'],
[
['Expired logs found', $totalCount],
['Oldest expired log', $oldestLog->created_at->format('Y-m-d H:i:s')],
['Newest expired log', $newestLog->created_at->format('Y-m-d H:i:s')],
['Date range', $oldestLog->created_at->diffForHumans($newestLog->created_at, true)],
]
);
$this->newLine();
// Show sample of logs to be deleted
$this->info('📋 Sample of logs to be deleted:');
$sampleLogs = $expiredLogs->take(5);
foreach ($sampleLogs as $log) {
$this->line(sprintf(
' • [%s] %s - %s (expired %s)',
$log->created_at->format('Y-m-d'),
$log->action_name,
$log->targetUser?->name ?? 'Unknown User',
$log->expires_at->diffForHumans()
));
}
if ($totalCount > 5) {
$this->line(" ... and {$totalCount} more");
}
$this->newLine();
// Dry run mode
if ($isDryRun) {
$this->warn('🧪 DRY RUN MODE - No logs will be deleted');
$this->info("Would delete {$totalCount} expired audit logs");
return self::SUCCESS;
}
// Confirmation prompt (unless forced)
if (! $isForced) {
$confirmed = $this->confirm(
"Are you sure you want to delete {$totalCount} expired audit logs?",
false
);
if (! $confirmed) {
$this->info('❌ Cleanup cancelled');
return self::SUCCESS;
}
}
// Perform deletion
$this->info('🗑️ Deleting expired audit logs...');
$progressBar = $this->output->createProgressBar($totalCount);
$progressBar->start();
$deletedCount = 0;
$errorCount = 0;
foreach ($expiredLogs as $log) {
try {
$log->delete();
$deletedCount++;
} catch (\Exception $e) {
$errorCount++;
$this->error("Failed to delete log ID {$log->id}: {$e->getMessage()}");
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
// Final summary
if ($errorCount === 0) {
$this->info("✅ Successfully deleted {$deletedCount} expired audit logs");
} else {
$this->warn("⚠️ Deleted {$deletedCount} logs with {$errorCount} errors");
}
// Show remaining stats
$remainingTotal = PermissionAuditLog::count();
$remainingCritical = PermissionAuditLog::critical()->count();
$remainingNonExpired = $remainingTotal - $remainingCritical;
$this->newLine();
$this->info('📊 Database statistics after cleanup:');
$this->table(
['Category', 'Count'],
[
['Critical logs (kept forever)', $remainingCritical],
['Non-critical logs (not yet expired)', $remainingNonExpired],
['Total remaining logs', $remainingTotal],
]
);
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Batch;
use App\Models\BatchCoaFile;
use App\Models\Product;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class SeedCoaData extends Command
{
protected $signature = 'seed:coa-data';
protected $description = 'Add COA files to existing batches for testing';
public function handle(): int
{
$this->info('Seeding COA data for testing...');
// Get all active products with batches
$products = Product::with('batches')
->where('is_active', true)
->whereHas('batches')
->get();
if ($products->isEmpty()) {
$this->warn('No products with batches found. Run the main seeder first.');
return 1;
}
$this->info("Found {$products->count()} products with batches");
$coaCount = 0;
foreach ($products as $product) {
foreach ($product->batches as $batch) {
// Skip if batch already has COAs
if ($batch->coaFiles()->exists()) {
continue;
}
// Create 1-2 COA files per batch
$numCoas = rand(1, 2);
for ($i = 1; $i <= $numCoas; $i++) {
$isPrimary = ($i === 1);
// Create a dummy PDF file
$fileName = "COA-{$batch->batch_number}-{$i}.pdf";
$filePath = "businesses/{$product->brand->business->uuid}/batches/{$batch->id}/coas/{$fileName}";
// Create dummy PDF content (just for testing)
$pdfContent = $this->generateDummyPdf($batch, $product);
Storage::disk('local')->put($filePath, $pdfContent);
// Create COA file record
BatchCoaFile::create([
'batch_id' => $batch->id,
'file_name' => $fileName,
'file_path' => $filePath,
'file_size' => strlen($pdfContent),
'mime_type' => 'application/pdf',
'is_primary' => $isPrimary,
'display_order' => $i,
]);
$coaCount++;
}
$this->line(" Added {$numCoas} COA(s) for batch {$batch->batch_number}");
}
}
$this->info("✓ Created {$coaCount} COA files");
return 0;
}
private function generateDummyPdf(Batch $batch, Product $product): string
{
// Generate a simple text-based "PDF" for testing
// In a real system, you'd use a PDF library
return "%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/Resources <<
/Font <<
/F1 <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
/MediaBox [0 0 612 792]
/Contents 4 0 R
>>
endobj
4 0 obj
<<
/Length 250
>>
stream
BT
/F1 12 Tf
50 700 Td
(CERTIFICATE OF ANALYSIS) Tj
0 -30 Td
(Batch Number: {$batch->batch_number}) Tj
0 -20 Td
(Product: {$product->name}) Tj
0 -20 Td
(Test Date: ".now()->format('Y-m-d').') Tj
0 -30 Td
(THC: 25.5%) Tj
0 -20 Td
(CBD: 0.8%) Tj
0 -20 Td
(Status: PASSED) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000317 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
619
%%EOF';
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Location;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class SeedTestOrders extends Command
{
protected $signature = 'seed:test-orders {--clean : Delete existing test orders first}';
protected $description = 'Create test orders at various statuses for testing the order flow';
public function handle(): int
{
if ($this->option('clean')) {
$this->info('Cleaning up existing test orders...');
$testOrders = Order::where('order_number', 'like', 'TEST-%')->get();
foreach ($testOrders as $order) {
// Delete order items first, then the order
$order->items()->delete();
$order->delete();
}
}
$this->info('Creating test orders at various statuses...');
// Get a buyer business (retailer) and location
$buyerBusiness = Business::where('business_type', 'retailer')->first();
if (! $buyerBusiness) {
$this->error('No buyer business found. Run the main seeder first.');
return 1;
}
$buyerLocation = Location::where('business_id', $buyerBusiness->id)->first();
if (! $buyerLocation) {
$this->error('No buyer location found. Run the main seeder first.');
return 1;
}
// Get a buyer user
$buyerUser = User::where('user_type', 'buyer')->first();
if (! $buyerUser) {
$this->error('No buyer user found. Run the main seeder first.');
return 1;
}
// Get products with batches and COAs
$products = Product::with(['brand.business', 'batches.coaFiles'])
->where('is_active', true)
->whereHas('batches.coaFiles')
->limit(10)
->get();
if ($products->isEmpty()) {
$this->error('No products with COAs found. Run seed:coa-data first.');
return 1;
}
$orders = [];
// 1. Order ready for pre-delivery review (after picking, before delivery)
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(3),
'ready_for_delivery',
'TEST-PREDELIVERY-001',
'Order ready for pre-delivery review (Review #1)'
);
// 2. Order delivered and ready for post-delivery acceptance (Review #2)
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(3),
'delivered',
'TEST-DELIVERED-001',
'Order delivered and ready for acceptance (Review #2)'
);
// 3. Order in progress (picking)
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(2),
'in_progress',
'TEST-PICKING-001',
'Order currently being picked'
);
// 4. Order accepted and approved for delivery
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(2),
'approved_for_delivery',
'TEST-APPROVED-001',
'Order approved for delivery (passed Review #1)'
);
// 5. Order out for delivery
$orders[] = $this->createTestOrder(
$buyerBusiness,
$buyerLocation,
$products->random(2),
'out_for_delivery',
'TEST-OUTDELIVERY-001',
'Order out for delivery'
);
$this->newLine();
$this->info('✓ Created '.count($orders).' test orders');
$this->newLine();
$this->table(
['Order Number', 'Status', 'Items', 'Description'],
collect($orders)->map(fn ($order) => [
$order->order_number,
$order->status,
$order->items->count(),
$this->getOrderDescription($order->order_number),
])
);
$this->newLine();
$this->info('You can now test the order flow in the UI:');
$this->line(' • Pre-delivery review: /b/'.$buyerBusiness->slug.'/orders/TEST-PREDELIVERY-001/pre-delivery-review');
$this->line(' • Post-delivery acceptance: /b/'.$buyerBusiness->slug.'/orders/TEST-DELIVERED-001/acceptance');
return 0;
}
private function createTestOrder(
Business $buyerBusiness,
Location $buyerLocation,
$products,
string $status,
string $orderNumber,
string $description
): Order {
return DB::transaction(function () use ($buyerBusiness, $buyerLocation, $products, $status, $orderNumber) {
// Get first product's seller business
$sellerBusiness = $products->first()->brand->business;
// Calculate totals
$subtotal = $products->sum(function ($product) {
return $product->wholesale_price * 5; // 5 units each
});
$surchargePercent = Order::getSurchargePercentage('net_30');
$surcharge = $subtotal * ($surchargePercent / 100);
$taxRate = $buyerBusiness->getTaxRate();
$tax = ($subtotal + $surcharge) * $taxRate;
$total = $subtotal + $surcharge + $tax;
// Create order
$order = Order::create([
'order_number' => $orderNumber,
'business_id' => $buyerBusiness->id,
'seller_business_id' => $sellerBusiness->id,
'location_id' => $buyerLocation->id,
'status' => $status,
'fulfillment_method' => 'delivery',
'payment_terms' => 'net_30',
'subtotal' => $subtotal,
'tax' => $tax,
'surcharge' => $surcharge,
'total' => $total,
'notes' => 'Test order for flow testing',
]);
// Create order items with batch allocation
foreach ($products as $product) {
$batch = $product->batches->first();
$quantity = 5;
// Allocate inventory
if ($batch) {
$batch->allocate($quantity);
}
OrderItem::create([
'order_id' => $order->id,
'product_id' => $product->id,
'batch_id' => $batch?->id,
'product_name' => $product->name,
'product_sku' => $product->sku,
'brand_name' => $product->brand->name,
'batch_number' => $batch?->batch_number,
'quantity' => $quantity,
'unit_price' => $product->wholesale_price,
'line_total' => $product->wholesale_price * $quantity,
]);
}
return $order->fresh(['items']);
});
}
private function getOrderDescription(string $orderNumber): string
{
return match (true) {
str_contains($orderNumber, 'PREDELIVERY') => 'Order ready for pre-delivery review (Review #1)',
str_contains($orderNumber, 'DELIVERED') => 'Order delivered and ready for acceptance (Review #2)',
str_contains($orderNumber, 'PICKING') => 'Order currently being picked',
str_contains($orderNumber, 'APPROVED') => 'Order approved for delivery (passed Review #1)',
str_contains($orderNumber, 'OUTDELIVERY') => 'Order out for delivery',
default => 'Test order',
};
}
}

View File

@@ -26,7 +26,10 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule)
{
// $schedule->command('inspire')->hourly();
// Check for scheduled broadcasts every minute
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob)
->everyMinute()
->withoutOverlapping();
}
/**

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Events;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class HighIntentBuyerDetected implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $sellerBusinessId,
public int $buyerBusinessId,
public IntentSignal $signal,
public ?BuyerEngagementScore $engagementScore = null
) {}
/**
* Get the channels the event should broadcast on.
*/
public function broadcastOn(): Channel
{
return new Channel("business.{$this->sellerBusinessId}.analytics");
}
/**
* Get the data to broadcast.
*/
public function broadcastWith(): array
{
return [
'buyer_business_id' => $this->buyerBusinessId,
'buyer_business_name' => $this->signal->buyerBusiness?->name,
'signal_type' => $this->signal->signal_type,
'signal_strength' => $this->signal->signal_strength,
'product_id' => $this->signal->subject_type === 'App\Models\Product' ? $this->signal->subject_id : null,
'total_engagement_score' => $this->engagementScore?->total_score,
'detected_at' => $this->signal->detected_at->toIso8601String(),
'context' => $this->signal->context,
];
}
/**
* The event's broadcast name.
*/
public function broadcastAs(): string
{
return 'high-intent-buyer-detected';
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\Batches\Schemas\BatchForm;
use App\Filament\Resources\Batches\Tables\BatchesTable;
use App\Filament\Resources\BatchResource\Pages;
use App\Models\Batch;
use App\Services\QrCodeService;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum;
class BatchResource extends Resource
{
protected static ?string $model = Batch::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationLabel = 'Batches';
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
protected static ?int $navigationSort = 2;
public static function form(Schema $schema): Schema
{
return BatchForm::configure($schema);
}
public static function table(Table $table): Table
{
$table = BatchesTable::configure($table);
// Add custom QR and COA actions
return $table
->recordActions(array_merge(
$table->getRecordActions(),
[
Action::make('generate_qr')
->label('Generate QR')
->icon('heroicon-o-qr-code')
->action(function (Batch $record) {
$qrService = app(QrCodeService::class);
$result = $qrService->generateForBatch($record);
if ($result['success']) {
Notification::make()
->title('QR Code Generated')
->body($result['message'])
->success()
->send();
} else {
Notification::make()
->title('Failed to generate QR code')
->body($result['message'])
->danger()
->send();
}
})
->visible(fn (Batch $record) => ! $record->qr_code_path),
Action::make('download_qr')
->label('Download QR')
->icon('heroicon-o-arrow-down-tray')
->url(fn (Batch $record) => route('seller.business.manufacturing.batches.qr-code.download', [
'business' => $record->business->slug,
'batch' => $record->id,
]))
->openUrlInNewTab()
->visible(fn (Batch $record) => $record->qr_code_path),
Action::make('regenerate_qr')
->label('Regenerate QR')
->icon('heroicon-o-arrow-path')
->action(function (Batch $record) {
$qrService = app(QrCodeService::class);
$result = $qrService->regenerate($record);
if ($result['success']) {
Notification::make()
->title('QR Code Regenerated')
->success()
->send();
} else {
Notification::make()
->title('Failed to regenerate QR code')
->body($result['message'])
->danger()
->send();
}
})
->requiresConfirmation()
->visible(fn (Batch $record) => $record->qr_code_path),
Action::make('view_coa')
->label('View COA')
->icon('heroicon-o-document-text')
->url(fn (Batch $record) => route('public.coa.show', ['batchNumber' => $record->batch_number]))
->openUrlInNewTab()
->visible(fn (Batch $record) => $record->lab !== null),
]
))
->bulkActions(array_merge(
$table->getBulkActions(),
[
BulkAction::make('generate_qr_codes')
->label('Generate QR Codes')
->icon('heroicon-o-qr-code')
->action(function (Collection $records) {
$qrService = app(QrCodeService::class);
$batchIds = $records->pluck('id')->toArray();
$result = $qrService->bulkGenerate($batchIds);
Notification::make()
->title("Generated {$result['successful']} QR codes")
->body("Failed: {$result['failed']}")
->success()
->send();
}),
]
));
}
public static function getRelations(): array
{
return [
//
];
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
// Scope to user's business unless they're a super admin
if (! auth()->user()->hasRole('super_admin')) {
$query->where('business_id', auth()->user()->business_id);
}
return $query;
}
public static function getPages(): array
{
return [
'index' => Pages\ListBatches::route('/'),
'create' => Pages\CreateBatch::route('/create'),
'view' => Pages\ViewBatch::route('/{record}'),
'edit' => Pages\EditBatch::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Filament\Resources\BatchResource\Pages;
use App\Filament\Resources\BatchResource;
use Filament\Resources\Pages\CreateRecord;
class CreateBatch extends CreateRecord
{
protected static string $resource = BatchResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['business_id'] = auth()->user()->business_id;
return $data;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\BatchResource\Pages;
use App\Filament\Resources\BatchResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditBatch extends EditRecord
{
protected static string $resource = BatchResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BatchResource\Pages;
use App\Filament\Resources\BatchResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListBatches extends ListRecords
{
protected static string $resource = BatchResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\BatchResource\Pages;
use App\Filament\Resources\BatchResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewBatch extends ViewRecord
{
protected static string $resource = BatchResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Resources\Batches\Schemas;
use Filament\Forms;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
@@ -18,84 +19,144 @@ class BatchForm
->components([
Section::make('Batch Information')
->schema([
TextInput::make('batch_number')
->label('Batch Number')
->placeholder('Auto-generated if left blank')
->maxLength(255)
->helperText('Unique identifier for this batch'),
Select::make('product_id')
->label('Product')
->relationship('product', 'name')
->searchable()
->preload()
->required()
->columnSpan(2),
TextInput::make('batch_number')
->required()
->unique(ignoreRecord: true)
->helperText('Unique identifier for this batch (e.g., TB-AM-240315)'),
TextInput::make('internal_code')
->helperText('Internal production/tracking code (optional)'),
])
->columns(2),
->required(),
Section::make('Production Dates')
->schema([
DatePicker::make('production_date')
->helperText('Date the batch was produced/manufactured'),
DatePicker::make('harvest_date')
->helperText('Harvest date (for flower products)'),
DatePicker::make('package_date')
->helperText('Date the batch was packaged'),
DatePicker::make('expiration_date')
->helperText('Expiration/best-by date'),
Select::make('batch_type')
->label('Batch Type')
->options([
'intake' => 'Intake',
'production' => 'Production',
'finished' => 'Finished',
])
->default('finished')
->required()
->helperText('Type of batch in the production process'),
Select::make('lab_id')
->label('Lab Test')
->relationship('lab', 'lab_name')
->searchable()
->preload()
->helperText('Associated lab test results'),
Select::make('parent_batch_id')
->label('Parent Batch')
->relationship('parentBatch', 'batch_number')
->searchable()
->preload()
->helperText('Parent batch if this was produced from another batch'),
])
->columns(2),
Section::make('Inventory Management')
->schema([
TextInput::make('quantity_produced')
->label('Quantity Produced')
->required()
->numeric()
->default(0)
->helperText('Total units produced in this batch'),
TextInput::make('quantity_available')
->label('Quantity Available')
->required()
->numeric()
->default(0)
->helperText('Units currently available for sale'),
TextInput::make('quantity_allocated')
->label('Quantity Allocated')
->numeric()
->default(0)
->disabled()
->dehydrated(false)
->helperText('Units reserved in pending orders (auto-calculated)'),
TextInput::make('quantity_sold')
->label('Quantity Sold')
->numeric()
->default(0)
->disabled()
->dehydrated(false)
->helperText('Units already sold (auto-calculated)'),
])
->columns(2)
->columns(4)
->description('Allocated and sold quantities are automatically managed by the system.'),
Section::make('Status & Compliance')
Section::make('Dates')
->schema([
Toggle::make('is_active')
->default(true)
->helperText('Is this batch available for sale?'),
Toggle::make('is_tested')
->default(false)
->helperText('Has this batch passed lab testing?'),
Toggle::make('is_quarantined')
->default(false)
->helperText('Is this batch quarantined pending results?'),
])
->columns(3),
DatePicker::make('production_date')
->label('Production Date')
->helperText('Date the batch was produced/manufactured'),
Section::make('Additional Information')
DatePicker::make('intake_date')
->label('Intake Date')
->helperText('Date the batch was received/intake'),
DatePicker::make('expiration_date')
->label('Expiration Date')
->helperText('Expiration/best-by date'),
DatePicker::make('test_date')
->label('Test Date')
->helperText('Date of lab testing'),
])
->columns(2),
Section::make('Warehouse & Location')
->schema([
TextInput::make('warehouse_location')
->label('Warehouse Location')
->placeholder('e.g., Shelf A-15')
->maxLength(255)
->helperText('Physical location in warehouse'),
TextInput::make('container_type')
->label('Container Type')
->placeholder('e.g., Turkey Bag, Box')
->maxLength(255)
->helperText('Type of container batch is stored in'),
])
->columns(2),
Section::make('Quality & Compliance')
->schema([
Toggle::make('is_quarantined')
->label('Quarantined')
->default(false)
->helperText('Is this batch quarantined?')
->reactive(),
Textarea::make('quarantine_reason')
->label('Quarantine Reason')
->rows(2)
->helperText('Reason for quarantine')
->visible(fn (Forms\Get $get) => $get('is_quarantined'))
->columnSpanFull(),
Toggle::make('is_released_for_sale')
->label('Released for Sale')
->default(false)
->helperText('Has this batch been released for sale?'),
Textarea::make('notes')
->label('Notes')
->rows(3)
->helperText('Production notes, special handling instructions, etc.')
->columnSpanFull(),
])
->collapsible(),
->columns(2),
]);
}
}

View File

@@ -23,18 +23,35 @@ class BatchesTable
return $table
->columns([
TextColumn::make('batch_number')
->label('Batch #')
->searchable()
->sortable()
->copyable()
->weight('bold'),
TextColumn::make('product.name')
->label('Product')
->searchable()
->sortable()
->description(fn ($record) => $record->product->sku ?? null),
->description(fn ($record) => $record->product->sku ?? null)
->limit(30),
TextColumn::make('batch_type')
->label('Type')
->badge()
->color(fn (string $state): string => match ($state) {
'intake' => 'info',
'production' => 'warning',
'finished' => 'success',
default => 'gray',
}),
TextColumn::make('warehouse_location')
->label('Location')
->searchable()
->toggleable(),
TextColumn::make('production_date')
->label('Produced')
->date()
->sortable()
->toggleable(),
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('expiration_date')
->date()
->sortable()
@@ -60,14 +77,13 @@ class BatchesTable
->label('Status')
->badge()
->getStateUsing(fn ($record) => $record->is_quarantined ? 'Quarantined' :
(! $record->is_active ? 'Inactive' :
(! $record->is_tested ? 'Pending Test' : 'Active'))
(! $record->is_released_for_sale ? 'Not Released' : 'Released')
)
->color(fn (string $state): string => match ($state) {
'Active' => Color::Green,
'Pending Test' => Color::Yellow,
'Released' => Color::Green,
'Not Released' => Color::Yellow,
'Quarantined' => Color::Red,
'Inactive' => Color::Gray,
default => Color::Gray,
}),
TextColumn::make('created_at')
->dateTime()
@@ -80,19 +96,23 @@ class BatchesTable
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('batch_type')
->options([
'intake' => 'Intake',
'production' => 'Production',
'finished' => 'Finished',
]),
SelectFilter::make('product')
->relationship('product', 'name')
->searchable()
->preload(),
Filter::make('active')
->query(fn (Builder $query): Builder => $query->where('is_active', true))
Filter::make('released')
->label('Released for Sale')
->query(fn (Builder $query): Builder => $query->where('is_released_for_sale', true))
->toggle(),
Filter::make('available')
->query(fn (Builder $query): Builder => $query->where('quantity_available', '>', 0))
->toggle(),
Filter::make('tested')
->query(fn (Builder $query): Builder => $query->where('is_tested', true))
->toggle(),
Filter::make('quarantined')
->query(fn (Builder $query): Builder => $query->where('is_quarantined', true))
->toggle(),

View File

@@ -454,6 +454,58 @@ class BusinessResource extends Resource
->columns(2),
]),
Tab::make('Modules')
->schema([
Section::make('Premium Feature Modules')
->description('Enable optional premium features for this business. Modules are activated on a per-business basis.')
->schema([
Grid::make(1)
->schema([
Toggle::make('has_analytics')
->label('Buyer Intelligence Module')
->helperText('Premium analytics: Buyer engagement tracking, intent signals, RFDI scoring, email campaign analytics')
->default(false)
->inline(false),
Toggle::make('has_marketing')
->label('Marketing Module')
->helperText('Email campaigns, marketing automation, broadcast messages')
->default(false)
->inline(false),
Toggle::make('has_manufacturing')
->label('Manufacturing Module')
->helperText('Production tracking, batch management, quality control')
->default(false)
->inline(false),
]),
]),
Section::make('Module Information')
->description('Module activation status and billing information')
->schema([
Forms\Components\Placeholder::make('active_modules_count')
->label('Active Modules')
->content(function ($record) {
if (! $record) {
return '0';
}
$count = 0;
if ($record->has_analytics) {
$count++;
}
if ($record->has_marketing) {
$count++;
}
if ($record->has_manufacturing) {
$count++;
}
return $count.' module'.($count !== 1 ? 's' : '').' enabled';
}),
])
->columns(1),
]),
Tab::make('Status & Settings')
->schema([
Grid::make(2)
@@ -547,6 +599,24 @@ class BusinessResource extends Resource
})
->searchable()
->sortable(),
TextColumn::make('modules')
->label('Active Modules')
->formatStateUsing(function ($record) {
$modules = [];
if ($record->has_analytics) {
$modules[] = 'Analytics';
}
if ($record->has_marketing) {
$modules[] = 'Marketing';
}
if ($record->has_manufacturing) {
$modules[] = 'Manufacturing';
}
return empty($modules) ? 'None' : implode(', ', $modules);
})
->badge()
->color(fn ($record) => ($record->has_analytics || $record->has_marketing || $record->has_manufacturing) ? 'success' : 'gray'),
BadgeColumn::make('status')
->label('Status')
->formatStateUsing(fn (string $state): string => ucfirst(str_replace('_', ' ', $state)))

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EmailTemplateResource\Pages\CreateEmailTemplate;
use App\Filament\Resources\EmailTemplateResource\Pages\EditEmailTemplate;
use App\Filament\Resources\EmailTemplateResource\Pages\ListEmailTemplates;
use App\Filament\Resources\EmailTemplateResource\Pages\ViewEmailTemplate;
use App\Filament\Resources\EmailTemplateResource\Schemas\EmailTemplateForm;
use App\Filament\Resources\EmailTemplateResource\Schemas\EmailTemplateInfolist;
use App\Filament\Resources\EmailTemplateResource\Tables\EmailTemplatesTable;
use App\Models\EmailTemplate;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
class EmailTemplateResource extends Resource
{
protected static ?string $model = EmailTemplate::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-envelope';
protected static \UnitEnum|string|null $navigationGroup = 'System';
protected static ?int $navigationSort = 10;
protected static ?string $navigationLabel = 'Email Templates';
protected static ?string $modelLabel = 'Email Template';
protected static ?string $pluralModelLabel = 'Email Templates';
public static function getNavigationBadge(): ?string
{
// Count inactive templates
return static::getModel()::where('is_active', false)->count() ?: null;
}
public static function form(Schema $schema): Schema
{
return EmailTemplateForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return EmailTemplateInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return EmailTemplatesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListEmailTemplates::route('/'),
'create' => CreateEmailTemplate::route('/create'),
'view' => ViewEmailTemplate::route('/{record}'),
'edit' => EditEmailTemplate::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Pages;
use App\Filament\Resources\EmailTemplateResource;
use Filament\Resources\Pages\CreateRecord;
class CreateEmailTemplate extends CreateRecord
{
protected static string $resource = EmailTemplateResource::class;
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Pages;
use App\Filament\Resources\EmailTemplateResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditEmailTemplate extends EditRecord
{
protected static string $resource = EmailTemplateResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Pages;
use App\Filament\Resources\EmailTemplateResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListEmailTemplates extends ListRecords
{
protected static string $resource = EmailTemplateResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Pages;
use App\Filament\Resources\EmailTemplateResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewEmailTemplate extends ViewRecord
{
protected static string $resource = EmailTemplateResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Schemas;
use App\Models\EmailTemplate;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class EmailTemplateForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->columns(1)
->components([
Section::make('Template Details')
->schema([
TextInput::make('key')
->label('Template Key')
->required()
->unique(ignoreRecord: true)
->regex('/^[a-z0-9_-]+$/')
->helperText('Lowercase alphanumeric characters, hyphens and underscores only')
->disabled(fn ($context) => $context === 'edit')
->dehydrated(fn ($context) => $context === 'create')
->columnSpanFull(),
TextInput::make('name')
->label('Template Name')
->required()
->maxLength(255)
->columnSpanFull(),
TextInput::make('subject')
->label('Email Subject')
->required()
->maxLength(255)
->columnSpanFull(),
Textarea::make('description')
->label('Description')
->helperText('Describe when this template is used')
->rows(3)
->columnSpanFull(),
TextInput::make('available_variables')
->label('Available Variables')
->helperText('Comma-separated list (e.g., verification_url, email, logo_url)')
->afterStateHydrated(function (TextInput $component, $state) {
if (is_array($state)) {
$component->state(implode(', ', $state));
}
})
->dehydrateStateUsing(function ($state) {
if (empty($state)) {
return [];
}
return array_map('trim', explode(',', $state));
})
->columnSpanFull(),
Checkbox::make('is_active')
->label('Template is Active')
->default(true)
->inline(false),
])
->columns(2),
Section::make('Email Content')
->schema([
Textarea::make('body_html')
->label('HTML Body')
->required()
->rows(25)
->helperText('Use {{ $variable }} syntax for dynamic content')
->columnSpanFull()
->extraAttributes(['style' => 'font-family: monospace; font-size: 13px;']),
Textarea::make('body_text')
->label('Plain Text Body (Optional)')
->rows(15)
->helperText('Plain text fallback for email clients that don\'t support HTML')
->columnSpanFull()
->extraAttributes(['style' => 'font-family: monospace; font-size: 13px;']),
]),
Section::make('Metadata')
->schema([
Placeholder::make('created_at')
->label('Created At')
->content(fn (?EmailTemplate $record): string => $record?->created_at?->diffForHumans() ?? '-'),
Placeholder::make('updated_at')
->label('Last Updated')
->content(fn (?EmailTemplate $record): string => $record?->updated_at?->diffForHumans() ?? '-'),
])
->columns(2)
->hidden(fn ($context) => $context === 'create'),
]);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Schemas;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\ViewEntry;
use Filament\Schemas\Schema;
use Illuminate\Support\HtmlString;
class EmailTemplateInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('name')
->label('Template Name')
->columnSpan(1),
TextEntry::make('key')
->label('Template Key')
->badge()
->copyable()
->copyMessage('Key copied!')
->copyMessageDuration(1500)
->columnSpan(1),
TextEntry::make('subject')
->label('Email Subject')
->columnSpan(2),
TextEntry::make('description')
->label('Description')
->columnSpan(2)
->placeholder('No description provided'),
TextEntry::make('available_variables')
->label('Available Variables')
->badge()
->separator(',')
->columnSpan(2)
->placeholder('No variables defined'),
IconEntry::make('is_active')
->label('Status')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->columnSpan(1),
TextEntry::make('created_at')
->label('Created')
->dateTime()
->since()
->columnSpan(1),
TextEntry::make('updated_at')
->label('Last Updated')
->dateTime()
->since()
->columnSpan(1),
ViewEntry::make('preview')
->label('HTML Preview')
->viewData(fn ($record) => [
'html' => $record->body_html,
])
->view('filament.email-template-preview')
->columnSpan(2),
TextEntry::make('body_html')
->label('HTML Source')
->formatStateUsing(fn ($state) => new HtmlString('<pre class="text-xs font-mono bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto whitespace-pre-wrap">'.htmlspecialchars($state).'</pre>'))
->columnSpan(2),
TextEntry::make('body_text')
->label('Plain Text Version')
->formatStateUsing(fn ($state) => new HtmlString('<pre class="text-xs font-mono bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto whitespace-pre-wrap">'.htmlspecialchars($state ?: 'No plain text version').'</pre>'))
->columnSpan(2)
->hidden(fn ($record) => empty($record->body_text)),
])
->columns(2);
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Filament\Resources\EmailTemplateResource\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class EmailTemplatesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Template Name')
->searchable()
->sortable()
->weight('bold'),
TextColumn::make('key')
->label('Key')
->searchable()
->sortable()
->fontFamily('mono')
->size('sm')
->copyable()
->copyMessage('Key copied!')
->copyMessageDuration(1500),
TextColumn::make('subject')
->label('Subject')
->searchable()
->limit(50)
->wrap(),
IconColumn::make('is_active')
->label('Status')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->sortable(),
TextColumn::make('updated_at')
->label('Last Updated')
->dateTime()
->sortable()
->since()
->size('sm'),
])
->defaultSort('name')
->filters([
SelectFilter::make('is_active')
->label('Status')
->options([
true => 'Active',
false => 'Inactive',
]),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource\Schemas\LabForm;
use App\Filament\Resources\LabResource\Tables\LabsTable;
use App\Models\Lab;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class LabResource extends Resource
{
protected static ?string $model = Lab::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-beaker';
protected static ?string $navigationLabel = 'Lab Tests';
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
protected static ?int $navigationSort = 3;
public static function form(Schema $schema): Schema
{
return LabForm::configure($schema);
}
public static function table(Table $table): Table
{
return LabsTable::configure($table);
}
public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();
// Scope to user's business products and batches unless they're a super admin
if (! auth()->user()->hasRole('super_admin')) {
$businessId = auth()->user()->business_id;
$query->where(function ($q) use ($businessId) {
// Include labs for products owned by this business
$q->whereHas('product', function ($productQuery) use ($businessId) {
$productQuery->whereHas('brand', function ($brandQuery) use ($businessId) {
$brandQuery->where('business_id', $businessId);
});
})
// OR labs for batches owned by this business
->orWhereHas('batch', function ($batchQuery) use ($businessId) {
$batchQuery->where('business_id', $businessId);
});
});
}
return $query;
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListLabs::route('/'),
'create' => Pages\CreateLab::route('/create'),
'view' => Pages\ViewLab::route('/{record}'),
'edit' => Pages\EditLab::route('/{record}/edit'),
];
}
public static function getNavigationBadge(): ?string
{
// Show count of recent lab tests (last 30 days)
return cache()->remember('recent_lab_tests_count', 300, function () {
$query = static::getEloquentQuery();
return $query->where('test_date', '>=', now()->subDays(30))
->count() ?: null;
});
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource;
use Filament\Resources\Pages\CreateRecord;
class CreateLab extends CreateRecord
{
protected static string $resource = LabResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditLab extends EditRecord
{
protected static string $resource = LabResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListLabs extends ListRecords
{
protected static string $resource = LabResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\LabResource\Pages;
use App\Filament\Resources\LabResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewLab extends ViewRecord
{
protected static string $resource = LabResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@@ -0,0 +1,298 @@
<?php
namespace App\Filament\Resources\LabResource\Schemas;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Schema;
class LabForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Tabs::make('Lab Test Information')
->tabs([
Tab::make('Basic Information')
->schema([
Section::make('Test Details')
->schema([
Select::make('product_id')
->label('Product')
->relationship('product', 'name')
->searchable()
->preload()
->helperText('Product this test is for'),
Select::make('batch_id')
->label('Batch')
->relationship('batch', 'batch_number')
->searchable()
->preload()
->helperText('Specific batch tested'),
TextInput::make('lab_name')
->required()
->maxLength(255)
->helperText('Testing laboratory name'),
TextInput::make('lab_license_number')
->label('Lab License #')
->maxLength(255)
->helperText('State license number'),
DatePicker::make('test_date')
->required()
->default(now())
->helperText('Date test was performed'),
TextInput::make('batch_number')
->label('Lab Batch Number')
->maxLength(255)
->helperText('Internal lab tracking number'),
TextInput::make('sample_id')
->label('Sample ID')
->maxLength(255)
->helperText('Sample identification'),
])
->columns(2),
]),
Tab::make('Cannabinoids')
->schema([
Section::make('Primary Cannabinoids')
->schema([
TextInput::make('thc_percentage')
->label('THC %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('thca_percentage')
->label('THCA %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('cbd_percentage')
->label('CBD %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('cbda_percentage')
->label('CBDA %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
])
->columns(4),
Section::make('Minor Cannabinoids')
->schema([
TextInput::make('cbg_percentage')
->label('CBG %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('cbn_percentage')
->label('CBN %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('thcv_percentage')
->label('THCV %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
TextInput::make('cbdv_percentage')
->label('CBDV %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
])
->columns(4),
Section::make('Calculated Totals')
->schema([
TextInput::make('total_thc')
->label('Total THC')
->numeric()
->disabled()
->dehydrated(false)
->suffix('%')
->helperText('Auto-calculated from THC + (THCA × 0.877)'),
TextInput::make('total_cbd')
->label('Total CBD')
->numeric()
->disabled()
->dehydrated(false)
->suffix('%')
->helperText('Auto-calculated from CBD + (CBDA × 0.877)'),
TextInput::make('total_cannabinoids')
->label('Total Cannabinoids')
->numeric()
->disabled()
->dehydrated(false)
->suffix('%')
->helperText('Sum of all cannabinoids'),
])
->columns(3)
->description('These values are automatically calculated on save'),
]),
Tab::make('Terpenes')
->schema([
Repeater::make('terpenes')
->schema([
TextInput::make('name')
->required()
->helperText('Terpene name (e.g., Myrcene)'),
TextInput::make('percentage')
->required()
->numeric()
->minValue(0)
->step(0.001)
->suffix('%')
->helperText('Percentage'),
])
->columns(2)
->collapsible()
->helperText('Add terpene profile data'),
]),
Tab::make('Compliance Tests')
->schema([
Section::make('Safety Tests')
->schema([
Toggle::make('pesticides_pass')
->label('Pesticides Pass')
->default(true)
->inline(false),
Toggle::make('heavy_metals_pass')
->label('Heavy Metals Pass')
->default(true)
->inline(false),
Toggle::make('microbials_pass')
->label('Microbials Pass')
->default(true)
->inline(false),
Toggle::make('mycotoxins_pass')
->label('Mycotoxins Pass')
->default(true)
->inline(false),
Toggle::make('residual_solvents_pass')
->label('Residual Solvents Pass')
->default(true)
->inline(false),
Toggle::make('foreign_material_pass')
->label('Foreign Material Pass')
->default(true)
->inline(false),
])
->columns(3)
->description('All tests must pass for overall compliance'),
Section::make('Additional Tests')
->schema([
TextInput::make('moisture_content')
->label('Moisture Content %')
->numeric()
->minValue(0)
->maxValue(100)
->step(0.01)
->suffix('%'),
Toggle::make('compliance_pass')
->label('Overall Compliance Pass')
->default(true)
->disabled()
->dehydrated(false)
->helperText('Auto-calculated from all safety tests'),
])
->columns(2),
]),
Tab::make('COA Files')
->schema([
Section::make('Certificate of Analysis Files')
->schema([
Repeater::make('coaFiles')
->relationship()
->schema([
FileUpload::make('file_path')
->label('File')
->required()
->directory('compliance/coas')
->acceptedFileTypes(['application/pdf', 'image/*'])
->maxSize(10240),
TextInput::make('description')
->maxLength(255)
->helperText('Optional description'),
Toggle::make('is_primary')
->label('Primary COA')
->inline(false),
])
->columns(3)
->collapsible()
->helperText('Upload COA files (PDF or images)'),
TextInput::make('certificate_url')
->label('External COA URL')
->url()
->maxLength(255)
->helperText('Link to COA on external site (optional)'),
]),
]),
Tab::make('Notes')
->schema([
Textarea::make('notes')
->rows(5)
->columnSpanFull()
->helperText('Additional notes about this test'),
]),
])
->columnSpanFull(),
]);
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Filament\Resources\LabResource\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Support\Colors\Color;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class LabsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('lab_name')
->label('Lab')
->searchable()
->sortable()
->weight('bold'),
TextColumn::make('product.name')
->label('Product')
->searchable()
->sortable()
->limit(30),
TextColumn::make('batch.batch_number')
->label('Batch')
->searchable()
->sortable()
->toggleable(),
TextColumn::make('test_date')
->date('M d, Y')
->sortable()
->color(fn ($record) => $record->test_date < now()->subDays(90) ? Color::Orange : null),
TextColumn::make('total_thc')
->label('THC')
->numeric(decimalPlaces: 2)
->suffix('%')
->sortable()
->color(fn ($state) => $state > 20 ? Color::Green : ($state > 15 ? Color::Amber : Color::Gray)),
TextColumn::make('total_cbd')
->label('CBD')
->numeric(decimalPlaces: 2)
->suffix('%')
->sortable()
->toggleable(),
TextColumn::make('total_cannabinoids')
->label('Total')
->numeric(decimalPlaces: 2)
->suffix('%')
->sortable()
->toggleable(),
IconColumn::make('compliance_pass')
->label('Compliance')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor(Color::Green)
->falseColor(Color::Red)
->sortable(),
TextColumn::make('terpene_profile')
->label('Top Terpenes')
->limit(40)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('test_date', 'desc')
->filters([
SelectFilter::make('lab_name')
->options(function () {
return \App\Models\Lab::query()
->distinct('lab_name')
->pluck('lab_name', 'lab_name')
->toArray();
})
->searchable(),
SelectFilter::make('product')
->relationship('product', 'name')
->searchable()
->preload(),
SelectFilter::make('batch')
->relationship('batch', 'batch_number')
->searchable()
->preload(),
TernaryFilter::make('compliance_pass')
->label('Compliant'),
Filter::make('recent')
->label('Recent (Last 30 days)')
->query(fn (Builder $query): Builder => $query->where('test_date', '>=', now()->subDays(30)))
->toggle(),
Filter::make('high_thc')
->label('High THC (>20%)')
->query(fn (Builder $query): Builder => $query->where('total_thc', '>', 20))
->toggle(),
Filter::make('high_cbd')
->label('High CBD (>10%)')
->query(fn (Builder $query): Builder => $query->where('total_cbd', '>', 10))
->toggle(),
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -35,10 +35,13 @@ class ModulesTable
->sortable()
->badge()
->color(fn (string $state): string => match ($state) {
'Communication' => 'info',
'Sales' => 'success',
'Operations' => 'warning',
'Core' => 'primary',
'Finance' => 'danger',
'Growth' => 'success',
'Intelligence' => 'info',
'Operations' => 'warning',
'Regulatory' => 'danger',
'Sales' => 'success',
default => 'gray',
}),
@@ -103,13 +106,13 @@ class ModulesTable
SelectFilter::make('category')
->options([
'Communication' => 'Communication',
'Sales' => 'Sales',
'Operations' => 'Operations',
'Core' => 'Core',
'Finance' => 'Finance',
'Marketing' => 'Marketing',
'Support' => 'Support',
'Analytics' => 'Analytics',
'Growth' => 'Growth',
'Intelligence' => 'Intelligence',
'Operations' => 'Operations',
'Regulatory' => 'Regulatory',
'Sales' => 'Sales',
])
->native(false),
])

View File

@@ -85,6 +85,22 @@ class UserResource extends Resource
'suspended' => 'Suspended',
])
->default('active'),
TextInput::make('password')
->label('Password')
->password()
->required(fn ($record) => $record === null)
->dehydrated(fn ($state) => filled($state))
->minLength(8)
->maxLength(255)
->helperText('Leave blank to keep current password when editing')
->visible(fn ($livewire) => $livewire instanceof CreateUser),
TextInput::make('password_confirmation')
->label('Confirm Password')
->password()
->required(fn ($record) => $record === null && filled($record?->password))
->dehydrated(false)
->same('password')
->visible(fn ($livewire) => $livewire instanceof CreateUser),
])->columns(2),
Section::make('Business Association')

View File

@@ -4,8 +4,18 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Hash;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return $data;
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace App\Helpers;
use App\Models\Business;
use App\Services\PermissionService;
use Illuminate\Support\Facades\Auth;
class BusinessHelper
{
/**
* Get current business context from session or user's primary business
*/
public static function current(): ?Business
{
if (! Auth::check()) {
return null;
}
$businessId = session('current_business_id');
if ($businessId) {
return Business::find($businessId);
}
// Fall back to user's primary business if no session is set
return Auth::user()->primaryBusiness();
}
/**
* Check if user has a permission for current business
*
* This method now uses PermissionService internally for better architecture
* while maintaining backward compatibility with existing code.
*
* @param string $permission Permission key (e.g. 'analytics.overview')
*/
public static function hasPermission(string $permission): bool
{
if (! Auth::check()) {
return false;
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return false;
}
// Use PermissionService for permission checking
$permissionService = app(PermissionService::class);
return $permissionService->check($user, $permission, $business);
}
/**
* Check if user is owner or admin for current business
*/
public static function isOwnerOrAdmin(): bool
{
if (! Auth::check()) {
return false;
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return false;
}
// Super admin
if ($user->user_type === 'admin') {
return true;
}
// Business owner
return $business->owner_user_id === $user->id;
}
/**
* Get user's role template for current business
*/
public static function getRoleTemplate(): ?string
{
if (! Auth::check()) {
return null;
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return null;
}
$businessUser = $user->businesses()
->where('businesses.id', $business->id)
->first();
return $businessUser?->pivot->role_template;
}
/**
* Get user's permissions array for current business
*/
public static function getPermissions(): array
{
if (! Auth::check()) {
return [];
}
$user = Auth::user();
$business = self::current();
if (! $business) {
return [];
}
// Use PermissionService for cached permission retrieval
$permissionService = app(PermissionService::class);
return $permissionService->getUserPermissions($user, $business);
}
/**
* Check if current business has a specific module enabled
*
* @param string $module Module name (sales, manufacturing, compliance)
*/
public static function hasModule(string $module): bool
{
$business = self::current();
return match ($module) {
'sales' => true, // Sales is always enabled (base product)
'manufacturing' => $business?->has_manufacturing ?? false,
'compliance' => $business?->has_compliance ?? false,
default => false,
};
}
}

24
app/Helpers/helpers.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
use App\Helpers\BusinessHelper;
if (! function_exists('currentBusiness')) {
function currentBusiness()
{
return BusinessHelper::current();
}
}
if (! function_exists('currentBusinessId')) {
function currentBusinessId()
{
return BusinessHelper::currentId();
}
}
if (! function_exists('hasBusinessPermission')) {
function hasBusinessPermission(string $permission): bool
{
return BusinessHelper::hasPermission($permission);
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class QuickSwitchController extends Controller
{
/**
* Ensure only admins can access
*/
public function __construct()
{
$this->middleware('auth');
$this->middleware(function ($request, $next) {
if (! auth()->user() || ! auth()->user()->canImpersonate()) {
abort(403, 'Only administrators can access this feature. Please login as an admin.');
}
return $next($request);
});
}
/**
* Show quick switch menu for testing
*/
public function index()
{
// Verify user can impersonate
if (! auth()->check() || ! auth()->user()->canImpersonate()) {
abort(403, 'Only administrators can access Quick Switch');
}
// Get all seller users for quick switching
$users = User::where('user_type', 'seller')
->with('businesses')
->orderBy('email')
->get();
return view('admin.quick-switch', compact('users'));
}
/**
* Quick switch to user (stores admin session)
*/
public function switch(Request $request, User $targetUser)
{
\Log::info('QuickSwitch: switch() method called', [
'user_id' => $targetUser->id,
'user_email' => $targetUser->email,
'admin_id' => auth()->id(),
]);
if (! auth()->check()) {
abort(403, 'Not authenticated');
}
if (! auth()->user()->canImpersonate()) {
abort(403, 'Only administrators can impersonate users. Current user type: '.auth()->user()->user_type);
}
if (! $targetUser->canBeImpersonated()) {
abort(403, 'This user cannot be impersonated');
}
// Store current admin user
session()->put('admin_user_id', auth()->id());
session()->put('quick_switch_mode', true);
// Switch to user
auth()->login($targetUser);
// Redirect based on user type and business
$business = $targetUser->primaryBusiness();
if ($business && $business->isParentCompany()) {
// Try executive dashboard first, fallback to regular dashboard if route doesn't exist
if (\Illuminate\Support\Facades\Route::has('seller.business.executive.dashboard')) {
return redirect()->route('seller.business.executive.dashboard', $business->slug);
}
return redirect()->route('seller.business.dashboard', $business->slug);
} elseif ($business) {
return redirect()->route('seller.business.dashboard', $business->slug);
}
return redirect()->route('seller.dashboard');
}
/**
* Switch back to admin
*/
public function backToAdmin()
{
$adminUserId = session()->get('admin_user_id');
if (! $adminUserId) {
return redirect()->route('filament.admin.auth.login')
->with('error', 'Admin session not found');
}
$adminUser = User::find($adminUserId);
if (! $adminUser) {
return redirect()->route('filament.admin.auth.login')
->with('error', 'Admin user not found');
}
auth()->login($adminUser);
session()->forget(['admin_user_id', 'quick_switch_mode']);
return redirect()->route('filament.admin.pages.dashboard')
->with('success', 'Returned to admin panel');
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AnalyticsDashboardController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.overview')) {
abort(403, 'Unauthorized to view analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30'); // days
$startDate = now()->subDays((int) $period);
// Key metrics
$metrics = [
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
'total_page_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->sum('page_views'),
'total_product_views' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->count(),
'unique_products_viewed' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->distinct('product_id')
->count('product_id'),
'high_intent_signals' => IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
->where('signal_strength', '>=', IntentSignal::STRENGTH_HIGH)
->count(),
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('last_interaction_at', '>=', $startDate)->count(),
];
// Traffic trend (daily breakdown)
$trafficTrend = AnalyticsEvent::forBusiness($business->id)->where('created_at', '>=', $startDate)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as total_events'),
DB::raw('COUNT(DISTINCT session_id) as unique_sessions')
)
->groupBy('date')
->orderBy('date')
->get();
// Top products by views
$topProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select('product_id', DB::raw('COUNT(*) as view_count'))
->groupBy('product_id')
->orderByDesc('view_count')
->limit(10)
->with('product')
->get();
// High-value buyers
$highValueBuyers = BuyerEngagementScore::forBusiness($business->id)->highValue()
->active()
->orderByDesc('score')
->limit(10)
->with('buyerBusiness')
->get();
// Recent high-intent signals
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
->where('detected_at', '>=', now()->subHours(24))
->orderByDesc('detected_at')
->limit(10)
->with(['buyerBusiness', 'user'])
->get();
// Engagement score distribution
$engagementDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
DB::raw('CASE
WHEN score >= 80 THEN \'Very High\'
WHEN score >= 60 THEN \'High\'
WHEN score >= 40 THEN \'Medium\'
ELSE \'Low\'
END as score_range'),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
return view('seller.analytics.dashboard', compact(
'business',
'period',
'metrics',
'trafficTrend',
'topProducts',
'highValueBuyers',
'recentIntentSignals',
'engagementDistribution'
));
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BuyerIntelligenceController extends Controller
{
public function index(Request $request)
{
// TODO: Re-enable when permission system is implemented
// if (! hasBusinessPermission('analytics.buyers')) {
// abort(403, 'Unauthorized to view buyer intelligence');
// }
$business = currentBusiness();
$period = $request->input('period', '30');
$filter = $request->input('filter', 'all'); // all, high-value, at-risk, new
$startDate = now()->subDays((int) $period);
// Overall buyer metrics
$metrics = [
'total_buyers' => BuyerEngagementScore::forBusiness($business->id)->count(),
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->active()->count(),
'high_value_buyers' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
'at_risk_buyers' => BuyerEngagementScore::forBusiness($business->id)->atRisk()->count(),
'new_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('first_interaction_at', '>=', now()->subDays(30))->count(),
];
// Build query based on filter
$buyersQuery = BuyerEngagementScore::forBusiness($business->id);
match ($filter) {
'high-value' => $buyersQuery->highValue(),
'at-risk' => $buyersQuery->atRisk(),
'new' => $buyersQuery->where('first_interaction_at', '>=', now()->subDays(30)),
default => $buyersQuery,
};
$buyers = $buyersQuery->orderByDesc('score')
->with('buyerBusiness')
->paginate(20);
// Engagement score distribution
$scoreDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
DB::raw("CASE
WHEN score >= 80 THEN 'Very High (80-100)'
WHEN score >= 60 THEN 'High (60-79)'
WHEN score >= 40 THEN 'Medium (40-59)'
WHEN score >= 20 THEN 'Low (20-39)'
ELSE 'Very Low (0-19)'
END as score_range"),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
// Tier distribution
$tierDistribution = BuyerEngagementScore::forBusiness($business->id)->select('score_tier')
->selectRaw('COUNT(*) as count')
->groupBy('score_tier')
->get();
// Recent high-intent signals
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
->where('detected_at', '>=', now()->subDays(7))
->orderByDesc('detected_at')
->with(['buyerBusiness', 'user'])
->limit(20)
->get();
// Intent signal breakdown
$signalBreakdown = IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
->select('signal_type')
->selectRaw('COUNT(*) as count')
->selectRaw('AVG(signal_strength) as avg_strength')
->groupBy('signal_type')
->orderByDesc('count')
->get();
return view('seller.analytics.buyers', compact(
'business',
'period',
'filter',
'metrics',
'buyers',
'scoreDistribution',
'tierDistribution',
'recentIntentSignals',
'signalBreakdown'
));
}
public function show(Request $request, Business $buyer)
{
// TODO: Re-enable when permission system is implemented
// if (! hasBusinessPermission('analytics.buyers')) {
// abort(403, 'Unauthorized to view buyer intelligence');
// }
$business = currentBusiness();
$period = $request->input('period', '90'); // Default to 90 days for buyer detail
$startDate = now()->subDays((int) $period);
// Get engagement score
$engagementScore = BuyerEngagementScore::forBusiness($business->id)->where('buyer_business_id', $buyer->id)->first();
// Activity timeline
$activityTimeline = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as product_views'),
DB::raw('COUNT(DISTINCT product_id) as unique_products'),
DB::raw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
)
->groupBy('date')
->orderBy('date')
->get();
// Products viewed
$productsViewed = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as view_count')
->selectRaw('MAX(viewed_at) as last_viewed')
->selectRaw('AVG(time_on_page) as avg_time')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
->groupBy('product_id')
->orderByDesc('view_count')
->with('product')
->limit(20)
->get();
// Intent signals
$intentSignals = IntentSignal::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('detected_at', '>=', $startDate)
->orderByDesc('detected_at')
->limit(50)
->get();
// Email engagement
$emailEngagement = DB::table('email_interactions')
->join('users', 'email_interactions.recipient_user_id', '=', 'users.id')
->join('business_user', 'users.id', '=', 'business_user.user_id')
->where('email_interactions.business_id', $business->id)
->where('business_user.business_id', $buyer->id)
->where('email_interactions.sent_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_sent')
->selectRaw('SUM(open_count) as total_opens')
->selectRaw('SUM(click_count) as total_clicks')
->selectRaw('AVG(engagement_score) as avg_engagement')
->first();
// Order history
$orderHistory = DB::table('orders')
->where('seller_business_id', $business->id)
->where('buyer_business_id', $buyer->id)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as order_count'),
DB::raw('SUM(total) as revenue')
)
->groupBy('date')
->orderBy('date')
->get();
$totalOrders = DB::table('orders')
->where('seller_business_id', $business->id)
->where('buyer_business_id', $buyer->id)
->selectRaw('COUNT(*) as count')
->selectRaw('SUM(total) as total_revenue')
->selectRaw('AVG(total) as avg_order_value')
->first();
return view('seller.analytics.buyer-detail', compact(
'buyer',
'period',
'engagementScore',
'activityTimeline',
'productsViewed',
'intentSignals',
'emailEngagement',
'orderHistory',
'totalOrders'
));
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\EmailCampaign;
use App\Models\Analytics\EmailInteraction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MarketingAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.marketing')) {
abort(403, 'Unauthorized to view marketing analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Campaign overview metrics
$metrics = [
'total_campaigns' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->count(),
'total_sent' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_sent'),
'total_delivered' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_delivered'),
'total_opened' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_opened'),
'total_clicked' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_clicked'),
];
// Calculate average rates
$metrics['avg_open_rate'] = $metrics['total_delivered'] > 0
? round(($metrics['total_opened'] / $metrics['total_delivered']) * 100, 2)
: 0;
$metrics['avg_click_rate'] = $metrics['total_delivered'] > 0
? round(($metrics['total_clicked'] / $metrics['total_delivered']) * 100, 2)
: 0;
// Campaign performance
$campaigns = EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)
->orderByDesc('sent_at')
->with('emailInteractions')
->paginate(20);
// Email engagement over time
$engagementTrend = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->select(
DB::raw('DATE(sent_at) as date'),
DB::raw('COUNT(*) as sent'),
DB::raw('SUM(CASE WHEN first_opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened'),
DB::raw('SUM(CASE WHEN first_clicked_at IS NOT NULL THEN 1 ELSE 0 END) as clicked')
)
->groupBy('date')
->orderBy('date')
->get();
// Top performing campaigns
$topCampaigns = EmailCampaign::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->where('total_sent', '>', 0)
->orderByRaw('(total_clicked / total_sent) DESC')
->limit(10)
->get();
// Email client breakdown
$emailClients = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->whereNotNull('email_client')
->select('email_client')
->selectRaw('COUNT(*) as count')
->groupBy('email_client')
->orderByDesc('count')
->get();
// Device type breakdown
$deviceTypes = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->whereNotNull('device_type')
->select('device_type')
->selectRaw('COUNT(*) as count')
->groupBy('device_type')
->orderByDesc('count')
->get();
// Engagement score distribution
$engagementScores = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->select(
DB::raw("CASE
WHEN engagement_score >= 80 THEN 'High'
WHEN engagement_score >= 50 THEN 'Medium'
WHEN engagement_score > 0 THEN 'Low'
ELSE 'None'
END as score_range"),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
return view('seller.analytics.marketing', compact(
'business',
'period',
'metrics',
'campaigns',
'engagementTrend',
'topCampaigns',
'emailClients',
'deviceTypes',
'engagementScores'
));
}
public function campaign(Request $request, EmailCampaign $campaign)
{
if (! hasBusinessPermission('analytics.marketing')) {
abort(403, 'Unauthorized to view marketing analytics');
}
// Verify campaign belongs to user's business
if ($campaign->business_id !== currentBusinessId()) {
abort(403, 'Unauthorized to view this campaign');
}
// Campaign metrics
$metrics = [
'total_sent' => $campaign->total_sent,
'total_delivered' => $campaign->total_delivered,
'total_bounced' => $campaign->total_bounced,
'total_opened' => $campaign->total_opened,
'total_clicked' => $campaign->total_clicked,
'open_rate' => $campaign->open_rate,
'click_rate' => $campaign->click_rate,
'bounce_rate' => $campaign->total_sent > 0
? round(($campaign->total_bounced / $campaign->total_sent) * 100, 2)
: 0,
];
// Interaction timeline
$timeline = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
->select(
DB::raw('DATE(sent_at) as date'),
DB::raw('SUM(open_count) as opens'),
DB::raw('SUM(click_count) as clicks')
)
->groupBy('date')
->orderBy('date')
->get();
// Top engaged recipients
$topRecipients = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
->orderByDesc('engagement_score')
->limit(20)
->with('recipientUser')
->get();
// Click breakdown by URL
$clicksByUrl = DB::table('email_clicks')
->join('email_interactions', 'email_clicks.email_interaction_id', '=', 'email_interactions.id')
->where('email_interactions.campaign_id', $campaign->id)
->select('email_clicks.url', 'email_clicks.link_identifier')
->selectRaw('COUNT(*) as click_count')
->selectRaw('COUNT(DISTINCT email_clicks.email_interaction_id) as unique_clicks')
->groupBy('email_clicks.url', 'email_clicks.link_identifier')
->orderByDesc('click_count')
->get();
return view('seller.analytics.campaign-detail', compact(
'campaign',
'metrics',
'timeline',
'topRecipients',
'clicksByUrl'
));
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Helpers\BusinessHelper;
use App\Http\Controllers\Controller;
use App\Models\Analytics\ProductView;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ProductAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.products')) {
abort(403, 'Unauthorized to view product analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Product performance metrics
$productMetrics = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as total_views')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->selectRaw('AVG(time_on_page) as avg_time_on_page')
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
->groupBy('product_id')
->orderByDesc('total_views')
->with('product.brand')
->paginate(20);
// Product view trend
$viewTrend = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as views'),
DB::raw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
)
->groupBy('date')
->orderBy('date')
->get();
// High engagement products (quality over quantity)
$highEngagementProducts = ProductView::forBusiness($business->id)->highEngagement()
->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as engagement_count')
->selectRaw('AVG(time_on_page) as avg_time')
->groupBy('product_id')
->orderByDesc('engagement_count')
->limit(10)
->with('product')
->get();
// Products with most cart additions (high intent)
$topCartProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->where('added_to_cart', true)
->select('product_id')
->selectRaw('COUNT(*) as cart_count')
->groupBy('product_id')
->orderByDesc('cart_count')
->limit(10)
->with('product')
->get();
// Engagement breakdown
$engagementBreakdown = [
'zoomed_image' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('zoomed_image', true)->count(),
'watched_video' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('watched_video', true)->count(),
'downloaded_spec' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('downloaded_spec', true)->count(),
'added_to_cart' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_cart', true)->count(),
'added_to_wishlist' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_wishlist', true)->count(),
];
return view('seller.analytics.products', compact(
'business',
'period',
'productMetrics',
'viewTrend',
'highEngagementProducts',
'topCartProducts',
'engagementBreakdown'
));
}
public function show(Request $request, Product $product)
{
if (! hasBusinessPermission('analytics.products')) {
abort(403, 'Unauthorized to view product analytics');
}
// Verify product belongs to user's business brands
$sellerBusiness = BusinessHelper::fromProduct($product);
if ($sellerBusiness->id !== currentBusinessId()) {
abort(403, 'Unauthorized to view this product');
}
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Product-specific metrics
$metrics = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_views')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->selectRaw('COUNT(DISTINCT session_id) as unique_sessions')
->selectRaw('AVG(time_on_page) as avg_time_on_page')
->selectRaw('MAX(time_on_page) as max_time_on_page')
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
->first();
// View trend
$viewTrend = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as views')
)
->groupBy('date')
->orderBy('date')
->get();
// Top buyers viewing this product
$topBuyers = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->whereNotNull('buyer_business_id')
->select('buyer_business_id')
->selectRaw('COUNT(*) as view_count')
->selectRaw('MAX(viewed_at) as last_viewed')
->groupBy('buyer_business_id')
->orderByDesc('view_count')
->limit(10)
->with('buyerBusiness')
->get();
// Traffic sources
$trafficSources = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->select('source')
->selectRaw('COUNT(*) as count')
->groupBy('source')
->orderByDesc('count')
->get();
return view('seller.analytics.product-detail', compact(
'product',
'period',
'metrics',
'viewTrend',
'topBuyers',
'trafficSources'
));
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SalesAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.sales')) {
abort(403, 'Unauthorized to view sales analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Sales funnel metrics
$funnelMetrics = [
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
'sessions_with_product_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('product_views', '>', 0)
->count(),
'sessions_with_cart' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->count(),
'checkout_initiated' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 2)
->count(),
'orders_completed' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('converted', true)
->count(),
];
// Calculate conversion rates
$funnelMetrics['product_view_rate'] = $funnelMetrics['total_sessions'] > 0
? round(($funnelMetrics['sessions_with_product_views'] / $funnelMetrics['total_sessions']) * 100, 2)
: 0;
$funnelMetrics['cart_rate'] = $funnelMetrics['sessions_with_product_views'] > 0
? round(($funnelMetrics['sessions_with_cart'] / $funnelMetrics['sessions_with_product_views']) * 100, 2)
: 0;
$funnelMetrics['checkout_rate'] = $funnelMetrics['sessions_with_cart'] > 0
? round(($funnelMetrics['checkout_initiated'] / $funnelMetrics['sessions_with_cart']) * 100, 2)
: 0;
$funnelMetrics['conversion_rate'] = $funnelMetrics['checkout_initiated'] > 0
? round(($funnelMetrics['orders_completed'] / $funnelMetrics['checkout_initiated']) * 100, 2)
: 0;
// Sales metrics from orders table
// Note: orders.business_id is the buyer's business
// To get seller's orders, join through order_items → products → brands
$salesMetrics = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->selectRaw('COUNT(DISTINCT orders.id) as total_orders')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('AVG(orders.total) as avg_order_value')
->selectRaw('COUNT(DISTINCT orders.business_id) as unique_buyers')
->first();
// Revenue trend
$revenueTrend = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select(
DB::raw('DATE(orders.created_at) as date'),
DB::raw('COUNT(DISTINCT orders.id) as orders'),
DB::raw('SUM(order_items.line_total) as revenue')
)
->groupBy('date')
->orderBy('date')
->get();
// Conversion funnel trend
$conversionTrend = UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->select(
DB::raw('DATE(started_at) as date'),
DB::raw('COUNT(*) as sessions'),
DB::raw('SUM(CASE WHEN product_views > 0 THEN 1 ELSE 0 END) as with_views'),
DB::raw('SUM(CASE WHEN interactions > 0 THEN 1 ELSE 0 END) as with_interactions'),
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as conversions'),
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as orders')
)
->groupBy('date')
->orderBy('date')
->get();
// Top revenue products
$topProducts = DB::table('order_items')
->join('orders', 'order_items.order_id', '=', 'orders.id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select('products.id', 'products.name')
->selectRaw('SUM(order_items.quantity) as units_sold')
->selectRaw('SUM(order_items.line_total) as revenue')
->groupBy('products.id', 'products.name')
->orderByDesc('revenue')
->limit(10)
->get();
// Session abandonment analysis (sessions with interactions but no conversion)
$cartAbandonment = [
'total_interactive_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->count(),
'abandoned_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->where('converted', false)
->count(),
];
$cartAbandonment['abandonment_rate'] = $cartAbandonment['total_interactive_sessions'] > 0
? round(($cartAbandonment['abandoned_sessions'] / $cartAbandonment['total_interactive_sessions']) * 100, 2)
: 0;
// Top buyers by revenue
$topBuyers = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->join('businesses', 'orders.business_id', '=', 'businesses.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select('businesses.id', 'businesses.name')
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('AVG(orders.total) as avg_order_value')
->groupBy('businesses.id', 'businesses.name')
->orderByDesc('total_revenue')
->limit(10)
->get();
return view('seller.analytics.sales', compact(
'business',
'period',
'funnelMetrics',
'salesMetrics',
'revenueTrend',
'conversionTrend',
'topProducts',
'cartAbandonment',
'topBuyers'
));
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\AnalyticsTracker;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class TrackingController extends Controller
{
protected AnalyticsTracker $tracker;
public function __construct(AnalyticsTracker $tracker)
{
$this->tracker = $tracker;
}
/**
* Initialize or update session
*/
public function session(Request $request)
{
try {
$session = $this->tracker->startSession();
return response()->json([
'success' => true,
'session_id' => $session->session_id,
]);
} catch (\Exception $e) {
Log::error('Analytics session tracking failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'Session tracking failed',
], 500);
}
}
/**
* Track various analytics events
*/
public function track(Request $request)
{
try {
$eventType = $request->input('event_type');
switch ($eventType) {
case 'page_view':
$this->trackPageView($request);
break;
case 'product_view':
$this->trackProductView($request);
break;
case 'page_engagement':
$this->trackPageEngagement($request);
break;
case 'click':
$this->trackClick($request);
break;
default:
$this->trackGenericEvent($request);
}
return response()->json(['success' => true]);
} catch (\Exception $e) {
Log::error('Analytics tracking failed', [
'event_type' => $request->input('event_type'),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'Tracking failed',
], 500);
}
}
/**
* Track page view
*/
protected function trackPageView(Request $request): void
{
$this->tracker->updateSessionPageView();
$this->tracker->trackEvent(
'page_view',
'navigation',
'view',
null,
null,
[
'url' => $request->input('url'),
'title' => $request->input('title'),
'referrer' => $request->input('referrer'),
]
);
}
/**
* Track product view with engagement signals
*/
protected function trackProductView(Request $request): void
{
$productId = $request->input('product_id');
if (! $productId) {
return;
}
$product = Product::find($productId);
if (! $product) {
return;
}
$signals = [
'time_on_page' => $request->input('time_on_page'),
'scroll_depth' => $request->input('scroll_depth'),
'zoomed_image' => $request->boolean('zoomed_image'),
'watched_video' => $request->boolean('watched_video'),
'downloaded_spec' => $request->boolean('downloaded_spec'),
'added_to_cart' => $request->boolean('added_to_cart'),
'added_to_wishlist' => $request->boolean('added_to_wishlist'),
];
$this->tracker->trackProductView($product, $signals);
}
/**
* Track generic page engagement
*/
protected function trackPageEngagement(Request $request): void
{
$this->tracker->updateSessionPageView();
$this->tracker->trackEvent(
'page_engagement',
'engagement',
'interact',
null,
null,
[
'time_on_page' => $request->input('time_on_page'),
'scroll_depth' => $request->input('scroll_depth'),
]
);
}
/**
* Track click event
*/
protected function trackClick(Request $request): void
{
$this->tracker->trackClick(
$request->input('element_type', 'unknown'),
$request->input('element_id'),
$request->input('element_label'),
$request->input('url'),
[
'timestamp' => $request->input('timestamp'),
]
);
}
/**
* Track generic event
*/
protected function trackGenericEvent(Request $request): void
{
$this->tracker->trackEvent(
$request->input('event_type', 'custom'),
$request->input('category', 'general'),
$request->input('action', 'action'),
$request->input('subject_id'),
$request->input('subject_type'),
$request->input('metadata', [])
);
}
}

View File

@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Http\Controllers\Business;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class UserController extends Controller
@@ -25,14 +28,57 @@ class UserController extends Controller
// Load users with their pivot data (contact_type, is_primary, permissions)
$users = $business->users()
->withPivot('contact_type', 'is_primary', 'permissions')
->withPivot('contact_type', 'is_primary', 'permissions', 'role')
->orderBy('is_primary', 'desc')
->orderBy('first_name')
->get();
// Available analytics permissions
$analyticsPermissions = [
'analytics.overview' => 'Access main analytics dashboard',
'analytics.products' => 'View product performance analytics',
'analytics.marketing' => 'View marketing and email analytics',
'analytics.sales' => 'View sales intelligence and pipeline',
'analytics.buyers' => 'View buyer intelligence and engagement',
'analytics.export' => 'Export analytics data',
];
return view('business.users.index', [
'business' => $business,
'users' => $users,
'analyticsPermissions' => $analyticsPermissions,
]);
}
/**
* Update user permissions.
*/
public function updatePermissions(Request $request, User $user): JsonResponse
{
$business = auth()->user()->businesses()->first();
if (! $business) {
return response()->json(['error' => 'No business found'], 404);
}
// Verify user belongs to this business
if (! $business->users->contains($user->id)) {
return response()->json(['error' => 'User not found in this business'], 404);
}
$validated = $request->validate([
'permissions' => 'array',
'permissions.*' => 'string',
]);
// Update permissions in pivot table
$business->users()->updateExistingPivot($user->id, [
'permissions' => $validated['permissions'] ?? [],
]);
return response()->json([
'success' => true,
'message' => 'Permissions updated successfully',
]);
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Http\Controllers\Business;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\PermissionService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class UserPermissionsController extends Controller
{
public function __construct(
protected PermissionService $permissionService
) {}
/**
* Update user permissions via AJAX
*/
public function update(Request $request, string $businessSlug, int $userId)
{
try {
$business = currentBusiness();
if (! $business) {
return response()->json([
'success' => false,
'message' => 'Business not found',
], 404);
}
// Only owners and admins can manage permissions
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
return response()->json([
'success' => false,
'message' => 'You do not have permission to manage user permissions',
], 403);
}
$user = User::findOrFail($userId);
// Verify user belongs to this business
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
return response()->json([
'success' => false,
'message' => 'User does not belong to this business',
], 404);
}
// Prevent owner from modifying their own permissions
if ($user->id === $business->owner_user_id) {
return response()->json([
'success' => false,
'message' => 'Cannot modify owner permissions',
], 403);
}
$validated = $request->validate([
'permissions' => 'array',
'permissions.*' => 'string',
'role_template' => 'nullable|string',
]);
$permissions = $validated['permissions'] ?? [];
$roleTemplate = $validated['role_template'] ?? null;
// Set permissions using PermissionService
$success = $this->permissionService->setPermissions(
user: $user,
permissions: $permissions,
business: $business,
roleTemplate: $roleTemplate,
reason: 'Updated by '.auth()->user()->name.' via permissions modal'
);
if ($success) {
Log::info('User permissions updated', [
'business_id' => $business->id,
'target_user_id' => $user->id,
'actor_user_id' => auth()->id(),
'permissions_count' => count($permissions),
'role_template' => $roleTemplate,
]);
return response()->json([
'success' => true,
'message' => 'Permissions updated successfully',
]);
}
return response()->json([
'success' => false,
'message' => 'Failed to update permissions',
], 500);
} catch (\Exception $e) {
Log::error('Error updating user permissions', [
'error' => $e->getMessage(),
'user_id' => $userId,
'business_slug' => $businessSlug,
]);
return response()->json([
'success' => false,
'message' => 'An error occurred while updating permissions',
], 500);
}
}
/**
* Apply a role template to a user
*/
public function applyTemplate(Request $request, string $businessSlug, int $userId)
{
try {
$business = currentBusiness();
if (! $business) {
return response()->json([
'success' => false,
'message' => 'Business not found',
], 404);
}
// Only owners and admins can manage permissions
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
return response()->json([
'success' => false,
'message' => 'You do not have permission to manage user permissions',
], 403);
}
$user = User::findOrFail($userId);
// Verify user belongs to this business
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
return response()->json([
'success' => false,
'message' => 'User does not belong to this business',
], 404);
}
$validated = $request->validate([
'template_key' => 'required|string',
'merge' => 'boolean',
]);
$templateKey = $validated['template_key'];
$merge = $validated['merge'] ?? false;
// Apply role template
$permissions = $this->permissionService->applyRoleTemplate(
user: $user,
templateKey: $templateKey,
business: $business,
merge: $merge
);
if ($permissions === null) {
return response()->json([
'success' => false,
'message' => 'Role template not found',
], 404);
}
return response()->json([
'success' => true,
'message' => 'Role template applied successfully',
'permissions' => $permissions,
]);
} catch (\Exception $e) {
Log::error('Error applying role template', [
'error' => $e->getMessage(),
'user_id' => $userId,
'business_slug' => $businessSlug,
]);
return response()->json([
'success' => false,
'message' => 'An error occurred while applying role template',
], 500);
}
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
class BrandBrowseController extends Controller
{
/**
* Show brand menu for buyers to browse and order
* This is the main product browsing interface for buyers
*
* @return \Illuminate\View\View
*/
public function browse(Request $request, string $businessSlug, string $brandHashid)
{
// Manually resolve business and brand (cross-tenant access allowed)
// Buyers can browse ANY seller's brand menu
$business = Business::where('slug', $businessSlug)->firstOrFail();
$brand = Brand::where('hashid', $brandHashid)
->where('business_id', $business->id)
->where('is_active', true)
->firstOrFail();
// Load brand with business relationship
$brand->load('business');
// Get products organized by product line
$products = $brand->products()
->with(['strain', 'images', 'productLine'])
->where('is_active', true)
->orderBy('product_line_id')
->orderBy('name')
->get();
// Group products by product line
$productsByLine = $products->groupBy(function ($product) {
return $product->productLine ? $product->productLine->name : 'Other Products';
});
// Get other brands from same business
$otherBrands = $business
->brands()
->where('id', '!=', $brand->id)
->where('is_active', true)
->get();
// Mark this as buyer view
$isSeller = false;
return view('seller.brands.preview', compact(
'business',
'brand',
'products',
'productsByLine',
'otherBrands',
'isSeller'
));
}
}

View File

@@ -101,7 +101,7 @@ class CartController extends Controller
/**
* Update cart item quantity (Ajax).
*/
public function update(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
public function update(\App\Models\Business $business, Request $request, string $cartId): JsonResponse
{
$request->validate([
'quantity' => 'required|integer|min:1',
@@ -111,7 +111,7 @@ class CartController extends Controller
$sessionId = $request->session()->getId();
try {
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'), $user, $sessionId);
$cart = $this->cartService->updateQuantity((int) $cartId, $request->integer('quantity'), $user, $sessionId);
// Ensure product is loaded for JSON response
$cart->load('product', 'brand');
@@ -140,7 +140,7 @@ class CartController extends Controller
/**
* Remove item from cart (Ajax).
*/
public function remove(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
public function remove(\App\Models\Business $business, Request $request, string $cartId): JsonResponse
{
$user = $request->user();
$sessionId = $request->session()->getId();

View File

@@ -8,10 +8,12 @@ use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Services\CartService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\View\View;
class CheckoutController extends Controller
@@ -104,94 +106,162 @@ class CheckoutController extends Controller
$paymentTerms = $request->input('payment_terms');
$dueDate = $this->calculateDueDate($paymentTerms);
// Calculate totals with payment term surcharge
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
$surchargePercent = Order::getSurchargePercentage($paymentTerms);
$surcharge = $subtotal * ($surchargePercent / 100);
// Group cart items by brand
$itemsByBrand = $items->groupBy(function ($item) {
return $item->product->brand_id ?? 'unknown';
});
// Tax is calculated on subtotal + surcharge using business tax rate
// (0.00 if business is tax-exempt wholesale/resale with Form 5000A)
// Remove items with unknown brand
$itemsByBrand = $itemsByBrand->filter(function ($items, $brandId) {
return $brandId !== 'unknown';
});
// Generate order group ID to link all orders from this checkout
$orderGroupId = 'OG-'.strtoupper(Str::random(12));
// Tax rate for buyer's business
$taxRate = $business->getTaxRate();
$tax = ($subtotal + $surcharge) * $taxRate;
$total = $subtotal + $surcharge + $tax;
$surchargePercent = Order::getSurchargePercentage($paymentTerms);
// Create order in transaction
$order = DB::transaction(function () use ($request, $user, $business, $items, $subtotal, $surcharge, $tax, $total, $paymentTerms, $dueDate) {
// Generate order number
$orderNumber = $this->generateOrderNumber();
// Create orders in transaction (one per brand)
$orders = DB::transaction(function () use (
$request,
$user,
$business,
$itemsByBrand,
$taxRate,
$surchargePercent,
$paymentTerms,
$dueDate,
$orderGroupId
) {
$createdOrders = [];
// Create order
$order = Order::create([
'order_number' => $orderNumber,
'business_id' => $business->id,
'user_id' => $user->id,
'location_id' => $request->input('location_id'),
'subtotal' => $subtotal,
'surcharge' => $surcharge,
'tax' => $tax,
'total' => $total,
'status' => 'new',
'created_by' => 'buyer', // Buyer-initiated order
'payment_terms' => $paymentTerms,
'due_date' => $dueDate,
'notes' => $request->input('notes'),
'delivery_method' => $request->input('delivery_method', 'delivery'),
'pickup_driver_first_name' => $request->input('pickup_driver_first_name'),
'pickup_driver_last_name' => $request->input('pickup_driver_last_name'),
'pickup_driver_license' => $request->input('pickup_driver_license'),
'pickup_driver_phone' => $request->input('pickup_driver_phone'),
'pickup_vehicle_plate' => $request->input('pickup_vehicle_plate'),
]);
foreach ($itemsByBrand as $brandId => $brandItems) {
// Get seller business ID from the brand
$sellerBusinessId = $brandItems->first()->product->brand->business_id;
// Create order items from cart
foreach ($items as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'batch_id' => $item->batch_id,
'batch_number' => $item->batch?->batch_number,
'quantity' => $item->quantity,
'unit_price' => $item->product->wholesale_price,
'line_total' => $item->quantity * $item->product->wholesale_price,
'product_name' => $item->product->name,
'product_sku' => $item->product->sku,
'brand_name' => $item->brand->name ?? '',
// Calculate totals for this brand's items
$brandSubtotal = $brandItems->sum(function ($item) {
return $item->quantity * $item->product->wholesale_price;
});
$brandSurcharge = $brandSubtotal * ($surchargePercent / 100);
$brandTax = ($brandSubtotal + $brandSurcharge) * $taxRate;
$brandTotal = $brandSubtotal + $brandSurcharge + $brandTax;
// Generate order number
$orderNumber = $this->generateOrderNumber();
// Create order for this brand
$order = Order::create([
'order_number' => $orderNumber,
'order_group_id' => $orderGroupId,
'business_id' => $business->id,
'seller_business_id' => $sellerBusinessId,
'user_id' => $user->id,
'location_id' => $request->input('location_id'),
'subtotal' => $brandSubtotal,
'surcharge' => $brandSurcharge,
'tax' => $brandTax,
'total' => $brandTotal,
'status' => 'new',
'created_by' => 'buyer',
'payment_terms' => $paymentTerms,
'due_date' => $dueDate,
'notes' => $request->input('notes'),
'delivery_method' => $request->input('delivery_method', 'delivery'),
'pickup_driver_first_name' => $request->input('pickup_driver_first_name'),
'pickup_driver_last_name' => $request->input('pickup_driver_last_name'),
'pickup_driver_license' => $request->input('pickup_driver_license'),
'pickup_driver_phone' => $request->input('pickup_driver_phone'),
'pickup_vehicle_plate' => $request->input('pickup_vehicle_plate'),
]);
// If batch selected, allocate inventory from that batch
if ($item->batch_id && $item->batch) {
$item->batch->allocate($item->quantity);
// Create order items for this brand
foreach ($brandItems as $item) {
OrderItem::create([
'order_id' => $order->id,
'product_id' => $item->product_id,
'batch_id' => $item->batch_id,
'batch_number' => $item->batch?->batch_number,
'quantity' => $item->quantity,
'unit_price' => $item->product->wholesale_price,
'line_total' => $item->quantity * $item->product->wholesale_price,
'product_name' => $item->product->name,
'product_sku' => $item->product->sku,
'brand_name' => $item->brand->name ?? '',
]);
// If batch selected, allocate inventory from that batch
if ($item->batch_id && $item->batch) {
$item->batch->allocate($item->quantity);
}
}
$createdOrders[] = $order;
}
return $order;
return collect($createdOrders);
});
// Clear the cart
$this->cartService->clear($user, $sessionId);
// Notify sellers of new order
// Notify sellers of new orders (wrapped in try-catch to prevent email failures from blocking checkout)
$sellerNotificationService = app(\App\Services\SellerNotificationService::class);
$sellerNotificationService->newOrderReceived($order);
foreach ($orders as $order) {
try {
$sellerNotificationService->newOrderReceived($order);
} catch (\Exception $e) {
// Log the error but don't block the checkout
\Log::error('Failed to send seller notification email', [
'order_id' => $order->id,
'order_number' => $order->order_number,
'error' => $e->getMessage(),
]);
}
}
// Redirect to success page
return redirect()->route('buyer.business.checkout.success', ['business' => $business->slug, 'order' => $order->order_number])
// Redirect to orders page with success message
if ($orders->count() > 1) {
return redirect()->route('buyer.business.orders.index', ['business' => $business->slug])
->with('success', "Orders placed successfully! {$orders->count()} separate orders created.");
}
// Single order - redirect to order details page
return redirect()->route('buyer.business.orders.show', ['business' => $business->slug, 'order' => $orders->first()->order_number])
->with('success', 'Order placed successfully!');
}
/**
* Display order confirmation page.
* Handles both single orders (by order_number) and order groups (by order_group_id).
*/
public function success(Business $business, Request $request, Order $order): View|RedirectResponse
public function success(Business $business, Request $request, string $order): View|RedirectResponse
{
// Load relationships
$order->load(['items.product', 'business', 'location']);
// Check if this is an order group ID (starts with OG-)
if (str_starts_with($order, 'OG-')) {
// Load all orders in this group
$orders = Order::where('order_group_id', $order)
->where('business_id', $business->id)
->with(['items.product', 'business', 'location', 'sellerBusiness'])
->orderBy('created_at')
->get();
// Ensure order belongs to this business
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized');
if ($orders->isEmpty()) {
abort(404, 'Order group not found');
}
return view('buyer.checkout.success-group', compact('orders', 'business'));
}
// Single order - find by order_number
$order = Order::where('order_number', $order)
->where('business_id', $business->id)
->with(['items.product', 'business', 'location'])
->firstOrFail();
return view('buyer.checkout.success', compact('order', 'business'));
}
@@ -221,4 +291,82 @@ class CheckoutController extends Controller
default => now()->addDays(30),
};
}
/**
* Process checkout and create orders (one per brand).
* New route for order splitting logic.
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'cart' => 'required|array|min:1',
'cart.*.product_id' => 'required|exists:products,id',
'cart.*.quantity' => 'required|integer|min:1',
'delivery_window_id' => 'nullable|exists:delivery_windows,id',
'delivery_window_date' => 'nullable|date',
]);
$business = $request->user()->businesses()->first();
if (! $business) {
return redirect()->back()->with('error', 'No business associated with your account');
}
// Load products and group by brand
$productIds = collect($validated['cart'])->pluck('product_id');
$products = Product::with('brand.business')
->whereIn('id', $productIds)
->get()
->keyBy('id');
// Group cart items by brand
$itemsByBrand = collect($validated['cart'])->groupBy(function ($item) use ($products) {
$product = $products->get($item['product_id']);
return $product->brand_id;
});
// Generate unique group ID for all orders in this checkout
$orderGroupId = 'checkout_'.Str::uuid();
$createdOrders = [];
// Create one order per brand
foreach ($itemsByBrand as $brandId => $items) {
// Get seller business ID from the brand
$product = $products->get($items[0]['product_id']);
$sellerBusinessId = $product->brand->business_id;
$order = Order::create([
'order_number' => $this->generateOrderNumber(),
'business_id' => $business->id,
'seller_business_id' => $sellerBusinessId,
'order_group_id' => $orderGroupId,
'status' => 'new',
'created_by' => 'buyer',
'delivery_window_id' => $validated['delivery_window_id'] ?? null,
'delivery_window_date' => $validated['delivery_window_date'] ?? null,
]);
// Create order items
foreach ($items as $item) {
$product = $products->get($item['product_id']);
$order->items()->create([
'product_id' => $product->id,
'quantity' => $item['quantity'],
'unit_price' => $product->wholesale_price,
'line_total' => $item['quantity'] * $product->wholesale_price,
'product_name' => $product->name,
'product_sku' => $product->sku,
]);
}
$createdOrders[] = $order;
}
return redirect()
->back()
->with('success', count($createdOrders).' order(s) created successfully');
}
}

View File

@@ -4,10 +4,7 @@ namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\Invoice;
use App\Models\OrderItem;
use App\Services\InvoiceService;
use App\Services\OrderModificationService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
@@ -48,145 +45,7 @@ class InvoiceController extends Controller
$invoice->load(['order.items', 'business']);
// Prepare invoice items data for Alpine.js
$invoiceItems = $invoice->order->items->map(function ($item) {
return [
'id' => $item->id,
'quantity' => $item->picked_qty,
'originalQuantity' => $item->picked_qty,
'unit_price' => $item->unit_price,
'deleted' => false,
];
})->values();
return view('buyer.invoices.show', compact('invoice', 'invoiceItems', 'business'));
}
/**
* Approve the invoice without modifications.
*/
public function approve(\App\Models\Business $business, Invoice $invoice)
{
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to approve this invoice.');
}
if (! $invoice->canBeEditedByBuyer()) {
return response()->json([
'success' => false,
'message' => 'This invoice cannot be approved at this time.',
], 400);
}
$invoice->buyerApprove(auth()->user());
return response()->json([
'success' => true,
'message' => 'Invoice approved successfully.',
]);
}
/**
* Reject the invoice.
*/
public function reject(\App\Models\Business $business, Request $request, Invoice $invoice)
{
$request->validate([
'reason' => 'required|string|max:1000',
]);
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to reject this invoice.');
}
if (! $invoice->canBeEditedByBuyer()) {
return back()->with('error', 'This invoice cannot be rejected at this time.');
}
$invoice->buyerReject(auth()->user(), $request->reason);
return redirect()->route('buyer.invoices.index')
->with('success', 'Invoice rejected successfully.');
}
/**
* Modify the invoice (record buyer's changes).
*/
public function modify(\App\Models\Business $business, Request $request, Invoice $invoice, OrderModificationService $modificationService)
{
$request->validate([
'items' => 'required|array',
'items.*.id' => 'required|exists:order_items,id',
'items.*.quantity' => 'required|integer|min:0',
'items.*.deleted' => 'required|boolean',
]);
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
return response()->json([
'success' => false,
'message' => 'Unauthorized to modify this invoice.',
], 403);
}
if (! $invoice->canBeEditedByBuyer()) {
return response()->json([
'success' => false,
'message' => 'This invoice cannot be modified at this time.',
], 400);
}
// Record all changes
$hasChanges = false;
foreach ($request->items as $itemData) {
$item = OrderItem::find($itemData['id']);
// Skip if item doesn't belong to this order
if ($item->order_id !== $invoice->order_id) {
continue;
}
// Check for deletion
if ($itemData['deleted'] && ! $item->deleted_at) {
$modificationService->recordItemDeletion($invoice, $item, auth()->user());
$hasChanges = true;
continue;
}
// Check for quantity change
if ($itemData['quantity'] != $item->picked_qty) {
// Validate: can only reduce, not increase
if ($itemData['quantity'] > $item->picked_qty) {
return response()->json([
'success' => false,
'message' => 'You can only reduce quantities, not increase them.',
], 400);
}
$modificationService->recordItemChange(
$invoice,
$item,
['quantity' => $itemData['quantity']],
auth()->user()
);
$hasChanges = true;
}
}
if (! $hasChanges) {
return response()->json([
'success' => false,
'message' => 'No changes detected.',
], 400);
}
// Update invoice status to buyer_modified
$invoice->buyerModify();
return response()->json([
'success' => true,
'message' => 'Changes saved successfully. The seller will review your modifications.',
]);
return view('buyer.invoices.show', compact('invoice', 'business'));
}
/**

View File

@@ -3,11 +3,19 @@
namespace App\Http\Controllers\Buyer;
use App\Http\Controllers\Controller;
use App\Models\DeliveryWindow;
use App\Models\Order;
use App\Services\DeliveryWindowService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function __construct(
private DeliveryWindowService $deliveryWindowService
) {}
/**
* Display a listing of the user's orders.
*/
@@ -42,7 +50,7 @@ class OrderController extends Controller
abort(403, 'Unauthorized to view this order.');
}
$order->load(['items.product', 'business', 'location', 'user', 'invoice', 'manifest']);
$order->load(['items.product.brand', 'business', 'location', 'user', 'invoice', 'manifest', 'deliveryWindow', 'pendingCancellationRequest']);
return view('buyer.orders.show', compact('business', 'order'));
}
@@ -65,8 +73,31 @@ class OrderController extends Controller
return back()->with('success', "Order {$order->order_number} has been accepted.");
}
/**
* Request cancellation of an order (buyer-initiated).
*/
public function requestCancellation(\App\Models\Business $business, Order $order, Request $request)
{
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to modify this order.');
}
if (! $order->canRequestCancellation()) {
return back()->with('error', 'This order cannot have a cancellation request at this stage.');
}
$validated = $request->validate([
'reason' => 'required|string|max:1000',
]);
$order->requestCancellation(auth()->user(), $validated['reason']);
return back()->with('success', "Cancellation request submitted for order {$order->order_number}. The seller will review your request.");
}
/**
* Cancel an order (buyer-initiated).
* NOTE: This is the old direct cancel method, kept for backward compatibility.
*/
public function cancel(\App\Models\Business $business, Order $order, Request $request)
{
@@ -156,4 +187,404 @@ class OrderController extends Controller
]
);
}
/**
* Update order's delivery window
*/
public function updateDeliveryWindow(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
{
// Ensure order belongs to buyer's business
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized access to order');
}
// Only allow updates for delivery orders at approved_for_delivery status
if ($order->status !== 'approved_for_delivery') {
abort(422, 'Delivery window can only be set after buyer has approved the order for delivery');
}
// Only delivery orders need delivery windows
if (! $order->isDelivery()) {
abort(422, 'Delivery window can only be set for delivery orders');
}
$validated = $request->validate([
'delivery_window_id' => 'required|exists:delivery_windows,id',
'delivery_window_date' => 'required|date|after_or_equal:today',
]);
$window = DeliveryWindow::findOrFail($validated['delivery_window_id']);
// Get seller's business ID from the first order item's product's brand
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if (! $sellerBusinessId) {
abort(422, 'Unable to determine seller business for this order');
}
// Ensure window belongs to the SELLER's business
if ($window->business_id !== $sellerBusinessId) {
abort(422, 'Delivery window does not belong to seller business');
}
$date = Carbon::parse($validated['delivery_window_date']);
// Validate using service
if (! $this->deliveryWindowService->validateWindowSelection($window, $date)) {
abort(422, 'Invalid delivery window selection');
}
$this->deliveryWindowService->updateOrderWindow($order, $window, $date);
return redirect()
->route('buyer.business.orders.show', [$business->slug, $order])
->with('success', 'Delivery window updated successfully');
}
/**
* Get available delivery windows for an order's seller business.
*/
public function getAvailableDeliveryWindows(\App\Models\Business $business, Order $order, Request $request)
{
// Ensure order belongs to buyer's business
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized access to order');
}
$date = $request->query('date');
if (! $date) {
return response()->json(['error' => 'Date parameter required'], 400);
}
try {
$selectedDate = Carbon::parse($date);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid date format'], 400);
}
$dayOfWeek = $selectedDate->dayOfWeek;
// Get seller's business ID from the first order item's product's brand
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if (! $sellerBusinessId) {
return response()->json(['windows' => []]);
}
// Fetch active delivery windows for the seller on this day
$windows = DeliveryWindow::where('business_id', $sellerBusinessId)
->where('day_of_week', $dayOfWeek)
->where('is_active', true)
->orderBy('start_time')
->get()
->map(function ($window) {
return [
'id' => $window->id,
'day_name' => $window->day_name,
'time_range' => $window->time_range,
'start_time' => $window->start_time,
'end_time' => $window->end_time,
];
});
return response()->json(['windows' => $windows]);
}
/**
* Show pre-delivery approval form (Review #1: After picking, before delivery).
* Buyer reviews order with COAs and can approve/reject entire line items.
*/
public function showPreDeliveryApproval(\App\Models\Business $business, Order $order)
{
// Authorization check
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to access this order.');
}
// Only ready_for_delivery orders can be reviewed
if ($order->status !== 'ready_for_delivery') {
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
->with('error', 'Only orders ready for delivery can be reviewed.');
}
// Load relationships including COAs
$order->load([
'items.product.brand',
'items.batch.coaFiles' => function ($query) {
$query->orderBy('is_primary', 'desc')->orderBy('display_order');
},
'business',
'location',
]);
return view('buyer.orders.pre-delivery-review', compact('business', 'order'));
}
/**
* Process pre-delivery approval (Review #1).
* Buyer can approve order or reject specific line items.
*/
public function processPreDeliveryApproval(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
{
// Authorization check
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to modify this order.');
}
// Only ready_for_delivery orders can be approved
if ($order->status !== 'ready_for_delivery') {
return back()->with('error', 'Only orders ready for delivery can be reviewed.');
}
$validated = $request->validate([
'action' => 'required|in:approve,reject',
'rejected_items' => 'nullable|array',
'rejected_items.*' => 'exists:order_items,id',
'rejection_reason' => 'nullable|string|max:1000',
]);
$rejectedProductNames = [];
\DB::transaction(function () use ($order, $validated, &$rejectedProductNames) {
if ($validated['action'] === 'reject') {
// Reject entire order
$order->update([
'status' => 'rejected',
'rejected_at' => now(),
'rejected_reason' => $validated['rejection_reason'] ?? 'Order rejected by buyer during review',
]);
// Return all inventory to stock
foreach ($order->items as $item) {
if ($item->batch_id && $item->batch) {
$item->batch->deallocate($item->quantity);
}
}
} else {
// Approve with optional item rejections
if (! empty($validated['rejected_items'])) {
// Remove rejected items and clean up related picking tickets
foreach ($validated['rejected_items'] as $itemId) {
$item = $order->items()->find($itemId);
if ($item) {
$rejectedProductNames[] = $item->product_name;
// Return inventory
if ($item->batch_id && $item->batch) {
$item->batch->deallocate($item->quantity);
}
// Delete related PickingTicketItems first to maintain referential integrity
\App\Models\PickingTicketItem::where('order_item_id', $item->id)->delete();
// Delete the order item
$item->delete();
}
}
// Add rejection instruction to the fulfillment work order
if (! empty($rejectedProductNames) && $order->fulfillmentWorkOrder) {
$rejectionMessage = 'Buyer rejected: '.implode(', ', $rejectedProductNames).'. Pull and restock these items.';
$order->fulfillmentWorkOrder->update([
'instructions' => $order->fulfillmentWorkOrder->instructions
? $order->fulfillmentWorkOrder->instructions."\n\n".$rejectionMessage
: $rejectionMessage,
]);
}
// Check for empty picking tickets and delete them
if ($order->fulfillmentWorkOrder) {
foreach ($order->fulfillmentWorkOrder->pickingTickets as $ticket) {
if ($ticket->items()->count() === 0) {
$ticket->delete();
}
}
}
// Recalculate order totals
$order->refresh();
$order->load('items');
$subtotal = $order->items->sum('line_total');
$surchargePercent = \App\Models\Order::getSurchargePercentage($order->payment_terms);
$surcharge = $subtotal * ($surchargePercent / 100);
$taxRate = $order->business->getTaxRate();
$tax = ($subtotal + $surcharge) * $taxRate;
$total = $subtotal + $surcharge + $tax;
$order->update([
'subtotal' => $subtotal,
'surcharge' => $surcharge,
'tax' => $tax,
'total' => $total,
]);
}
// Check if any items remain
if ($order->items()->count() === 0) {
$order->update([
'status' => 'rejected',
'rejected_at' => now(),
'rejected_reason' => 'All items rejected by buyer during review',
]);
} else {
// Mark as approved for delivery
$order->update([
'status' => 'approved_for_delivery',
'buyer_approved_at' => now(),
'buyer_approved_by' => auth()->id(),
]);
}
}
});
// Notify seller if items were rejected
if (! empty($rejectedProductNames)) {
try {
$sellerNotificationService = app(\App\Services\SellerNotificationService::class);
$sellerNotificationService->itemsRejectedDuringReview($order, $rejectedProductNames);
} catch (\Exception $e) {
// Log the error but don't block the approval process
\Log::error('Failed to send seller notification for rejected items', [
'order_id' => $order->id,
'order_number' => $order->order_number,
'error' => $e->getMessage(),
]);
}
}
$message = match ($order->status) {
'rejected' => 'Order rejected. Items have been returned to inventory.',
'approved_for_delivery' => empty($validated['rejected_items'])
? 'Order approved for delivery!'
: 'Order approved with '.count($validated['rejected_items']).' item(s) removed.',
default => 'Order updated.',
};
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
->with('success', $message);
}
/**
* Show delivery acceptance form for buyer to accept/reject items (Review #2: After delivery).
*/
public function showAcceptance(\App\Models\Business $business, Order $order)
{
// Authorization check
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to access this order.');
}
// Only delivered orders can be accepted
if ($order->status !== 'delivered') {
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
->with('error', 'Only delivered orders can be accepted.');
}
// Load relationships including COAs
$order->load([
'items.product.brand',
'items.batch.coaFiles' => function ($query) {
$query->orderBy('is_primary', 'desc')->orderBy('display_order');
},
'business',
'location',
]);
return view('buyer.orders.accept', compact('business', 'order'));
}
/**
* Process delivery acceptance (accept/reject line items).
*/
public function processAcceptance(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
{
// Authorization check
if (! $order->belongsToBusiness($business)) {
abort(403, 'Unauthorized to modify this order.');
}
// Only delivered orders can be accepted
if ($order->status !== 'delivered') {
return back()->with('error', 'Only delivered orders can be accepted.');
}
$validated = $request->validate([
'items' => 'required|array',
'items.*.accepted_qty' => 'required|integer|min:0',
'items.*.rejected_qty' => 'required|integer|min:0',
'items.*.rejection_reason' => 'nullable|string|max:1000',
]);
// Custom validation: accepted + rejected must equal ordered quantity
$order->load('items');
foreach ($validated['items'] as $itemId => $itemData) {
$orderItem = $order->items->firstWhere('id', $itemId);
if (! $orderItem) {
continue;
}
$totalQty = $itemData['accepted_qty'] + $itemData['rejected_qty'];
if ($totalQty !== $orderItem->quantity) {
return back()->withErrors([
"items.{$itemId}" => "Accepted and rejected quantities must equal ordered quantity ({$orderItem->quantity})",
]);
}
// Validate rejection reason is provided when items are rejected
if ($itemData['rejected_qty'] > 0 && empty($itemData['rejection_reason'])) {
return back()->withErrors([
"items.{$itemId}.rejection_reason" => 'Rejection reason is required when rejecting items',
]);
}
}
// Update each order item with acceptance data
\DB::transaction(function () use ($order, $validated) {
foreach ($validated['items'] as $itemId => $itemData) {
$orderItem = $order->items->firstWhere('id', $itemId);
if ($orderItem) {
$orderItem->update([
'accepted_qty' => $itemData['accepted_qty'],
'rejected_qty' => $itemData['rejected_qty'],
'rejection_reason' => $itemData['rejection_reason'] ?? null,
]);
// Return rejected items to inventory if batch is set
if ($itemData['rejected_qty'] > 0 && $orderItem->batch_id && $orderItem->batch) {
$orderItem->batch->deallocate($itemData['rejected_qty']);
}
}
}
// Determine final order status
$hasRejections = collect($validated['items'])->some(fn ($item) => $item['rejected_qty'] > 0);
$allRejected = collect($validated['items'])->every(fn ($item) => $item['rejected_qty'] === ($order->items->firstWhere('id', array_search($item, $validated['items']))->quantity ?? 0));
if ($allRejected) {
$order->update([
'status' => 'rejected',
'rejected_at' => now(),
'rejected_reason' => 'All items rejected by buyer',
]);
} else {
$order->markBuyerApproved();
// Create invoice based on accepted quantities
$invoiceService = app(\App\Services\InvoiceService::class);
$invoiceService->createFromDelivery($order);
}
});
$message = $order->status === 'rejected'
? 'Order rejected. All items have been returned to inventory.'
: 'Order accepted successfully. Invoice has been generated.';
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
->with('success', $message);
}
}

View File

@@ -42,6 +42,23 @@ class DashboardController extends Controller
$isPending = $business->status === 'submitted';
$isRejected = $business->status === 'rejected';
// Get user's departments to determine which metrics to show
$userDepartments = $user->departments ?? collect();
$departmentCodes = $userDepartments->pluck('code');
// Determine dashboard type based on departments
$hasSolventless = $departmentCodes->intersect(['LAZ-SOLV', 'CRG-SOLV'])->isNotEmpty();
$hasSales = $departmentCodes->intersect(['CBD-SALES', 'CBD-MKTG'])->isNotEmpty();
$hasDelivery = $departmentCodes->contains('CRG-DELV');
$isOwner = $business->owner_user_id === $user->id;
$isSuperAdmin = $user->hasRole('super-admin');
// Dashboard blocks determined ONLY by department groups (not by ownership or admin role)
// Users see data for their assigned departments - add user to department for access
$showSalesMetrics = $hasSales;
$showProcessingMetrics = $hasSolventless;
$showFleetMetrics = $hasDelivery;
// Get filtered brand IDs for multi-tenancy
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
@@ -56,25 +73,27 @@ class DashboardController extends Controller
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Get order IDs that have items matching our brands
$currentOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$currentStart, $currentEnd]))
->pluck('order_id')
->unique();
// Get order IDs and revenue in single optimized queries using joins
$currentStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$currentStart, $currentEnd])
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
->first();
$previousOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$previousStart, $previousEnd]))
->pluck('order_id')
->unique();
$previousStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
->first();
// Revenue
$currentRevenue = \App\Models\Order::whereIn('id', $currentOrderIds)->sum('total') / 100;
$previousRevenue = \App\Models\Order::whereIn('id', $previousOrderIds)->sum('total') / 100;
$currentRevenue = ($currentStats->revenue ?? 0) / 100;
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
$revenueChange = $previousRevenue > 0 ? (($currentRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
// Orders count
$currentOrders = $currentOrderIds->count();
$previousOrders = $previousOrderIds->count();
$currentOrders = $currentStats->order_count ?? 0;
$previousOrders = $previousStats->order_count ?? 0;
$ordersChange = $previousOrders > 0 ? (($currentOrders - $previousOrders) / $previousOrders) * 100 : 0;
// Products count (active products for selected brand(s))
@@ -141,18 +160,37 @@ class DashboardController extends Controller
// Get chart data for revenue visualization
$chartData = $this->getRevenueChartData($brandIds);
// Get processing metrics if user is in solventless departments
$processingData = null;
if ($showProcessingMetrics) {
$processingData = $this->getProcessingMetrics($business, $userDepartments);
}
// Get fleet metrics if user is in delivery department
$fleetData = null;
if ($showFleetMetrics) {
$fleetData = $this->getFleetMetrics($business);
}
return view('seller.dashboard', [
'user' => $user,
'business' => $business,
'needsOnboarding' => $needsOnboarding,
'isPending' => $isPending,
'isRejected' => $isRejected,
'isOwner' => $isOwner,
'dashboardData' => $dashboardData,
'progressData' => $progressData,
'progressSummary' => $progressSummary,
'chartData' => $chartData,
'invoiceStats' => $stats,
'recentInvoices' => $recentInvoices,
'showSalesMetrics' => $showSalesMetrics,
'showProcessingMetrics' => $showProcessingMetrics,
'showFleetMetrics' => $showFleetMetrics,
'processingData' => $processingData,
'fleetData' => $fleetData,
'userDepartments' => $userDepartments,
]);
}
@@ -188,16 +226,11 @@ class DashboardController extends Controller
$start = now()->sub($count, $unit)->startOfDay();
$end = now()->endOfDay();
// Get all order IDs for the period
$orderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$start, $end]))
->pluck('order_id')
->unique();
// Get orders with dates
$orders = \App\Models\Order::whereIn('id', $orderIds)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, SUM(total) as revenue')
// Optimized query using join instead of subquery
$orders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
->whereIn('order_items.brand_name', $brandNames)
->whereBetween('orders.created_at', [$start, $end])
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
->groupBy('date')
->orderBy('date', 'asc')
->get();
@@ -272,4 +305,426 @@ class DashboardController extends Controller
'values' => $values,
];
}
/**
* Get processing/manufacturing metrics for solventless departments
*/
private function getProcessingMetrics(Business $business, $userDepartments): array
{
$solventlessDepts = $userDepartments->whereIn('code', ['LAZ-SOLV', 'CRG-SOLV']);
$departmentIds = $solventlessDepts->pluck('id');
// Current period (last 30 days)
$currentStart = now()->subDays(30);
$previousStart = now()->subDays(60);
$previousEnd = now()->subDays(30);
// Get wash reports (hash washes) - using Conversion model
$currentWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $currentStart)
->count();
$previousWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('created_at', [$previousStart, $previousEnd])
->count();
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
// Average Yield (calculate from metadata)
$currentWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $currentStart)
->get();
$currentYield = $currentWashesWithYield->avg(function ($conversion) {
$stage1 = $conversion->getStage1Data();
$stage2 = $conversion->getStage2Data();
if (! $stage1 || ! $stage2) {
return 0;
}
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
}) ?? 0;
$previousWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('created_at', [$previousStart, $previousEnd])
->get();
$previousYield = $previousWashesWithYield->avg(function ($conversion) {
$stage1 = $conversion->getStage1Data();
$stage2 = $conversion->getStage2Data();
if (! $stage1 || ! $stage2) {
return 0;
}
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
}) ?? 0;
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
// Active Work Orders
$activeWorkOrders = \App\Models\WorkOrder::where('business_id', $business->id)
->whereIn('department_id', $departmentIds)
->where('status', 'in_progress')
->count();
// Completed Work Orders (30 days)
$currentCompletedOrders = \App\Models\WorkOrder::where('business_id', $business->id)
->whereIn('department_id', $departmentIds)
->where('status', 'completed')
->where('updated_at', '>=', $currentStart)
->count();
$previousCompletedOrders = \App\Models\WorkOrder::where('business_id', $business->id)
->whereIn('department_id', $departmentIds)
->where('status', 'completed')
->whereBetween('updated_at', [$previousStart, $previousEnd])
->count();
$completedChange = $previousCompletedOrders > 0 ? (($currentCompletedOrders - $previousCompletedOrders) / $previousCompletedOrders) * 100 : 0;
// Get strain performance data
$strainPerformance = $this->getStrainPerformanceData($business, $currentStart);
// Get Idle Fresh Frozen data
$idleFreshFrozen = $this->getIdleFreshFrozen($business);
// Get current user's subdivision prefixes (first 3 chars of department codes)
$userSubdivisions = auth()->user()->departments()
->pluck('code')
->map(fn ($code) => substr($code, 0, 3))
->unique()
->values();
// Get all user IDs in the same subdivisions
$allowedOperatorIds = \App\Models\User::whereHas('departments', function ($q) use ($userSubdivisions) {
$q->whereIn(\DB::raw('SUBSTRING(code, 1, 3)'), $userSubdivisions->toArray());
})->pluck('id');
// Get Active Washes data
$activeWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'in_progress')
->whereIn('operator_user_id', $allowedOperatorIds)
->with(['operator.departments'])
->orderBy('started_at', 'desc')
->take(5)
->get();
return [
'washes' => [
'current' => $currentWashes,
'previous' => $previousWashes,
'change' => round($washesChange, 1),
],
'yield' => [
'current' => number_format($currentYield, 1),
'previous' => number_format($previousYield, 1),
'change' => round($yieldChange, 1),
],
'active_orders' => [
'current' => $activeWorkOrders,
'previous' => $activeWorkOrders, // No historical tracking
'change' => 0,
],
'completed_orders' => [
'current' => $currentCompletedOrders,
'previous' => $previousCompletedOrders,
'change' => round($completedChange, 1),
],
'strain_performance' => $strainPerformance,
'idle_fresh_frozen' => $idleFreshFrozen,
'active_washes' => $activeWashes,
];
}
/**
* Get idle Fresh Frozen components ready for processing
*/
private function getIdleFreshFrozen(Business $business): array
{
// Find Fresh Frozen category
$ffCategory = \App\Models\ComponentCategory::where('business_id', $business->id)
->where('slug', 'fresh-frozen')
->first();
if (! $ffCategory) {
return [
'components' => collect([]),
'total_count' => 0,
'total_weight' => 0,
];
}
// Get all Fresh Frozen components with inventory
$components = \App\Models\Component::where('business_id', $business->id)
->where('component_category_id', $ffCategory->id)
->where('quantity_on_hand', '>', 0)
->where('is_active', true)
->orderBy('created_at', 'desc')
->limit(5) // Show top 5 on dashboard
->get();
// Add past performance data for each component
$componentsWithPerformance = $components->map(function ($component) use ($business) {
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
// Get past washes for this strain
$pastWashes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereJsonContains('metadata->stage_1->strain', $strainName)
->orderBy('completed_at', 'desc')
->take(10)
->get();
if ($pastWashes->isEmpty()) {
$component->past_performance = [
'has_data' => false,
'wash_count' => 0,
'avg_yield' => null,
'avg_hash_quality' => null,
];
} else {
// Calculate average yield
$avgYield = $pastWashes->avg(function ($wash) {
$stage1 = $wash->getStage1Data();
$stage2 = $wash->getStage2Data();
if (! $stage1 || ! $stage2) {
return 0;
}
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
});
// Calculate average hash quality (Stage 2) - defensive extraction
$qualityGrades = [];
foreach ($pastWashes as $wash) {
$stage2 = $wash->getStage2Data();
if (! $stage2 || ! isset($stage2['yields'])) {
continue;
}
// Check all yield types for quality data (handles both hash and rosin structures)
foreach ($stage2['yields'] as $yieldType => $yieldData) {
if (isset($yieldData['quality']) && $yieldData['quality']) {
$qualityGrades[] = $yieldData['quality'];
}
}
}
// Only include quality if we have the data
if (empty($qualityGrades)) {
$component->past_performance = [
'has_data' => true, // Has wash data
'wash_count' => $pastWashes->count(),
'avg_yield' => round($avgYield, 1),
'avg_hash_quality' => null, // No quality data tracked
];
} else {
$avgQuality = $this->calculateAverageQuality($qualityGrades);
$component->past_performance = [
'has_data' => true,
'wash_count' => $pastWashes->count(),
'avg_yield' => round($avgYield, 1),
'avg_hash_quality' => $avgQuality['letter'],
];
}
}
return $component;
});
return [
'components' => $componentsWithPerformance,
'total_count' => $componentsWithPerformance->count(),
'total_weight' => $componentsWithPerformance->sum('quantity_on_hand'),
];
}
/**
* Get strain-specific performance metrics for processing department
*/
private function getStrainPerformanceData(Business $business, $startDate): array
{
// Get all completed washes for the period
$washes = \App\Models\Conversion::where('business_id', $business->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->where('created_at', '>=', $startDate)
->get();
// Group by strain and calculate metrics
$strainData = [];
foreach ($washes as $wash) {
$stage1 = $wash->getStage1Data();
$stage2 = $wash->getStage2Data();
if (! $stage1 || ! $stage2) {
continue;
}
$strain = $stage1['strain'] ?? 'Unknown';
$startingWeight = $stage1['starting_weight'] ?? 0;
$totalYield = $stage2['total_yield'] ?? 0;
if (! isset($strainData[$strain])) {
$strainData[$strain] = [
'strain' => $strain,
'wash_count' => 0,
'total_input' => 0,
'total_output' => 0,
'yields' => [],
'hash_stage1_quality_grades' => [],
'hash_stage2_quality_grades' => [],
];
}
$strainData[$strain]['wash_count']++;
$strainData[$strain]['total_input'] += $startingWeight;
$strainData[$strain]['total_output'] += $totalYield;
// Calculate yield percentage as number
$yieldPercentage = $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
$strainData[$strain]['yields'][] = $yieldPercentage;
// Collect quality grades from Stage 1 (hash - initial assessment)
if (isset($stage1['quality_grades'])) {
foreach ($stage1['quality_grades'] as $micron => $grade) {
if ($grade) {
$strainData[$strain]['hash_stage1_quality_grades'][] = $grade;
}
}
}
// Collect quality grades from Stage 2 (hash - final assessment after drying)
if (isset($stage2['yields'])) {
foreach ($stage2['yields'] as $type => $data) {
if (isset($data['quality']) && $data['quality']) {
$strainData[$strain]['hash_stage2_quality_grades'][] = $data['quality'];
}
}
}
}
// Calculate averages and format data
$results = [];
foreach ($strainData as $strain => $data) {
$avgYield = count($data['yields']) > 0 ? array_sum($data['yields']) / count($data['yields']) : 0;
// Calculate average quality grades
// Stage 1: Initial hash assessment during washing
// Stage 2: Final hash assessment after drying
$hashStage1Quality = $this->calculateAverageQuality($data['hash_stage1_quality_grades']);
$hashStage2Quality = $this->calculateAverageQuality($data['hash_stage2_quality_grades']);
$results[] = [
'strain' => $strain,
'wash_count' => $data['wash_count'],
'total_input_g' => round($data['total_input'], 2),
'total_output_g' => round($data['total_output'], 2),
'avg_yield_percentage' => round($avgYield, 2),
'avg_input_per_wash' => $data['wash_count'] > 0 ? round($data['total_input'] / $data['wash_count'], 2) : 0,
'avg_output_per_wash' => $data['wash_count'] > 0 ? round($data['total_output'] / $data['wash_count'], 2) : 0,
'avg_hash_quality' => $hashStage1Quality['letter'], // Stage 1 assessment
'avg_rosin_quality' => $hashStage2Quality['letter'], // Stage 2 assessment (still called rosin for backward compat with views)
'hash_quality_score' => $hashStage1Quality['score'],
'rosin_quality_score' => $hashStage2Quality['score'], // Actually hash Stage 2 score
];
}
// Sort by wash count (most processed strains first)
usort($results, function ($a, $b) {
return $b['wash_count'] - $a['wash_count'];
});
return $results;
}
/**
* Calculate average quality grade from array of letter grades
*
* @param array $grades Array of letter grades (A, B, C, D, F)
* @return array ['letter' => 'A', 'score' => 4.0]
*/
private function calculateAverageQuality(array $grades): array
{
if (empty($grades)) {
return ['letter' => null, 'score' => null];
}
// Convert letters to numeric scores
$gradeMap = ['A' => 4, 'B' => 3, 'C' => 2, 'D' => 1, 'F' => 0];
$scores = array_map(fn ($grade) => $gradeMap[$grade] ?? 0, $grades);
$avgScore = array_sum($scores) / count($scores);
// Convert back to letter grade
if ($avgScore >= 3.5) {
$letter = 'A';
} elseif ($avgScore >= 2.5) {
$letter = 'B';
} elseif ($avgScore >= 1.5) {
$letter = 'C';
} elseif ($avgScore >= 0.5) {
$letter = 'D';
} else {
$letter = 'F';
}
return ['letter' => $letter, 'score' => round($avgScore, 2)];
}
/**
* Get fleet/delivery metrics
*/
private function getFleetMetrics(Business $business): array
{
// Current metrics
$totalDrivers = \App\Models\Driver::where('business_id', $business->id)->count();
$activeVehicles = \App\Models\Vehicle::where('business_id', $business->id)
->where('status', 'active')
->count();
$totalVehicles = \App\Models\Vehicle::where('business_id', $business->id)->count();
// Deliveries today (would need Delivery model - placeholder)
$deliveriesToday = 0;
return [
'drivers' => [
'current' => $totalDrivers,
'previous' => $totalDrivers,
'change' => 0,
],
'active_vehicles' => [
'current' => $activeVehicles,
'previous' => $activeVehicles,
'change' => 0,
],
'total_vehicles' => [
'current' => $totalVehicles,
'previous' => $totalVehicles,
'change' => 0,
],
'deliveries_today' => [
'current' => $deliveriesToday,
'previous' => 0,
'change' => 0,
],
];
}
}

View File

@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\DeliveryWindow;
use App\Models\Manifest;
use App\Models\Order;
use App\Services\DeliveryWindowService;
use App\Services\ManifestService;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -16,6 +19,10 @@ use Illuminate\View\View;
class OrderController extends Controller
{
public function __construct(
private DeliveryWindowService $deliveryWindowService
) {}
/**
* Display list of orders for sellers.
* Shows all orders including new, in-progress, completed, rejected, and cancelled.
@@ -30,11 +37,12 @@ class OrderController extends Controller
'new',
'accepted',
'in_progress',
'ready_for_invoice',
'awaiting_invoice_approval',
'ready_for_manifest',
'ready_for_delivery',
'approved_for_delivery',
'out_for_delivery',
'delivered',
'completed',
'rejected',
'cancelled',
])
@@ -82,11 +90,66 @@ class OrderController extends Controller
*/
public function show(\App\Models\Business $business, Order $order): View
{
$order->load(['business', 'user', 'location', 'items.product.brand']);
$order->load([
'business',
'user',
'location',
'items.product.brand',
'audits' => function ($query) {
$query->with('user')->orderBy('created_at', 'desc');
},
'pendingCancellationRequest.requestedBy',
'cancellationRequests',
'cancellationRequests.audits' => function ($query) {
$query->with('user')->orderBy('created_at', 'desc');
},
'cancellationRequests.requestedBy',
'cancellationRequests.reviewedBy',
]);
return view('seller.orders.show', compact('order', 'business'));
}
/**
* Approve a cancellation request (seller action).
*/
public function approveCancellationRequest(\App\Models\Business $business, Order $order, \App\Models\OrderCancellationRequest $cancellationRequest)
{
if (! $cancellationRequest->isPending()) {
return back()->with('error', 'This cancellation request has already been reviewed.');
}
if ($cancellationRequest->order_id !== $order->id) {
abort(404);
}
$cancellationRequest->approve(auth()->user());
return back()->with('success', "Cancellation request approved. Order {$order->order_number} has been cancelled.");
}
/**
* Deny a cancellation request (seller action).
*/
public function denyCancellationRequest(\App\Models\Business $business, Order $order, \App\Models\OrderCancellationRequest $cancellationRequest, Request $request)
{
if (! $cancellationRequest->isPending()) {
return back()->with('error', 'This cancellation request has already been reviewed.');
}
if ($cancellationRequest->order_id !== $order->id) {
abort(404);
}
$validated = $request->validate([
'notes' => 'required|string|max:1000',
]);
$cancellationRequest->deny(auth()->user(), $validated['notes']);
return back()->with('success', 'Cancellation request denied.');
}
/**
* Accept a new order (seller accepting buyer's order).
*/
@@ -138,14 +201,40 @@ class OrderController extends Controller
return back()->with('success', "Order {$order->order_number} has been cancelled.");
}
/**
* Approve order for delivery (after buyer selects delivery method).
*/
public function approveForDelivery(\App\Models\Business $business, Order $order)
{
try {
$order->approveForDelivery();
return back()->with('success', 'Order approved for delivery. You can now schedule delivery/pickup.');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Show picking ticket interface for warehouse/lab staff.
* Mobile-friendly interface for updating picked quantities.
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K
*/
public function pick(\App\Models\Business $business, Order $pickingTicket): View|RedirectResponse
public function pick(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket): View|RedirectResponse
{
$order = $pickingTicket; // For clarity in blade templates
// Handle both old (Order) and new (PickingTicket) systems
if ($pickingTicket instanceof \App\Models\PickingTicket) {
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Load relationships for the ticket
$ticket->load(['items.orderItem.product', 'department']);
return view('seller.orders.pick', compact('order', 'ticket', 'business'));
}
// Old system: Order model
$order = $pickingTicket;
// Only allow picking for accepted or in_progress orders
if (! in_array($order->status, ['accepted', 'in_progress'])) {
@@ -163,49 +252,66 @@ class OrderController extends Controller
* Allows partial fulfillment - invoice will reflect actual picked quantities.
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K/complete
*/
public function complete(\App\Models\Business $business, Order $pickingTicket)
public function complete(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
{
$order = $pickingTicket; // For clarity
// Handle new PickingTicket system
if ($pickingTicket instanceof \App\Models\PickingTicket) {
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Mark this ticket as complete
$ticket->complete();
// PickingTicket->complete() handles:
// - Setting ticket status to 'completed'
// - Checking if all tickets are complete
// - Advancing order to ready_for_delivery if all tickets done
// The order status flow is now: accepted -> in_progress -> ready_for_delivery
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Picking ticket completed successfully!');
}
// Handle old single picking ticket system (Order model)
$order = $pickingTicket;
// Calculate final workorder status based on picked quantities
$order->updatePickingStatus();
$order->refresh();
// Recalculate order totals based on picked quantities
$subtotal = 0;
foreach ($order->items as $item) {
// Update line total based on picked quantity
$newLineTotal = $item->unit_price * $item->picked_qty;
$item->update(['line_total' => $newLineTotal]);
$subtotal += $newLineTotal;
}
// Update order totals
$tax = $subtotal * 0.0; // TODO: Calculate tax based on company settings
$total = $subtotal + $tax;
$order->update([
'subtotal' => $subtotal,
'tax' => $tax,
'total' => $total,
'status' => 'ready_for_invoice',
'ready_for_invoice_at' => now(),
]);
// Automatically generate invoice for buyer approval
$invoiceService = app(\App\Services\InvoiceService::class);
$invoice = $invoiceService->generateFromOrder($order);
// Update order to awaiting invoice approval status
$order->update([
'status' => 'awaiting_invoice_approval',
'invoiced_at' => now(),
]);
// Invoice is now ready for buyer approval with approval_status = 'pending_buyer_approval'
// NOTE: Do NOT auto-advance to ready_for_delivery
// Seller must manually click "Mark Order Ready for Buyer Review" button
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Picking ticket completed! Invoice has been generated based on fulfilled quantities.');
->with('success', 'Picking ticket completed! You can now mark the order ready for buyer review.');
}
/**
* Display picking ticket as PDF in browser.
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K/pdf
*/
public function downloadPickingTicketPdf(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
{
// Handle both old (Order) and new (PickingTicket) systems
if ($pickingTicket instanceof \App\Models\PickingTicket) {
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Load relationships for the ticket
$ticket->load(['items.orderItem.product.brand', 'department']);
$pdf = \PDF::loadView('seller.orders.pick-pdf', compact('order', 'ticket', 'business'));
return $pdf->stream('picking-ticket-'.$ticket->ticket_number.'.pdf');
}
// Old system: Order model
$order = $pickingTicket;
$order->load(['business', 'user', 'location', 'items.product.brand']);
$pdf = \PDF::loadView('seller.orders.pick-pdf', compact('order', 'business'));
return $pdf->stream('picking-ticket-'.$order->picking_ticket_number.'.pdf');
}
/**
@@ -621,4 +727,384 @@ class OrderController extends Controller
'delivery_url' => $deliveryUrl,
]);
}
/**
* Update pickup date for an order
*/
public function updatePickupDate(\App\Models\Business $business, Order $order, Request $request)
{
// Ensure order can be accessed by this business (seller)
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow updates for pickup orders at ready_for_delivery or approved_for_delivery status
if (! in_array($order->status, ['ready_for_delivery', 'approved_for_delivery'])) {
abort(422, 'Pickup date can only be set when order is ready for pickup');
}
if (! $order->isPickup()) {
abort(422, 'Pickup date can only be set for pickup orders');
}
$validated = $request->validate([
'pickup_date' => 'required|date|after_or_equal:today',
]);
$order->update([
'pickup_date' => $validated['pickup_date'],
]);
// Return JSON for AJAX requests
if ($request->expectsJson() || $request->ajax()) {
return response()->json([
'success' => true,
'message' => 'Pickup date updated successfully',
'pickup_date' => $order->pickup_date->format('l, F j, Y'),
]);
}
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Pickup date updated successfully');
}
/**
* Mark order as ready for delivery (seller action).
* Only available when all picking tickets are completed.
*/
public function markReadyForDelivery(\App\Models\Business $business, Order $order): RedirectResponse
{
// Verify business owns this order
$isSellerOrder = $order->items()->whereHas('product.brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->exists();
if (! $isSellerOrder) {
abort(403, 'Unauthorized access to this order');
}
// Only allow when order is accepted or in_progress
if (! in_array($order->status, ['accepted', 'in_progress'])) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order cannot be marked as ready for delivery from current status');
}
// Verify all items have been picked (workorder at 100% OR all picking tickets completed)
// Note: We check picking tickets first because there may be short-picks where workorder < 100%
// but the warehouse has completed all tickets (meaning they picked everything available)
if (! $order->allPickingTicketsCompleted() && $order->workorder_status < 100) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'All order items must be picked before marking order ready for delivery');
}
// Mark order as ready for delivery
$success = $order->markReadyForDelivery();
if ($success) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order marked as ready for delivery. Buyer has been notified.');
}
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Failed to mark order as ready for delivery');
}
/**
* Get available delivery windows for a specific date (for sellers).
*/
public function getAvailableDeliveryWindows(\App\Models\Business $business, Order $order, Request $request)
{
// Ensure order is for seller's business
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
abort(403, 'Unauthorized access to order');
}
$date = $request->query('date');
if (! $date) {
return response()->json(['error' => 'Date parameter required'], 400);
}
try {
$selectedDate = \Carbon\Carbon::parse($date);
} catch (\Exception $e) {
return response()->json(['error' => 'Invalid date format'], 400);
}
$dayOfWeek = $selectedDate->dayOfWeek;
// Fetch active delivery windows for the seller's business on this day
$windows = \App\Models\DeliveryWindow::where('business_id', $business->id)
->where('day_of_week', $dayOfWeek)
->where('is_active', true)
->orderBy('start_time')
->get()
->map(function ($window) {
return [
'id' => $window->id,
'day_name' => $window->day_name,
'time_range' => $window->time_range,
];
});
return response()->json(['windows' => $windows]);
}
/**
* Update order's delivery window (seller action).
*/
public function updateDeliveryWindow(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
{
// Ensure order is for seller's business
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
if ($sellerBusinessId !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow updates for delivery orders at approved_for_delivery status
if ($order->status !== 'approved_for_delivery') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Delivery window can only be set after buyer has approved the order for delivery');
}
// Only delivery orders need delivery windows
if (! $order->isDelivery()) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Delivery window can only be set for delivery orders');
}
$validated = $request->validate([
'delivery_window_id' => 'required|exists:delivery_windows,id',
'delivery_window_date' => 'required|date|after_or_equal:today',
]);
$window = DeliveryWindow::findOrFail($validated['delivery_window_id']);
// Ensure window belongs to seller's business
if ($window->business_id !== $business->id) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Delivery window does not belong to your business');
}
$date = Carbon::parse($validated['delivery_window_date']);
// Validate using service
if (! $this->deliveryWindowService->validateWindowSelection($window, $date)) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Invalid delivery window selection');
}
$this->deliveryWindowService->updateOrderWindow($order, $window, $date);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Delivery window updated successfully');
}
/**
* Mark order as out for delivery (for delivery orders at approved_for_delivery status).
*/
public function markOutForDelivery(\App\Models\Business $business, Order $order): RedirectResponse
{
// Ensure order is for seller's business
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow for delivery orders at approved_for_delivery status
if ($order->status !== 'approved_for_delivery') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order must be approved for delivery before marking as out for delivery');
}
if (! $order->isDelivery()) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'This action is only available for delivery orders');
}
// Require delivery window to be set
if (! $order->deliveryWindow || ! $order->delivery_window_date) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Please schedule a delivery window before marking order as out for delivery');
}
// Update order status
$order->update([
'status' => 'out_for_delivery',
'out_for_delivery_at' => now(),
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order marked as out for delivery');
}
/**
* Confirm pickup complete (for pickup orders at approved_for_delivery status).
*/
public function confirmPickup(\App\Models\Business $business, Order $order): RedirectResponse
{
// Ensure order is for seller's business
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow for pickup orders at approved_for_delivery status
if ($order->status !== 'approved_for_delivery') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order must be approved for delivery before confirming pickup');
}
if (! $order->isPickup()) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'This action is only available for pickup orders');
}
// Require pickup date to be set
if (! $order->pickup_date) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Please set a pickup date before confirming pickup completion');
}
// Update order status to delivered (pickup complete)
$order->update([
'status' => 'delivered',
'delivered_at' => now(),
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Pickup confirmed! Order marked as delivered.');
}
/**
* Confirm delivery complete (for delivery orders).
*/
public function confirmDelivery(\App\Models\Business $business, Order $order): RedirectResponse
{
// Ensure order is for seller's business
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow for delivery orders at out_for_delivery status
if ($order->status !== 'out_for_delivery') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order must be out for delivery before confirming delivery completion');
}
if (! $order->isDelivery()) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'This action is only available for delivery orders');
}
// Update order status to delivered
$order->update([
'status' => 'delivered',
'delivered_at' => now(),
]);
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Delivery confirmed! Order marked as delivered. You can now finalize the order.');
}
/**
* Finalize order after delivery - confirm actual delivered quantities and complete the order.
*/
public function finalizeOrder(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
{
// Ensure order is for seller's business
if ($order->items->first()?->product?->brand?->business_id !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only allow finalization for delivered orders
if ($order->status !== 'delivered') {
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('error', 'Order must be delivered before it can be finalized');
}
// Validate the request
$validated = $request->validate([
'delivery_notes' => 'nullable|string|max:5000',
'items' => 'required|array',
'items.*.id' => 'required|exists:order_items,id',
'items.*.delivered_qty' => 'required|numeric|min:0',
]);
\DB::transaction(function () use ($order, $validated) {
foreach ($validated['items'] as $itemData) {
$orderItem = $order->items()->findOrFail($itemData['id']);
$deliveredQty = (float) $itemData['delivered_qty'];
$pickedQty = (float) $orderItem->picked_qty;
// Calculate rejected quantity (items that were picked but not delivered)
$rejectedQty = $pickedQty - $deliveredQty;
// Update the order item with delivered quantity
$orderItem->update([
'delivered_qty' => $deliveredQty,
]);
// Return rejected items to inventory if any
if ($rejectedQty > 0 && $orderItem->batch_id && $orderItem->batch) {
$orderItem->batch->increment('quantity_available', $rejectedQty);
}
}
// Update order with finalization details
$order->update([
'delivery_notes' => $validated['delivery_notes'],
'finalized_at' => now(),
'finalized_by_user_id' => Auth::id(),
'status' => 'completed',
]);
// Recalculate line totals for each item based on delivered quantities
$newSubtotal = 0;
foreach ($order->items as $item) {
$deliveredQty = $item->delivered_qty ?? $item->picked_qty;
$lineTotal = $deliveredQty * $item->unit_price;
$item->update(['line_total' => $lineTotal]);
$newSubtotal += $lineTotal;
}
$order->update([
'subtotal' => $newSubtotal,
'total' => $newSubtotal + ($order->tax ?? 0) + ($order->delivery_fee ?? 0),
]);
// Refresh order to get updated items with delivered_qty
$order->refresh();
// Generate final invoice based on delivered quantities
$invoiceService = app(\App\Services\InvoiceService::class);
$invoiceService->generateFromOrder($order);
});
return redirect()
->route('seller.business.orders.show', [$business->slug, $order])
->with('success', 'Order finalized successfully. Final invoice generated.');
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Models\Batch;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PublicCoaController extends Controller
{
/**
* Display COA information for a specific batch
* Public route: /coa/{batchNumber}
*/
public function show(string $batchNumber)
{
// Find batch by batch number
$batch = Batch::where('batch_number', $batchNumber)
->with(['product', 'lab.coaFiles', 'business'])
->first();
if (! $batch) {
abort(404, 'Batch not found');
}
// Get lab test and COA files
$lab = $batch->lab;
if (! $lab) {
abort(404, 'No lab test available for this batch');
}
// Get all COA files
$coaFiles = $lab->getAllCoas();
$primaryCoa = $lab->getPrimaryCoa();
return view('public.coa.show', [
'batch' => $batch,
'lab' => $lab,
'coaFiles' => $coaFiles,
'primaryCoa' => $primaryCoa,
'product' => $batch->product,
'business' => $batch->business,
]);
}
/**
* Download a specific COA file
*/
public function download(string $batchNumber, int $coaFileId)
{
// Find batch
$batch = Batch::where('batch_number', $batchNumber)
->with('lab.coaFiles')
->first();
if (! $batch || ! $batch->lab) {
abort(404, 'Batch or lab test not found');
}
// Verify COA file belongs to THIS batch's lab (business isolation)
$coaFile = $batch->lab->coaFiles()
->where('id', $coaFileId)
->firstOrFail();
if (! $coaFile) {
abort(404, 'COA file not found');
}
// Check if file exists
if (! $coaFile->exists()) {
abort(404, 'File not found in storage');
}
// Download the file
return Storage::download($coaFile->file_path, $coaFile->file_name);
}
/**
* View a specific COA file inline (for PDFs)
*/
public function view(string $batchNumber, int $coaFileId): StreamedResponse
{
// Find batch
$batch = Batch::where('batch_number', $batchNumber)
->with('lab.coaFiles')
->first();
if (! $batch || ! $batch->lab) {
abort(404, 'Batch or lab test not found');
}
// Verify COA file belongs to THIS batch's lab (business isolation)
$coaFile = $batch->lab->coaFiles()
->where('id', $coaFileId)
->firstOrFail();
if (! $coaFile) {
abort(404, 'COA file not found');
}
// Check if file exists
if (! $coaFile->exists()) {
abort(404, 'File not found in storage');
}
// Stream the file for inline viewing
return Storage::response($coaFile->file_path, $coaFile->file_name, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$coaFile->file_name.'"',
]);
}
/**
* Legacy route support: /retail/labs/{batchNumber}
* Redirects to new COA route
*/
public function legacyShow(string $batchNumber)
{
return redirect()->route('public.coa.show', ['batchNumber' => $batchNumber], 301);
}
}

View File

@@ -0,0 +1,329 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Batch;
use App\Models\Business;
use App\Models\Product;
use App\Services\QrCodeService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class BatchController extends Controller
{
/**
* Display a listing of batches for the business
*/
public function index(Request $request, Business $business)
{
// Build query for batches
$query = Batch::where('business_id', $business->id)
->with(['product.brand', 'coaFiles'])
->orderBy('production_date', 'desc');
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('batch_number', 'LIKE', "%{$search}%")
->orWhere('test_id', 'LIKE', "%{$search}%")
->orWhere('lot_number', 'LIKE', "%{$search}%")
->orWhereHas('product', function ($productQuery) use ($search) {
$productQuery->where('name', 'LIKE', "%{$search}%");
});
});
}
$batches = $query->paginate(20)->withQueryString();
// Separate active and inactive batches
$activeBatches = $batches->filter(fn ($batch) => $batch->is_active);
$inactiveBatches = $batches->filter(fn ($batch) => ! $batch->is_active);
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches'));
}
/**
* Show the form for creating a new batch
*/
public function create(Request $request, Business $business)
{
// Get products owned by this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
return view('seller.batches.create', compact('business', 'products'));
}
/**
* Store a newly created batch
*/
public function store(Request $request, Business $business)
{
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
'production_date' => 'nullable|date',
'test_date' => 'nullable|date',
'test_id' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Set business_id
$validated['business_id'] = $business->id;
$validated['is_active'] = true; // New batches are active by default
// Create batch (calculations happen in model boot method)
$batch = Batch::create($validated);
// Handle COA file uploads
if ($request->hasFile('coa_files')) {
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$batch->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'file_type' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'is_primary' => $index === 0,
'display_order' => $index,
]);
}
}
return redirect()
->route('seller.business.manufacturing.batches.index', $business->slug)
->with('success', 'Batch created successfully.');
}
/**
* Show the form for editing the specified batch
*/
public function edit(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Get products owned by this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
$batch->load('coaFiles');
return view('seller.batches.edit', compact('business', 'batch', 'products'));
}
/**
* Update the specified batch
*/
public function update(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number,'.$batch->id,
'production_date' => 'nullable|date',
'test_date' => 'nullable|date',
'test_id' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Update batch (calculations happen in model boot method)
$batch->update($validated);
// Handle new COA file uploads
if ($request->hasFile('coa_files')) {
$existingFilesCount = $batch->coaFiles()->count();
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$batch->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'file_type' => $file->getClientOriginalExtension(),
'file_size' => $file->getSize(),
'is_primary' => $existingFilesCount === 0 && $index === 0,
'display_order' => $existingFilesCount + $index,
]);
}
}
return redirect()
->route('seller.business.manufacturing.batches.index', $business->slug)
->with('success', 'Batch updated successfully.');
}
/**
* Remove the specified batch
*/
public function destroy(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
// Delete associated COA files from storage
foreach ($batch->coaFiles as $coaFile) {
if (Storage::disk('public')->exists($coaFile->file_path)) {
Storage::disk('public')->delete($coaFile->file_path);
}
}
$batch->delete();
return redirect()
->route('seller.business.manufacturing.batches.index', $business->slug)
->with('success', 'Batch deleted successfully.');
}
/**
* Generate QR code for a batch
*/
public function generateQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->generateForBatch($batch);
return response()->json($result);
}
/**
* Download QR code for a batch
*/
public function downloadQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$download = $qrService->download($batch);
if (! $download) {
return back()->with('error', 'QR code not found');
}
return $download;
}
/**
* Regenerate QR code for a batch
*/
public function regenerateQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->regenerate($batch);
return response()->json($result);
}
/**
* Delete QR code for a batch
*/
public function deleteQrCode(Request $request, Business $business, Batch $batch)
{
// Verify batch belongs to this business
if ($batch->business_id !== $business->id) {
abort(403, 'Unauthorized');
}
$qrService = app(QrCodeService::class);
$result = $qrService->delete($batch);
return response()->json($result);
}
/**
* Bulk generate QR codes for multiple batches
*/
public function bulkGenerateQrCodes(Request $request, Business $business)
{
$validated = $request->validate([
'batch_ids' => 'required|array',
'batch_ids.*' => 'exists:batches,id',
]);
// Verify all batches belong to this business
$batches = Batch::whereIn('id', $validated['batch_ids'])
->where('business_id', $business->id)
->get();
if ($batches->count() !== count($validated['batch_ids'])) {
return response()->json([
'success' => false,
'message' => 'Some batches do not belong to this business',
], 403);
}
$qrService = app(QrCodeService::class);
$result = $qrService->bulkGenerate($validated['batch_ids']);
return response()->json($result);
}
}

View File

@@ -0,0 +1,312 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class BrandController extends Controller
{
/**
* Display a listing of brands for the business
*/
public function index(Request $request, Business $business)
{
// Get brands for this business and parent company (if division)
$brands = Brand::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.brands.index', compact('business', 'brands'));
}
/**
* Show the form for creating a new brand
*/
public function create(Business $business)
{
return view('seller.brands.create', compact('business'));
}
/**
* Store a newly created brand in storage
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'tagline' => 'nullable|string|max:45',
'description' => 'nullable|string|max:300',
'long_description' => 'nullable|string|max:1000',
'website_url' => 'nullable|string|max:255',
'address' => 'nullable|string|max:255',
'unit_number' => 'nullable|string|max:50',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:2',
'zip_code' => 'nullable|string|max:10',
'phone' => 'nullable|string|max:20',
'logo' => 'nullable|image|max:2048',
'banner' => 'nullable|image|max:4096',
'is_public' => 'boolean',
'is_featured' => 'boolean',
'is_active' => 'boolean',
'instagram_handle' => 'nullable|string|max:255',
'facebook_url' => 'nullable|url|max:255',
'twitter_handle' => 'nullable|string|max:255',
'youtube_url' => 'nullable|url|max:255',
]);
// Automatically add https:// to website_url if not present
if ($request->filled('website_url')) {
$url = $validated['website_url'];
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$validated['website_url'] = 'https://'.$url;
}
}
// Generate slug from name
$validated['slug'] = Str::slug($validated['name']);
// Handle logo upload
if ($request->hasFile('logo')) {
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
}
// Handle banner upload
if ($request->hasFile('banner')) {
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
}
// Set boolean defaults
$validated['is_public'] = $request->boolean('is_public');
$validated['is_featured'] = $request->boolean('is_featured');
$validated['is_active'] = $request->boolean('is_active');
// Create brand
$brand = $business->brands()->create($validated);
return redirect()
->route('seller.business.brands.index', $business->slug)
->with('success', 'Brand created successfully!');
}
/**
* Display the specified brand (read-only view)
*/
public function show(Business $business, Brand $brand)
{
// Ensure brand belongs to this business or parent company (if division)
$allowedBusinessIds = [$business->id];
if ($business->parent_id) {
$allowedBusinessIds[] = $business->parent_id;
}
if (! in_array($brand->business_id, $allowedBusinessIds)) {
abort(403, 'This brand does not belong to your business.');
}
// Load relationships
$brand->load(['business', 'products']);
return view('seller.brands.show', compact('business', 'brand'));
}
/**
* Preview the brand as it would appear to buyers
*/
public function preview(Business $business, Brand $brand)
{
// Ensure brand belongs to this business or parent company (if division)
$allowedBusinessIds = [$business->id];
if ($business->parent_id) {
$allowedBusinessIds[] = $business->parent_id;
}
if (! in_array($brand->business_id, $allowedBusinessIds)) {
abort(403, 'This brand does not belong to your business.');
}
// Load relationships including active products
$brand->load(['business', 'products' => function ($query) {
$query->where('is_active', true)
->orderBy('name');
}]);
return view('seller.brands.preview', compact('business', 'brand'));
}
/**
* Show the form for editing the specified brand
*/
public function edit(Business $business, Brand $brand)
{
// Ensure brand belongs to this business or parent company (if division)
$allowedBusinessIds = [$business->id];
if ($business->parent_id) {
$allowedBusinessIds[] = $business->parent_id;
}
if (! in_array($brand->business_id, $allowedBusinessIds)) {
abort(403, 'This brand does not belong to your business.');
}
return view('seller.brands.edit', compact('business', 'brand'));
}
/**
* Update the specified brand in storage
*/
public function update(Request $request, Business $business, Brand $brand)
{
// Ensure brand belongs to this business or parent company (if division)
$allowedBusinessIds = [$business->id];
if ($business->parent_id) {
$allowedBusinessIds[] = $business->parent_id;
}
if (! in_array($brand->business_id, $allowedBusinessIds)) {
abort(403, 'This brand does not belong to your business.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'tagline' => 'nullable|string|max:45',
'description' => 'nullable|string|max:300',
'long_description' => 'nullable|string|max:1000',
'website_url' => 'nullable|string|max:255',
'address' => 'nullable|string|max:255',
'unit_number' => 'nullable|string|max:50',
'city' => 'nullable|string|max:100',
'state' => 'nullable|string|max:2',
'zip_code' => 'nullable|string|max:10',
'phone' => 'nullable|string|max:20',
'logo' => 'nullable|image|max:2048',
'banner' => 'nullable|image|max:4096',
'remove_logo' => 'boolean',
'remove_banner' => 'boolean',
'is_public' => 'boolean',
'is_featured' => 'boolean',
'is_active' => 'boolean',
'instagram_handle' => 'nullable|string|max:255',
'facebook_url' => 'nullable|url|max:255',
'twitter_handle' => 'nullable|string|max:255',
'youtube_url' => 'nullable|url|max:255',
]);
// Automatically add https:// to website_url if not present
if ($request->filled('website_url')) {
$url = $validated['website_url'];
if (! str_starts_with($url, 'http://') && ! str_starts_with($url, 'https://')) {
$validated['website_url'] = 'https://'.$url;
}
} else {
$validated['website_url'] = null;
}
// Update slug if name changed
if ($validated['name'] !== $brand->name) {
$validated['slug'] = Str::slug($validated['name']);
}
// Handle logo removal
if ($request->boolean('remove_logo') && $brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
$validated['logo_path'] = null;
}
// Handle logo upload
if ($request->hasFile('logo')) {
// Delete old logo
if ($brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
}
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
}
// Handle banner removal
if ($request->boolean('remove_banner') && $brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
$validated['banner_path'] = null;
}
// Handle banner upload
if ($request->hasFile('banner')) {
// Delete old banner
if ($brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
}
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
}
// Set boolean defaults
$validated['is_public'] = $request->boolean('is_public');
$validated['is_featured'] = $request->boolean('is_featured');
$validated['is_active'] = $request->boolean('is_active');
// Remove form-only fields
unset($validated['remove_logo'], $validated['remove_banner']);
// Update brand
$brand->update($validated);
return redirect()
->route('seller.business.brands.index', $business->slug)
->with('success', 'Brand updated successfully!');
}
/**
* Remove the specified brand from storage
*/
public function destroy(Business $business, Brand $brand)
{
// Ensure brand belongs to this business or parent company (if division)
$allowedBusinessIds = [$business->id];
if ($business->parent_id) {
$allowedBusinessIds[] = $business->parent_id;
}
if (! in_array($brand->business_id, $allowedBusinessIds)) {
abort(403, 'This brand does not belong to your business.');
}
// Check user has permission (only company-owner or company-manager can delete)
if (! auth()->user()->hasAnyRole(['company-owner', 'company-manager'])) {
abort(403, 'You do not have permission to delete brands.');
}
// Check if brand has any products with sales/orders
$hasProductsWithSales = $brand->products()
->whereHas('orderItems')
->exists();
if ($hasProductsWithSales) {
return redirect()
->route('seller.business.brands.index', $business->slug)
->with('error', 'Cannot delete brand - it has products with sales activity.');
}
// Delete logo and banner files
if ($brand->logo_path) {
Storage::disk('public')->delete($brand->logo_path);
}
if ($brand->banner_path) {
Storage::disk('public')->delete($brand->banner_path);
}
$brand->delete();
return redirect()
->route('seller.business.brands.index', $business->slug)
->with('success', 'Brand deleted successfully!');
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use Illuminate\Http\Request;
class BrandPreviewController extends Controller
{
/**
* Show brand menu preview for sellers
* This allows sellers to preview how buyers will see their brand menu
*
* @return \Illuminate\View\View
*/
public function preview(Request $request, Business $business, Brand $brand)
{
// Verify the brand belongs to the business (business isolation)
if ($brand->business_id !== $business->id) {
abort(404, 'Brand not found for this business');
}
// Load brand with business relationship
$brand->load('business');
// Get products organized by product line
$products = $brand->products()
->with(['strain', 'images', 'productLine'])
->where('is_active', true)
->orderBy('product_line_id')
->orderBy('name')
->get();
// Group products by product line
$productsByLine = $products->groupBy(function ($product) {
return $product->productLine ? $product->productLine->name : 'Other Products';
});
// Get other brands from same business
$otherBrands = $business
->brands()
->where('id', '!=', $brand->id)
->where('is_active', true)
->get();
// Mark this as seller view
$isSeller = true;
return view('seller.brands.preview', compact(
'business',
'brand',
'products',
'productsByLine',
'otherBrands',
'isSeller'
));
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\ComponentCategory;
use App\Models\ProductCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class CategoryController extends Controller
{
public function index(Business $business)
{
// Load product categories with nesting and counts (include parent if division)
$productCategories = ProductCategory::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->whereNull('parent_id')
->with(['children' => function ($query) {
$query->orderBy('sort_order')->orderBy('name');
}])
->withCount('products')
->orderBy('sort_order')
->orderBy('name')
->get();
// Load component categories with nesting and counts (include parent if division)
$componentCategories = ComponentCategory::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->whereNull('parent_id')
->with(['children' => function ($query) {
$query->orderBy('sort_order')->orderBy('name');
}])
->withCount('components')
->orderBy('sort_order')
->orderBy('name')
->get();
return view('seller.settings.categories.index', compact('business', 'productCategories', 'componentCategories'));
}
public function create(Business $business, string $type)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
// Get all categories of this type for parent selection (include parent if division)
$categories = $type === 'product'
? ProductCategory::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->whereNull('parent_id')
->with('children')
->orderBy('name')
->get()
: ComponentCategory::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->whereNull('parent_id')
->with('children')
->orderBy('name')
->get();
return view('seller.settings.categories.create', compact('business', 'type', 'categories'));
}
public function store(Request $request, Business $business, string $type)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$tableName = $type === 'product' ? 'product_categories' : 'component_categories';
$validated = $request->validate([
'name' => 'required|string|max:255',
'parent_id' => "nullable|exists:{$tableName},id",
'description' => 'nullable|string',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'boolean',
'image' => 'nullable|image|max:2048',
]);
$validated['business_id'] = $business->id;
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = $request->has('is_active') ? true : false;
// Handle image upload
if ($request->hasFile('image')) {
$validated['image_path'] = $request->file('image')->store('categories', 'public');
}
// Validate parent belongs to same business if provided
if (! empty($validated['parent_id'])) {
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$parent = $model::where('business_id', $business->id)->find($validated['parent_id']);
if (! $parent) {
return back()->withErrors(['parent_id' => 'Invalid parent category'])->withInput();
}
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
$model::create($validated);
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category created successfully');
}
public function edit(Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
// Allow accessing categories from parent company if division
$category = $model::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})->findOrFail($id);
// Get all categories of this type for parent selection (excluding self and descendants, include parent if division)
$categories = $model::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})
->whereNull('parent_id')
->where('id', '!=', $id)
->with('children')
->orderBy('name')
->get();
return view('seller.settings.categories.edit', compact('business', 'type', 'category', 'categories'));
}
public function update(Request $request, Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
// Allow accessing categories from parent company if division
$category = $model::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})->findOrFail($id);
$tableName = $type === 'product' ? 'product_categories' : 'component_categories';
$validated = $request->validate([
'name' => 'required|string|max:255',
'parent_id' => "nullable|exists:{$tableName},id",
'description' => 'nullable|string',
'sort_order' => 'nullable|integer|min:0',
'is_active' => 'boolean',
'image' => 'nullable|image|max:2048',
]);
$validated['slug'] = Str::slug($validated['name']);
$validated['is_active'] = $request->has('is_active') ? true : false;
// Handle image upload
if ($request->hasFile('image')) {
// Delete old image if exists
if ($category->image_path) {
\Storage::disk('public')->delete($category->image_path);
}
$validated['image_path'] = $request->file('image')->store('categories', 'public');
}
// Validate parent (can't be self or descendant)
if (! empty($validated['parent_id'])) {
if ($validated['parent_id'] == $id) {
return back()->withErrors(['parent_id' => 'Category cannot be its own parent'])->withInput();
}
$parent = $model::where('business_id', $business->id)->find($validated['parent_id']);
if (! $parent) {
return back()->withErrors(['parent_id' => 'Invalid parent category'])->withInput();
}
// Check for circular reference (if parent's parent is this category)
if ($parent->parent_id == $id) {
return back()->withErrors(['parent_id' => 'This would create a circular reference'])->withInput();
}
}
$category->update($validated);
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category updated successfully');
}
public function destroy(Business $business, string $type, int $id)
{
// Validate type
if (! in_array($type, ['product', 'component'])) {
abort(404);
}
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
// Allow accessing categories from parent company if division
$category = $model::where(function ($query) use ($business) {
$query->where('business_id', $business->id);
if ($business->parent_id) {
$query->orWhere('business_id', $business->parent_id);
}
})->findOrFail($id);
// Check if has products/components
if ($type === 'product') {
$count = $category->products()->count();
if ($count > 0) {
return back()->with('error', "Cannot delete category with {$count} products. Please reassign or delete products first.");
}
} else {
$count = $category->components()->count();
if ($count > 0) {
return back()->with('error', "Cannot delete category with {$count} components. Please reassign or delete components first.");
}
}
// Check if has children
$childCount = $category->children()->count();
if ($childCount > 0) {
return back()->with('error', "Cannot delete category with {$childCount} subcategories. Please delete or move subcategories first.");
}
// Delete image if exists
if ($category->image_path) {
\Storage::disk('public')->delete($category->image_path);
}
$category->delete();
return redirect()->route('seller.business.settings.categories.index', $business->slug)
->with('success', ucfirst($type).' category deleted successfully');
}
}

View File

@@ -0,0 +1,222 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Conversion;
use App\Models\Department;
use App\Models\WorkOrder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ConsolidatedAnalyticsController extends Controller
{
/**
* Analytics overview
*/
public function index(Business $business)
{
if (! $business->isParentCompany()) {
abort(403, 'Consolidated analytics only available for parent companies');
}
return view('seller.analytics.index', compact('business'));
}
/**
* Manufacturing analytics across all divisions
*/
public function manufacturing(Business $business, Request $request)
{
if (! $business->isParentCompany()) {
abort(403);
}
$divisionIds = $business->divisions->pluck('id');
// Date range filter
$startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d'));
$endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d'));
// Work Orders by Division
$workOrdersByDivision = $business->divisions->map(function ($division) use ($startDate, $endDate) {
return [
'division' => $division->division_name,
'total' => WorkOrder::where('business_id', $division->id)
->whereBetween('created_at', [$startDate, $endDate])
->count(),
'completed' => WorkOrder::where('business_id', $division->id)
->where('status', 'completed')
->whereBetween('completed_at', [$startDate, $endDate])
->count(),
'in_progress' => WorkOrder::where('business_id', $division->id)
->where('status', 'in_progress')
->count(),
'overdue' => WorkOrder::where('business_id', $division->id)
->overdue()
->count(),
];
});
// Wash Reports by Division
$washReportsByDivision = $business->divisions->map(function ($division) use ($startDate, $endDate) {
$completed = Conversion::where('business_id', $division->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('completed_at', [$startDate, $endDate])
->get();
return [
'division' => $division->division_name,
'total' => $completed->count(),
'total_input_weight' => $completed->sum('input_weight'),
'total_output_weight' => $completed->sum('output_weight'),
'average_yield' => $completed->avg('yield_percentage'),
];
});
// Department Performance
$departmentPerformance = Department::whereIn('business_id', $divisionIds)
->with('business')
->withCount(['workOrders as active_work_orders' => function ($q) {
$q->active();
}])
->withCount(['workOrders as completed_work_orders' => function ($q) use ($startDate, $endDate) {
$q->where('status', 'completed')
->whereBetween('completed_at', [$startDate, $endDate]);
}])
->get()
->map(function ($dept) {
return [
'division' => $dept->business->division_name ?? 'Unknown',
'department' => $dept->name,
'active_work_orders' => $dept->active_work_orders,
'completed_work_orders' => $dept->completed_work_orders,
];
});
// Work Order Completion Trend (last 30 days)
$completionTrend = WorkOrder::whereIn('business_id', $divisionIds)
->where('status', 'completed')
->whereBetween('completed_at', [now()->subDays(30), now()])
->select(DB::raw('DATE(completed_at) as date'), DB::raw('COUNT(*) as count'))
->groupBy('date')
->orderBy('date')
->get();
return view('seller.analytics.manufacturing', compact(
'business',
'workOrdersByDivision',
'washReportsByDivision',
'departmentPerformance',
'completionTrend',
'startDate',
'endDate'
));
}
/**
* Production analytics (detailed manufacturing metrics)
*/
public function production(Business $business, Request $request)
{
if (! $business->isParentCompany()) {
abort(403);
}
$divisionIds = $business->divisions->pluck('id');
// Date range
$startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d'));
$endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d'));
// Yield Analysis by Division
$yieldAnalysis = $business->divisions->map(function ($division) use ($startDate, $endDate) {
$washes = Conversion::where('business_id', $division->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('completed_at', [$startDate, $endDate])
->get();
return [
'division' => $division->division_name,
'total_washes' => $washes->count(),
'total_input_kg' => round($washes->sum('input_weight') / 1000, 2),
'total_output_kg' => round($washes->sum('output_weight') / 1000, 2),
'average_yield' => round($washes->avg('yield_percentage'), 2),
'best_yield' => round($washes->max('yield_percentage'), 2),
'worst_yield' => round($washes->min('yield_percentage'), 2),
];
});
// Top Strains (by output weight)
$topStrains = Conversion::whereIn('business_id', $divisionIds)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('completed_at', [$startDate, $endDate])
->whereNotNull('metadata->strain')
->select(DB::raw("metadata->>'strain' as strain"), DB::raw('SUM(output_weight) as total_output'))
->groupBy('strain')
->orderByDesc('total_output')
->limit(10)
->get();
// Equipment Utilization (if tracked in metadata)
$equipmentUtilization = Conversion::whereIn('business_id', $divisionIds)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereBetween('completed_at', [$startDate, $endDate])
->whereNotNull('metadata->washer')
->select(DB::raw("metadata->>'washer' as washer"), DB::raw('COUNT(*) as uses'))
->groupBy('washer')
->orderBy('washer')
->get();
return view('seller.analytics.production', compact(
'business',
'yieldAnalysis',
'topStrains',
'equipmentUtilization',
'startDate',
'endDate'
));
}
/**
* Department efficiency report
*/
public function departments(Business $business, Request $request)
{
if (! $business->isParentCompany()) {
abort(403);
}
$divisionIds = $business->divisions->pluck('id');
$departments = Department::whereIn('business_id', $divisionIds)
->with(['business', 'users'])
->withCount('workOrders')
->get()
->map(function ($dept) {
$activeWorkOrders = $dept->workOrders()->active()->count();
$completedThisMonth = $dept->workOrders()
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->count();
return [
'division' => $dept->business->division_name ?? 'Unknown',
'department' => $dept->name,
'code' => $dept->code,
'users_count' => $dept->users->count(),
'active_work_orders' => $activeWorkOrders,
'completed_this_month' => $completedThisMonth,
'total_work_orders' => $dept->work_orders_count,
'is_active' => $dept->is_active,
];
});
return view('seller.analytics.departments', compact('business', 'departments'));
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class CorporateSettingsController extends Controller
{
/**
* Corporate settings overview
*/
public function index(Business $business)
{
// Verify this is a parent company
if (! $business->isParentCompany()) {
abort(403, 'Corporate settings only available for parent companies');
}
return redirect()->route('seller.business.corporate.divisions', $business->slug);
}
/**
* Manage divisions
*/
public function divisions(Business $business)
{
if (! $business->isParentCompany()) {
abort(403);
}
$divisions = $business->divisions()->with('departments')->get();
return view('seller.corporate.divisions', compact('business', 'divisions'));
}
/**
* Show form to create a new division
*/
public function createDivision(Business $business)
{
if (! $business->isParentCompany()) {
abort(403);
}
return view('seller.corporate.create-division', compact('business'));
}
/**
* Store a new division
*/
public function storeDivision(Request $request, Business $business)
{
if (! $business->isParentCompany()) {
abort(403);
}
$validated = $request->validate([
'division_name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'business_type' => 'required|in:brand,retailer,distributor,cultivator,processor,testing_lab,both',
'override_billing' => 'boolean',
'override_legal_name' => 'nullable|string|max:255',
'override_address' => 'nullable|string|max:255',
'override_city' => 'nullable|string|max:255',
'override_state' => 'nullable|string|max:2',
'override_zip' => 'nullable|string|max:10',
'override_phone' => 'nullable|string|max:20',
'override_email' => 'nullable|email|max:255',
]);
// Generate slug from division name
$slug = Str::slug($validated['division_name']);
// Ensure unique slug
$originalSlug = $slug;
$counter = 1;
while (Business::where('slug', $slug)->exists()) {
$slug = $originalSlug.'-'.$counter;
$counter++;
}
$division = Business::create([
'parent_id' => $business->id,
'owner_user_id' => $business->owner_user_id,
'name' => $business->name, // Inherit parent legal name
'division_name' => $validated['division_name'],
'slug' => $slug,
'dba_name' => $validated['dba_name'] ?? $validated['division_name'],
'description' => $validated['description'],
'type' => $business->type,
'business_type' => $validated['business_type'],
'is_active' => true,
'status' => 'approved',
'approved_at' => now(),
'onboarding_completed' => true,
// Inherit or override settings
'override_billing' => $validated['override_billing'] ?? false,
'override_legal_name' => $validated['override_legal_name'],
'override_address' => $validated['override_address'],
'override_city' => $validated['override_city'],
'override_state' => $validated['override_state'],
'override_zip' => $validated['override_zip'],
'override_phone' => $validated['override_phone'],
'override_email' => $validated['override_email'],
// Inherit parent info
'physical_address' => $business->physical_address,
'physical_city' => $business->physical_city,
'physical_state' => $business->physical_state,
'physical_zipcode' => $business->physical_zipcode,
'business_phone' => $business->business_phone,
'business_email' => $business->business_email,
'license_number' => $business->license_number,
'tin_ein' => $business->tin_ein,
]);
return redirect()
->route('seller.business.corporate.divisions', $business->slug)
->with('success', 'Division created successfully! You can now add departments to it.');
}
/**
* Show form to edit a division
*/
public function editDivision(Business $business, Business $division)
{
if (! $business->isParentCompany() || $division->parent_id !== $business->id) {
abort(403);
}
return view('seller.corporate.edit-division', compact('business', 'division'));
}
/**
* Update a division
*/
public function updateDivision(Request $request, Business $business, Business $division)
{
if (! $business->isParentCompany() || $division->parent_id !== $business->id) {
abort(403);
}
$validated = $request->validate([
'division_name' => 'required|string|max:255',
'dba_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'business_type' => 'required|in:brand,retailer,distributor,cultivator,processor,testing_lab,both',
'is_active' => 'boolean',
'override_billing' => 'boolean',
'override_legal_name' => 'nullable|string|max:255',
'override_address' => 'nullable|string|max:255',
'override_city' => 'nullable|string|max:255',
'override_state' => 'nullable|string|max:2',
'override_zip' => 'nullable|string|max:10',
'override_phone' => 'nullable|string|max:20',
'override_email' => 'nullable|email|max:255',
]);
$division->update($validated);
return redirect()
->route('seller.business.corporate.divisions', $business->slug)
->with('success', 'Division updated successfully!');
}
/**
* Manage company-wide information (for all divisions)
*/
public function companyInformation(Business $business)
{
if (! $business->isParentCompany()) {
abort(403);
}
return view('seller.corporate.company-information', compact('business'));
}
/**
* Update company information
*/
public function updateCompanyInformation(Request $request, Business $business)
{
if (! $business->isParentCompany()) {
abort(403);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'physical_address' => 'required|string|max:255',
'physical_city' => 'required|string|max:255',
'physical_state' => 'required|string|max:2',
'physical_zipcode' => 'required|string|max:10',
'business_phone' => 'required|string|max:20',
'business_email' => 'required|email|max:255',
'tin_ein' => 'nullable|string|max:20',
'license_number' => 'nullable|string|max:255',
]);
$business->update($validated);
return redirect()
->route('seller.business.corporate.company-information', $business->slug)
->with('success', 'Company information updated successfully!');
}
/**
* Manage users across all divisions
*/
public function users(Business $business)
{
if (! $business->isParentCompany()) {
abort(403);
}
// Get all users associated with parent or any division
$divisionIds = $business->divisions->pluck('id')->push($business->id);
$users = User::whereHas('businesses', function ($q) use ($divisionIds) {
$q->whereIn('businesses.id', $divisionIds);
})->with(['businesses' => function ($q) use ($divisionIds) {
$q->whereIn('businesses.id', $divisionIds);
}, 'departments'])->get();
$divisions = $business->divisions;
return view('seller.corporate.users', compact('business', 'users', 'divisions'));
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index()
{
return view('seller.dashboard.index');
}
public function create()
{
return view('seller.dashboard.create');
}
public function store(Request $request)
{
// TODO: Implement store logic
return redirect()->route('seller.business.dashboard.index');
}
public function show($id)
{
return view('seller.dashboard.show');
}
public function edit($id)
{
return view('seller.dashboard.edit');
}
public function update(Request $request, $id)
{
// TODO: Implement update logic
return redirect()->route('seller.business.dashboard.index');
}
public function destroy($id)
{
// TODO: Implement destroy logic
return redirect()->route('seller.business.dashboard.index');
}
public function preview($id)
{
return view('seller.dashboard.preview');
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Services\FulfillmentService;
use App\Services\InvoiceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class DeliveryController extends Controller
{
public function __construct(
private FulfillmentService $fulfillmentService,
private InvoiceService $invoiceService
) {}
/**
* Show delivery confirmation form
*/
public function show(Request $request, Order $order): View
{
$business = $request->user()->businesses()->first();
// Ensure order belongs to seller's business
if ($order->seller_business_id !== $business->id) {
abort(403, 'Unauthorized access to order');
}
// Only out_for_delivery orders can be confirmed
if ($order->status !== 'out_for_delivery') {
abort(422, 'Order is not ready for delivery confirmation');
}
$order->load('items.product');
return view('seller.delivery.confirm', compact('order'));
}
/**
* Confirm delivery and record acceptance/rejection
*/
public function confirm(Request $request, Order $order): RedirectResponse
{
$business = $request->user()->businesses()->first();
// Business isolation: Ensure order belongs to seller's business
if ($order->seller_business_id !== $business->id) {
abort(403);
}
// Validate order status
if ($order->status !== 'out_for_delivery') {
return back()->withErrors(['status' => 'Order is not ready for delivery confirmation']);
}
$validated = $request->validate([
'items' => 'required|array',
'items.*.accepted_qty' => 'required|integer|min:0',
'items.*.rejected_qty' => 'required|integer|min:0',
'items.*.rejection_reason' => 'nullable|string',
]);
// Custom validation: accepted + rejected must equal ordered quantity
$order->load('items');
foreach ($validated['items'] as $itemId => $itemData) {
$orderItem = $order->items->firstWhere('id', $itemId);
if (! $orderItem) {
continue;
}
$totalQty = $itemData['accepted_qty'] + $itemData['rejected_qty'];
if ($totalQty !== $orderItem->quantity) {
return back()->withErrors([
"items.{$itemId}" => "Accepted and rejected quantities must equal ordered quantity ({$orderItem->quantity})",
]);
}
// Validate rejection reason is provided when items are rejected
if ($itemData['rejected_qty'] > 0 && empty($itemData['rejection_reason'])) {
return back()->withErrors([
"items.{$itemId}.rejection_reason" => 'Rejection reason is required when rejecting items',
]);
}
}
DB::transaction(function () use ($order, $validated) {
// Process delivery using service
$this->fulfillmentService->processDelivery($order, $validated);
// Create invoice if delivery was at least partially accepted
$order->refresh();
if ($order->status !== 'rejected') {
$this->invoiceService->createFromDelivery($order);
}
});
return redirect()
->route('seller.business.orders.show', [$business->slug, $order->order_number])
->with('success', 'Delivery confirmed successfully');
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\DeliveryWindow;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DeliveryWindowController extends Controller
{
/**
* List delivery windows for seller's business
*/
public function index(Request $request): View
{
$business = $request->user()->businesses()->first();
$windows = DeliveryWindow::where('business_id', $business->id)
->orderBy('day_of_week')
->orderBy('start_time')
->get();
return view('seller.delivery-windows.index', compact('windows'));
}
/**
* Store a new delivery window
*/
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'day_of_week' => 'required|integer|between:0,6',
'start_time' => 'required|date_format:H:i',
'end_time' => 'required|date_format:H:i|after:start_time',
'is_active' => 'boolean',
]);
$business = $request->user()->businesses()->first();
DeliveryWindow::create([
'business_id' => $business->id,
'day_of_week' => $validated['day_of_week'],
'start_time' => $validated['start_time'],
'end_time' => $validated['end_time'],
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()
->route('seller.delivery-windows.index')
->with('success', 'Delivery window created successfully');
}
/**
* Update delivery window
*/
public function update(Request $request, DeliveryWindow $deliveryWindow): RedirectResponse
{
$business = $request->user()->businesses()->first();
// Ensure window belongs to seller's business
if ($deliveryWindow->business_id !== $business->id) {
abort(403, 'Unauthorized access to delivery window');
}
$validated = $request->validate([
'day_of_week' => 'required|integer|between:0,6',
'start_time' => 'required|date_format:H:i',
'end_time' => 'required|date_format:H:i|after:start_time',
'is_active' => 'boolean',
]);
$deliveryWindow->update([
'day_of_week' => $validated['day_of_week'],
'start_time' => $validated['start_time'],
'end_time' => $validated['end_time'],
'is_active' => $validated['is_active'] ?? true,
]);
return redirect()
->route('seller.delivery-windows.index')
->with('success', 'Delivery window updated successfully');
}
/**
* Delete delivery window
*/
public function destroy(Request $request, DeliveryWindow $deliveryWindow): RedirectResponse
{
$business = $request->user()->businesses()->first();
if ($deliveryWindow->business_id !== $business->id) {
abort(403);
}
$deliveryWindow->delete();
return redirect()
->route('seller.delivery-windows.index')
->with('success', 'Delivery window deleted successfully');
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Conversion;
use App\Models\Department;
use App\Models\Order;
use App\Models\WorkOrder;
use Illuminate\Support\Facades\DB;
class ExecutiveDashboardController extends Controller
{
/**
* Display executive dashboard for parent company
* Shows consolidated metrics across all divisions
*/
public function index(Business $business)
{
// Verify this is a parent company
if (! $business->isParentCompany()) {
return redirect()
->route('seller.business.dashboard', $business->slug)
->with('error', 'Executive dashboard is only available for parent companies');
}
// Get all divisions
$divisions = $business->divisions()->with('departments')->get();
$divisionIds = $divisions->pluck('id');
// Consolidated Work Order Metrics
$workOrderStats = [
'total' => WorkOrder::whereIn('business_id', $divisionIds)->count(),
'pending' => WorkOrder::whereIn('business_id', $divisionIds)->where('status', 'pending')->count(),
'in_progress' => WorkOrder::whereIn('business_id', $divisionIds)->where('status', 'in_progress')->count(),
'completed_this_month' => WorkOrder::whereIn('business_id', $divisionIds)
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->count(),
'overdue' => WorkOrder::whereIn('business_id', $divisionIds)->overdue()->count(),
];
// Division Performance Summary
$divisionPerformance = $divisions->map(function ($division) {
return [
'id' => $division->id,
'name' => $division->division_name,
'slug' => $division->slug,
'departments_count' => $division->departments->count(),
'work_orders_active' => WorkOrder::where('business_id', $division->id)
->active()
->count(),
'work_orders_completed_month' => WorkOrder::where('business_id', $division->id)
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->count(),
'wash_reports_completed_month' => Conversion::where('business_id', $division->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->count(),
];
});
// Recent Activity Across All Divisions
$recentWorkOrders = WorkOrder::whereIn('business_id', $divisionIds)
->with(['business', 'department', 'assignedTo'])
->latest()
->take(10)
->get();
// Department Summary Across All Divisions
$departmentStats = Department::whereIn('business_id', $divisionIds)
->select('business_id', DB::raw('count(*) as count'))
->groupBy('business_id')
->get();
// Total Departments
$totalDepartments = Department::whereIn('business_id', $divisionIds)->count();
// Active Departments
$activeDepartments = Department::whereIn('business_id', $divisionIds)
->where('is_active', true)
->count();
// Manufacturing Metrics (Wash Reports)
$manufacturingStats = [
'total_washes_month' => Conversion::whereIn('business_id', $divisionIds)
->where('conversion_type', 'hash_wash')
->whereMonth('created_at', now()->month)
->count(),
'completed_washes_month' => Conversion::whereIn('business_id', $divisionIds)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->count(),
'active_washes' => Conversion::whereIn('business_id', $divisionIds)
->where('conversion_type', 'hash_wash')
->where('status', 'in_progress')
->count(),
'average_yield' => Conversion::whereIn('business_id', $divisionIds)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->avg('yield_percentage'),
];
// Order Metrics (if applicable)
$orderStats = [
'total_orders_month' => Order::whereHas('items.product.brand', function ($q) use ($divisionIds) {
$q->whereIn('business_id', $divisionIds);
})->whereMonth('created_at', now()->month)->count(),
'pending_orders' => Order::whereHas('items.product.brand', function ($q) use ($divisionIds) {
$q->whereIn('business_id', $divisionIds);
})->where('status', 'pending')->count(),
];
return view('seller.executive.dashboard', compact(
'business',
'divisions',
'divisionPerformance',
'workOrderStats',
'recentWorkOrders',
'totalDepartments',
'activeDepartments',
'manufacturingStats',
'orderStats'
));
}
/**
* Compare divisions side-by-side
*/
public function compareDivisions(Business $business)
{
// Verify this is a parent company
if (! $business->isParentCompany()) {
abort(403);
}
$divisions = $business->divisions;
// Build comparison data
$comparison = $divisions->map(function ($division) {
return [
'division' => $division,
'work_orders' => [
'total' => WorkOrder::where('business_id', $division->id)->count(),
'completed_this_month' => WorkOrder::where('business_id', $division->id)
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->count(),
'average_completion_time' => $this->getAverageCompletionTime($division->id),
],
'departments' => [
'total' => $division->departments->count(),
'active' => $division->departments->where('is_active', true)->count(),
],
'manufacturing' => [
'washes_completed' => Conversion::where('business_id', $division->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->count(),
'average_yield' => Conversion::where('business_id', $division->id)
->where('conversion_type', 'hash_wash')
->where('status', 'completed')
->whereMonth('completed_at', now()->month)
->avg('yield_percentage'),
],
];
});
return view('seller.executive.compare-divisions', compact('business', 'comparison'));
}
/**
* Get average work order completion time in hours
*/
private function getAverageCompletionTime(int $businessId): ?float
{
$completedOrders = WorkOrder::where('business_id', $businessId)
->where('status', 'completed')
->whereNotNull('started_at')
->whereNotNull('completed_at')
->whereMonth('completed_at', now()->month)
->get();
if ($completedOrders->isEmpty()) {
return null;
}
$totalHours = $completedOrders->sum(function ($order) {
return $order->started_at->diffInHours($order->completed_at);
});
return round($totalHours / $completedOrders->count(), 1);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Seller\Fleet;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class DriverController extends Controller
{
public function index()
{
return view('seller.fleet.driver.index');
}
public function create()
{
return view('seller.fleet.driver.create');
}
public function store(Request $request)
{
// TODO: Implement store logic
return redirect()->route('seller.business.fleet.driver.index');
}
public function show($id)
{
return view('seller.fleet.driver.show');
}
public function edit($id)
{
return view('seller.fleet.driver.edit');
}
public function update(Request $request, $id)
{
// TODO: Implement update logic
return redirect()->route('seller.business.fleet.driver.index');
}
public function destroy($id)
{
// TODO: Implement destroy logic
return redirect()->route('seller.business.fleet.driver.index');
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Seller\Fleet;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class VehicleController extends Controller
{
public function index()
{
return view('seller.fleet.vehicle.index');
}
public function create()
{
return view('seller.fleet.vehicle.create');
}
public function store(Request $request)
{
// TODO: Implement store logic
return redirect()->route('seller.business.fleet.vehicle.index');
}
public function show($id)
{
return view('seller.fleet.vehicle.show');
}
public function edit($id)
{
return view('seller.fleet.vehicle.edit');
}
public function update(Request $request, $id)
{
// TODO: Implement update logic
return redirect()->route('seller.business.fleet.vehicle.index');
}
public function destroy($id)
{
// TODO: Implement destroy logic
return redirect()->route('seller.business.fleet.vehicle.index');
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\FulfillmentWorkOrder;
use App\Services\FulfillmentWorkOrderService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FulfillmentWorkOrderController extends Controller
{
public function __construct(
private FulfillmentWorkOrderService $workOrderService
) {
//
}
/**
* List work orders for seller's business
*/
public function index(Request $request): View
{
$business = $request->user()->businesses()->first();
$workOrders = FulfillmentWorkOrder::whereHas('order', function ($query) use ($business) {
$query->where('seller_business_id', $business->id);
})
->with(['order', 'pickingTickets'])
->orderBy('created_at', 'desc')
->paginate(20);
return view('seller.work-orders.index', compact('workOrders'));
}
/**
* Show work order details with picking tickets
*/
public function show(Request $request, FulfillmentWorkOrder $workOrder): View
{
$business = $request->user()->businesses()->first();
// Ensure work order belongs to seller's business
if ($workOrder->order->seller_business_id !== $business->id) {
abort(403, 'Unauthorized access to work order');
}
$workOrder->load(['order.items.product', 'pickingTickets.department', 'pickingTickets.items']);
return view('seller.work-orders.show', compact('workOrder', 'business'));
}
/**
* Assign picker to a picking ticket
*/
public function assignPicker(Request $request, FulfillmentWorkOrder $workOrder)
{
$validated = $request->validate([
'ticket_id' => 'required|exists:picking_tickets,id',
'picker_id' => 'required|exists:users,id',
]);
$business = $request->user()->businesses()->first();
if ($workOrder->order->seller_business_id !== $business->id) {
abort(403);
}
$ticket = $workOrder->pickingTickets()->findOrFail($validated['ticket_id']);
$picker = \App\Models\User::findOrFail($validated['picker_id']);
$this->workOrderService->assignPicker($ticket, $picker);
return redirect()
->route('seller.work-orders.show', $workOrder)
->with('success', 'Picker assigned successfully');
}
/**
* Start picking the order
*/
public function startPicking(Request $request, FulfillmentWorkOrder $workOrder)
{
$business = $request->user()->businesses()->first();
// Verify authorization
if ($workOrder->order->seller_business_id !== $business->id) {
abort(403, 'Unauthorized access to work order');
}
try {
$workOrder->order->startPicking();
return redirect()
->route('seller.business.orders.show', [$business->slug, $workOrder->order])
->with('success', 'Order started! You can now begin picking items.');
} catch (\Exception $e) {
return redirect()
->route('seller.business.orders.show', [$business->slug, $workOrder->order])
->with('error', 'Could not start order: '.$e->getMessage());
}
}
}

View File

@@ -273,90 +273,4 @@ class InvoiceController extends Controller
'contacts' => $contacts,
]);
}
/**
* Update invoice line items (seller modifications).
*/
public function update(Business $business, Invoice $invoice, Request $request)
{
// Verify invoice belongs to this business through order items
$invoice->load(['order.items.product.brand']);
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
// Verify invoice is in a state that allows seller modifications
if (! $invoice->isPendingBuyerApproval() || $invoice->isBuyerModified()) {
return response()->json([
'success' => false,
'message' => 'Invoice cannot be modified in its current state',
], 422);
}
// Validate request
$validated = $request->validate([
'modifications' => 'required|array',
'modifications.*.picked_qty' => 'required|integer|min:0',
]);
// Apply modifications using the service
$modificationService = app(\App\Services\OrderModificationService::class);
try {
$modificationService->applySellerModifications(
$invoice,
$validated['modifications'],
auth()->user()
);
// Regenerate PDF with updated quantities
$invoiceService = app(InvoiceService::class);
$invoiceService->regeneratePdf($invoice);
return response()->json([
'success' => true,
'message' => 'Invoice updated successfully',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Failed to update invoice: '.$e->getMessage(),
], 500);
}
}
/**
* Finalize and send a manual invoice to the buyer after picking is complete.
*/
public function finalize(Business $business, Invoice $invoice, InvoiceService $invoiceService)
{
// Verify invoice belongs to this business through order items
$invoice->load(['order.items.product.brand', 'order']);
// Check if any of the order's items belong to brands owned by this business
$belongsToBusiness = $invoice->order->items->some(function ($item) use ($business) {
return $item->product && $item->product->belongsToBusiness($business);
});
if (! $belongsToBusiness) {
abort(403, 'This invoice does not belong to your business');
}
try {
$invoiceService->finalizeAndSend($invoice);
return redirect()
->route('seller.business.invoices.show', [$business->slug, $invoice->invoice_number])
->with('success', 'Invoice finalized and sent to buyer for approval!');
} catch (\Exception $e) {
return back()
->with('error', 'Failed to finalize invoice: '.$e->getMessage());
}
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Lab;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class LabController extends Controller
{
/**
* Display a listing of lab tests for the business
*/
public function index(Request $request, Business $business)
{
// Get products that belong to brands owned by this business
$productIds = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->pluck('id');
// Build query for labs
$query = Lab::whereIn('product_id', $productIds)
->with(['product.brand', 'brand', 'coaFiles'])
->orderBy('test_date', 'desc');
// Search filter
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('test_id', 'LIKE', "%{$search}%")
->orWhere('batch_number', 'LIKE', "%{$search}%")
->orWhere('lot_number', 'LIKE', "%{$search}%")
->orWhereHas('product', function ($productQuery) use ($search) {
$productQuery->where('name', 'LIKE', "%{$search}%");
});
});
}
$labs = $query->paginate(20)->withQueryString();
return view('seller.labs.index', compact('business', 'labs'));
}
/**
* Show the form for creating a new lab test
*/
public function create(Request $request, Business $business)
{
// Get products owned by brands of this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
return view('seller.labs.create', compact('business', 'products'));
}
/**
* Store a newly created lab test
*/
public function store(Request $request, Business $business)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'test_id' => 'nullable|string|max:100|unique:labs,test_id',
'batch_number' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'test_date' => 'required|date',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => 'nullable|numeric|min:0|max:100',
'thca_percentage' => 'nullable|numeric|min:0|max:100',
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
'cbda_percentage' => 'nullable|numeric|min:0|max:100',
'delta_9_percentage' => 'nullable|numeric|min:0|max:100',
'total_terps_percentage' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Verify product belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Auto-set brand_id from the selected product
$validated['brand_id'] = $product->brand_id;
// Create lab test
$lab = Lab::create($validated);
// Handle COA file uploads
if ($request->hasFile('coa_files')) {
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/labs/{$lab->id}";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$lab->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'is_primary' => $index === 0,
'display_order' => $index,
]);
}
}
return redirect()
->route('seller.business.manufacturing.labs.index', $business->slug)
->with('success', 'Lab test created successfully.');
}
/**
* Show the form for editing the specified lab test
*/
public function edit(Request $request, Business $business, Lab $lab)
{
// Verify lab belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($lab->product_id);
// Get products owned by brands of this business
$products = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->orderBy('name', 'asc')->get();
$lab->load('coaFiles');
return view('seller.labs.edit', compact('business', 'lab', 'products'));
}
/**
* Update the specified lab test
*/
public function update(Request $request, Business $business, Lab $lab)
{
// Verify lab belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($lab->product_id);
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'test_id' => 'nullable|string|max:100|unique:labs,test_id,'.$lab->id,
'batch_number' => 'nullable|string|max:100',
'lot_number' => 'nullable|string|max:100',
'test_date' => 'required|date',
'lab_name' => 'nullable|string|max:255',
'thc_percentage' => 'nullable|numeric|min:0|max:100',
'thca_percentage' => 'nullable|numeric|min:0|max:100',
'cbd_percentage' => 'nullable|numeric|min:0|max:100',
'cbda_percentage' => 'nullable|numeric|min:0|max:100',
'delta_9_percentage' => 'nullable|numeric|min:0|max:100',
'total_terps_percentage' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
]);
// Get the updated product to auto-set brand_id
$updatedProduct = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($validated['product_id']);
// Auto-set brand_id from the selected product
$validated['brand_id'] = $updatedProduct->brand_id;
// Update lab test
$lab->update($validated);
// Handle new COA file uploads
if ($request->hasFile('coa_files')) {
$existingFilesCount = $lab->coaFiles()->count();
foreach ($request->file('coa_files') as $index => $file) {
$storagePath = "businesses/{$business->uuid}/labs/{$lab->id}";
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
$filePath = $file->storeAs($storagePath, $fileName, 'public');
$lab->coaFiles()->create([
'file_name' => $file->getClientOriginalName(),
'file_path' => $filePath,
'is_primary' => $existingFilesCount === 0 && $index === 0,
'display_order' => $existingFilesCount + $index,
]);
}
}
return redirect()
->route('seller.business.manufacturing.labs.index', $business->slug)
->with('success', 'Lab test updated successfully.');
}
/**
* Remove the specified lab test
*/
public function destroy(Request $request, Business $business, Lab $lab)
{
// Verify lab belongs to this business
$product = Product::whereHas('brand', function ($query) use ($business) {
$query->where('business_id', $business->id);
})->findOrFail($lab->product_id);
// Delete associated COA files from storage
foreach ($lab->coaFiles as $coaFile) {
if (Storage::disk('public')->exists($coaFile->file_path)) {
Storage::disk('public')->delete($coaFile->file_path);
}
}
$lab->delete();
return redirect()
->route('seller.business.manufacturing.labs.index', $business->slug)
->with('success', 'Lab test deleted successfully.');
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Seller\Marketing\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\AnalyticsEvent;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AnalyticsDashboardController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.overview')) {
abort(403, 'Unauthorized to view analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30'); // days
$startDate = now()->subDays((int) $period);
// Key metrics
$metrics = [
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
'total_page_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->sum('page_views'),
'total_product_views' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->count(),
'unique_products_viewed' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->distinct('product_id')
->count('product_id'),
'high_intent_signals' => IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
->where('signal_strength', '>=', IntentSignal::STRENGTH_HIGH)
->count(),
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('last_interaction_at', '>=', $startDate)->count(),
];
// Traffic trend (daily breakdown)
$trafficTrend = AnalyticsEvent::forBusiness($business->id)->where('created_at', '>=', $startDate)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as total_events'),
DB::raw('COUNT(DISTINCT session_id) as unique_sessions')
)
->groupBy('date')
->orderBy('date')
->get();
// Top products by views
$topProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select('product_id', DB::raw('COUNT(*) as view_count'))
->groupBy('product_id')
->orderByDesc('view_count')
->limit(10)
->with('product')
->get();
// High-value buyers
$highValueBuyers = BuyerEngagementScore::forBusiness($business->id)->highValue()
->active()
->orderByDesc('score')
->limit(10)
->with('buyerBusiness')
->get();
// Recent high-intent signals
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
->where('detected_at', '>=', now()->subHours(24))
->orderByDesc('detected_at')
->limit(10)
->with(['buyerBusiness', 'user'])
->get();
// Engagement score distribution
$engagementDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
DB::raw('CASE
WHEN score >= 80 THEN \'Very High\'
WHEN score >= 60 THEN \'High\'
WHEN score >= 40 THEN \'Medium\'
ELSE \'Low\'
END as score_range'),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
return view('seller.marketing.analytics.dashboard', compact(
'business',
'period',
'metrics',
'trafficTrend',
'topProducts',
'highValueBuyers',
'recentIntentSignals',
'engagementDistribution'
));
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\Seller\Marketing\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\BuyerEngagementScore;
use App\Models\Analytics\IntentSignal;
use App\Models\Analytics\ProductView;
use App\Models\Business;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BuyerIntelligenceController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.buyers')) {
abort(403, 'Unauthorized to view buyer intelligence');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$filter = $request->input('filter', 'all'); // all, high-value, at-risk, new
$startDate = now()->subDays((int) $period);
// Overall buyer metrics
$metrics = [
'total_buyers' => BuyerEngagementScore::forBusiness($business->id)->count(),
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->active()->count(),
'high_value_buyers' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
'at_risk_buyers' => BuyerEngagementScore::forBusiness($business->id)->atRisk()->count(),
'new_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('first_interaction_at', '>=', now()->subDays(30))->count(),
];
// Build query based on filter
$buyersQuery = BuyerEngagementScore::forBusiness($business->id);
match ($filter) {
'high-value' => $buyersQuery->highValue(),
'at-risk' => $buyersQuery->atRisk(),
'new' => $buyersQuery->where('first_interaction_at', '>=', now()->subDays(30)),
default => $buyersQuery,
};
$buyers = $buyersQuery->orderByDesc('score')
->with('buyerBusiness')
->paginate(20);
// Engagement score distribution
$scoreDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
DB::raw("CASE
WHEN score >= 80 THEN 'Very High (80-100)'
WHEN score >= 60 THEN 'High (60-79)'
WHEN score >= 40 THEN 'Medium (40-59)'
WHEN score >= 20 THEN 'Low (20-39)'
ELSE 'Very Low (0-19)'
END as score_range"),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
// Tier distribution
$tierDistribution = BuyerEngagementScore::forBusiness($business->id)->select('score_tier')
->selectRaw('COUNT(*) as count')
->groupBy('score_tier')
->get();
// Recent high-intent signals
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
->where('detected_at', '>=', now()->subDays(7))
->orderByDesc('detected_at')
->with(['buyerBusiness', 'user'])
->limit(20)
->get();
// Intent signal breakdown
$signalBreakdown = IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
->select('signal_type')
->selectRaw('COUNT(*) as count')
->selectRaw('AVG(signal_strength) as avg_strength')
->groupBy('signal_type')
->orderByDesc('count')
->get();
return view('seller.marketing.analytics.buyers', compact(
'business',
'period',
'filter',
'metrics',
'buyers',
'scoreDistribution',
'tierDistribution',
'recentIntentSignals',
'signalBreakdown'
));
}
public function show(Request $request, Business $buyer)
{
if (! hasBusinessPermission('analytics.buyers')) {
abort(403, 'Unauthorized to view buyer intelligence');
}
$business = currentBusiness();
$period = $request->input('period', '90'); // Default to 90 days for buyer detail
$startDate = now()->subDays((int) $period);
// Get engagement score
$engagementScore = BuyerEngagementScore::forBusiness($business->id)->where('buyer_business_id', $buyer->id)->first();
// Activity timeline
$activityTimeline = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as product_views'),
DB::raw('COUNT(DISTINCT product_id) as unique_products'),
DB::raw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
)
->groupBy('date')
->orderBy('date')
->get();
// Products viewed
$productsViewed = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as view_count')
->selectRaw('MAX(viewed_at) as last_viewed')
->selectRaw('AVG(time_on_page) as avg_time')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
->groupBy('product_id')
->orderByDesc('view_count')
->with('product')
->limit(20)
->get();
// Intent signals
$intentSignals = IntentSignal::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
->where('detected_at', '>=', $startDate)
->orderByDesc('detected_at')
->limit(50)
->get();
// Email engagement
$emailEngagement = DB::table('email_interactions')
->join('users', 'email_interactions.recipient_user_id', '=', 'users.id')
->join('business_user', 'users.id', '=', 'business_user.user_id')
->where('email_interactions.business_id', $business->id)
->where('business_user.business_id', $buyer->id)
->where('email_interactions.sent_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_sent')
->selectRaw('SUM(open_count) as total_opens')
->selectRaw('SUM(click_count) as total_clicks')
->selectRaw('AVG(engagement_score) as avg_engagement')
->first();
// Order history
$orderHistory = DB::table('orders')
->where('seller_business_id', $business->id)
->where('buyer_business_id', $buyer->id)
->select(
DB::raw('DATE(created_at) as date'),
DB::raw('COUNT(*) as order_count'),
DB::raw('SUM(total) as revenue')
)
->groupBy('date')
->orderBy('date')
->get();
$totalOrders = DB::table('orders')
->where('seller_business_id', $business->id)
->where('buyer_business_id', $buyer->id)
->selectRaw('COUNT(*) as count')
->selectRaw('SUM(total) as total_revenue')
->selectRaw('AVG(total) as avg_order_value')
->first();
return view('seller.marketing.analytics.buyer-detail', compact(
'buyer',
'period',
'engagementScore',
'activityTimeline',
'productsViewed',
'intentSignals',
'emailEngagement',
'orderHistory',
'totalOrders'
));
}
}

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Seller\Marketing\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\EmailCampaign;
use App\Models\Analytics\EmailInteraction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MarketingAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.marketing')) {
abort(403, 'Unauthorized to view marketing analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Campaign overview metrics
$metrics = [
'total_campaigns' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->count(),
'total_sent' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_sent'),
'total_delivered' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_delivered'),
'total_opened' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_opened'),
'total_clicked' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_clicked'),
];
// Calculate average rates
$metrics['avg_open_rate'] = $metrics['total_delivered'] > 0
? round(($metrics['total_opened'] / $metrics['total_delivered']) * 100, 2)
: 0;
$metrics['avg_click_rate'] = $metrics['total_delivered'] > 0
? round(($metrics['total_clicked'] / $metrics['total_delivered']) * 100, 2)
: 0;
// Campaign performance
$campaigns = EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)
->orderByDesc('sent_at')
->with('emailInteractions')
->paginate(20);
// Email engagement over time
$engagementTrend = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->select(
DB::raw('DATE(sent_at) as date'),
DB::raw('COUNT(*) as sent'),
DB::raw('SUM(CASE WHEN first_opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened'),
DB::raw('SUM(CASE WHEN first_clicked_at IS NOT NULL THEN 1 ELSE 0 END) as clicked')
)
->groupBy('date')
->orderBy('date')
->get();
// Top performing campaigns
$topCampaigns = EmailCampaign::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->where('total_sent', '>', 0)
->orderByRaw('(total_clicked / total_sent) DESC')
->limit(10)
->get();
// Email client breakdown
$emailClients = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->whereNotNull('email_client')
->select('email_client')
->selectRaw('COUNT(*) as count')
->groupBy('email_client')
->orderByDesc('count')
->get();
// Device type breakdown
$deviceTypes = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->whereNotNull('device_type')
->select('device_type')
->selectRaw('COUNT(*) as count')
->groupBy('device_type')
->orderByDesc('count')
->get();
// Engagement score distribution
$engagementScores = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
->select(
DB::raw("CASE
WHEN engagement_score >= 80 THEN 'High'
WHEN engagement_score >= 50 THEN 'Medium'
WHEN engagement_score > 0 THEN 'Low'
ELSE 'None'
END as score_range"),
DB::raw('COUNT(*) as count')
)
->groupBy('score_range')
->get();
return view('seller.marketing.analytics.marketing', compact(
'business',
'period',
'metrics',
'campaigns',
'engagementTrend',
'topCampaigns',
'emailClients',
'deviceTypes',
'engagementScores'
));
}
public function campaign(Request $request, EmailCampaign $campaign)
{
if (! hasBusinessPermission('analytics.marketing')) {
abort(403, 'Unauthorized to view marketing analytics');
}
// Verify campaign belongs to user's business
if ($campaign->business_id !== currentBusinessId()) {
abort(403, 'Unauthorized to view this campaign');
}
// Campaign metrics
$metrics = [
'total_sent' => $campaign->total_sent,
'total_delivered' => $campaign->total_delivered,
'total_bounced' => $campaign->total_bounced,
'total_opened' => $campaign->total_opened,
'total_clicked' => $campaign->total_clicked,
'open_rate' => $campaign->open_rate,
'click_rate' => $campaign->click_rate,
'bounce_rate' => $campaign->total_sent > 0
? round(($campaign->total_bounced / $campaign->total_sent) * 100, 2)
: 0,
];
// Interaction timeline
$timeline = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
->select(
DB::raw('DATE(sent_at) as date'),
DB::raw('SUM(open_count) as opens'),
DB::raw('SUM(click_count) as clicks')
)
->groupBy('date')
->orderBy('date')
->get();
// Top engaged recipients
$topRecipients = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
->orderByDesc('engagement_score')
->limit(20)
->with('recipientUser')
->get();
// Click breakdown by URL
$clicksByUrl = DB::table('email_clicks')
->join('email_interactions', 'email_clicks.email_interaction_id', '=', 'email_interactions.id')
->where('email_interactions.campaign_id', $campaign->id)
->select('email_clicks.url', 'email_clicks.link_identifier')
->selectRaw('COUNT(*) as click_count')
->selectRaw('COUNT(DISTINCT email_clicks.email_interaction_id) as unique_clicks')
->groupBy('email_clicks.url', 'email_clicks.link_identifier')
->orderByDesc('click_count')
->get();
return view('seller.marketing.analytics.campaign-detail', compact(
'campaign',
'metrics',
'timeline',
'topRecipients',
'clicksByUrl'
));
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Http\Controllers\Seller\Marketing\Analytics;
use App\Helpers\BusinessHelper;
use App\Http\Controllers\Controller;
use App\Models\Analytics\ProductView;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ProductAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.products')) {
abort(403, 'Unauthorized to view product analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Product performance metrics
$productMetrics = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as total_views')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->selectRaw('AVG(time_on_page) as avg_time_on_page')
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
->groupBy('product_id')
->orderByDesc('total_views')
->with('product.brand')
->paginate(20);
// Product view trend
$viewTrend = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as views'),
DB::raw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
)
->groupBy('date')
->orderBy('date')
->get();
// High engagement products (quality over quantity)
$highEngagementProducts = ProductView::forBusiness($business->id)->highEngagement()
->where('viewed_at', '>=', $startDate)
->select('product_id')
->selectRaw('COUNT(*) as engagement_count')
->selectRaw('AVG(time_on_page) as avg_time')
->groupBy('product_id')
->orderByDesc('engagement_count')
->limit(10)
->with('product')
->get();
// Products with most cart additions (high intent)
$topCartProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
->where('added_to_cart', true)
->select('product_id')
->selectRaw('COUNT(*) as cart_count')
->groupBy('product_id')
->orderByDesc('cart_count')
->limit(10)
->with('product')
->get();
// Engagement breakdown
$engagementBreakdown = [
'zoomed_image' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('zoomed_image', true)->count(),
'watched_video' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('watched_video', true)->count(),
'downloaded_spec' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('downloaded_spec', true)->count(),
'added_to_cart' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_cart', true)->count(),
'added_to_wishlist' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_wishlist', true)->count(),
];
return view('seller.marketing.analytics.products', compact(
'business',
'period',
'productMetrics',
'viewTrend',
'highEngagementProducts',
'topCartProducts',
'engagementBreakdown'
));
}
public function show(Request $request, Product $product)
{
if (! hasBusinessPermission('analytics.products')) {
abort(403, 'Unauthorized to view product analytics');
}
// Verify product belongs to user's business brands
$sellerBusiness = BusinessHelper::fromProduct($product);
if ($sellerBusiness->id !== currentBusinessId()) {
abort(403, 'Unauthorized to view this product');
}
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Product-specific metrics
$metrics = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->selectRaw('COUNT(*) as total_views')
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
->selectRaw('COUNT(DISTINCT session_id) as unique_sessions')
->selectRaw('AVG(time_on_page) as avg_time_on_page')
->selectRaw('MAX(time_on_page) as max_time_on_page')
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
->first();
// View trend
$viewTrend = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->select(
DB::raw('DATE(viewed_at) as date'),
DB::raw('COUNT(*) as views')
)
->groupBy('date')
->orderBy('date')
->get();
// Top buyers viewing this product
$topBuyers = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->whereNotNull('buyer_business_id')
->select('buyer_business_id')
->selectRaw('COUNT(*) as view_count')
->selectRaw('MAX(viewed_at) as last_viewed')
->groupBy('buyer_business_id')
->orderByDesc('view_count')
->limit(10)
->with('buyerBusiness')
->get();
// Traffic sources
$trafficSources = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
->where('viewed_at', '>=', $startDate)
->select('source')
->selectRaw('COUNT(*) as count')
->groupBy('source')
->orderByDesc('count')
->get();
return view('seller.marketing.analytics.product-detail', compact(
'product',
'period',
'metrics',
'viewTrend',
'topBuyers',
'trafficSources'
));
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Http\Controllers\Seller\Marketing\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Analytics\UserSession;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SalesAnalyticsController extends Controller
{
public function index(Request $request)
{
if (! hasBusinessPermission('analytics.sales')) {
abort(403, 'Unauthorized to view sales analytics');
}
$business = currentBusiness();
$period = $request->input('period', '30');
$startDate = now()->subDays((int) $period);
// Sales funnel metrics
$funnelMetrics = [
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
'sessions_with_product_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('product_views', '>', 0)
->count(),
'sessions_with_cart' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->count(),
'checkout_initiated' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 2)
->count(),
'orders_completed' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('converted', true)
->count(),
];
// Calculate conversion rates
$funnelMetrics['product_view_rate'] = $funnelMetrics['total_sessions'] > 0
? round(($funnelMetrics['sessions_with_product_views'] / $funnelMetrics['total_sessions']) * 100, 2)
: 0;
$funnelMetrics['cart_rate'] = $funnelMetrics['sessions_with_product_views'] > 0
? round(($funnelMetrics['sessions_with_cart'] / $funnelMetrics['sessions_with_product_views']) * 100, 2)
: 0;
$funnelMetrics['checkout_rate'] = $funnelMetrics['sessions_with_cart'] > 0
? round(($funnelMetrics['checkout_initiated'] / $funnelMetrics['sessions_with_cart']) * 100, 2)
: 0;
$funnelMetrics['conversion_rate'] = $funnelMetrics['checkout_initiated'] > 0
? round(($funnelMetrics['orders_completed'] / $funnelMetrics['checkout_initiated']) * 100, 2)
: 0;
// Sales metrics from orders table
// Note: orders.business_id is the buyer's business
// To get seller's orders, join through order_items → products → brands
$salesMetrics = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->selectRaw('COUNT(DISTINCT orders.id) as total_orders')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('AVG(orders.total) as avg_order_value')
->selectRaw('COUNT(DISTINCT orders.business_id) as unique_buyers')
->first();
// Revenue trend
$revenueTrend = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select(
DB::raw('DATE(orders.created_at) as date'),
DB::raw('COUNT(DISTINCT orders.id) as orders'),
DB::raw('SUM(order_items.line_total) as revenue')
)
->groupBy('date')
->orderBy('date')
->get();
// Conversion funnel trend
$conversionTrend = UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->select(
DB::raw('DATE(started_at) as date'),
DB::raw('COUNT(*) as sessions'),
DB::raw('SUM(CASE WHEN product_views > 0 THEN 1 ELSE 0 END) as with_views'),
DB::raw('SUM(CASE WHEN interactions > 0 THEN 1 ELSE 0 END) as with_interactions'),
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as conversions'),
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as orders')
)
->groupBy('date')
->orderBy('date')
->get();
// Top revenue products
$topProducts = DB::table('order_items')
->join('orders', 'order_items.order_id', '=', 'orders.id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select('products.id', 'products.name')
->selectRaw('SUM(order_items.quantity) as units_sold')
->selectRaw('SUM(order_items.line_total) as revenue')
->groupBy('products.id', 'products.name')
->orderByDesc('revenue')
->limit(10)
->get();
// Session abandonment analysis (sessions with interactions but no conversion)
$cartAbandonment = [
'total_interactive_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->count(),
'abandoned_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
->where('interactions', '>', 0)
->where('converted', false)
->count(),
];
$cartAbandonment['abandonment_rate'] = $cartAbandonment['total_interactive_sessions'] > 0
? round(($cartAbandonment['abandoned_sessions'] / $cartAbandonment['total_interactive_sessions']) * 100, 2)
: 0;
// Top buyers by revenue
$topBuyers = DB::table('orders')
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('products', 'order_items.product_id', '=', 'products.id')
->join('brands', 'products.brand_id', '=', 'brands.id')
->join('businesses', 'orders.business_id', '=', 'businesses.id')
->where('brands.business_id', $business->id)
->where('orders.created_at', '>=', $startDate)
->select('businesses.id', 'businesses.name')
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
->selectRaw('SUM(order_items.line_total) as total_revenue')
->selectRaw('AVG(orders.total) as avg_order_value')
->groupBy('businesses.id', 'businesses.name')
->orderByDesc('total_revenue')
->limit(10)
->get();
return view('seller.marketing.analytics.sales', compact(
'business',
'period',
'funnelMetrics',
'salesMetrics',
'revenueTrend',
'conversionTrend',
'topProducts',
'cartAbandonment',
'topBuyers'
));
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers\Seller\Marketing\Analytics;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\AnalyticsTracker;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class TrackingController extends Controller
{
protected AnalyticsTracker $tracker;
public function __construct(AnalyticsTracker $tracker)
{
$this->tracker = $tracker;
}
/**
* Initialize or update session
*/
public function session(Request $request)
{
try {
$session = $this->tracker->startSession();
return response()->json([
'success' => true,
'session_id' => $session->session_id,
]);
} catch (\Exception $e) {
Log::error('Analytics session tracking failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'Session tracking failed',
], 500);
}
}
/**
* Track various analytics events
*/
public function track(Request $request)
{
try {
$eventType = $request->input('event_type');
switch ($eventType) {
case 'page_view':
$this->trackPageView($request);
break;
case 'product_view':
$this->trackProductView($request);
break;
case 'page_engagement':
$this->trackPageEngagement($request);
break;
case 'click':
$this->trackClick($request);
break;
default:
$this->trackGenericEvent($request);
}
return response()->json(['success' => true]);
} catch (\Exception $e) {
Log::error('Analytics tracking failed', [
'event_type' => $request->input('event_type'),
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'error' => 'Tracking failed',
], 500);
}
}
/**
* Track page view
*/
protected function trackPageView(Request $request): void
{
$this->tracker->updateSessionPageView();
$this->tracker->trackEvent(
'page_view',
'navigation',
'view',
null,
null,
[
'url' => $request->input('url'),
'title' => $request->input('title'),
'referrer' => $request->input('referrer'),
]
);
}
/**
* Track product view with engagement signals
*/
protected function trackProductView(Request $request): void
{
$productId = $request->input('product_id');
if (! $productId) {
return;
}
$product = Product::find($productId);
if (! $product) {
return;
}
$signals = [
'time_on_page' => $request->input('time_on_page'),
'scroll_depth' => $request->input('scroll_depth'),
'zoomed_image' => $request->boolean('zoomed_image'),
'watched_video' => $request->boolean('watched_video'),
'downloaded_spec' => $request->boolean('downloaded_spec'),
'added_to_cart' => $request->boolean('added_to_cart'),
'added_to_wishlist' => $request->boolean('added_to_wishlist'),
];
$this->tracker->trackProductView($product, $signals);
}
/**
* Track generic page engagement
*/
protected function trackPageEngagement(Request $request): void
{
$this->tracker->updateSessionPageView();
$this->tracker->trackEvent(
'page_engagement',
'engagement',
'interact',
null,
null,
[
'time_on_page' => $request->input('time_on_page'),
'scroll_depth' => $request->input('scroll_depth'),
]
);
}
/**
* Track click event
*/
protected function trackClick(Request $request): void
{
$this->tracker->trackClick(
$request->input('element_type', 'unknown'),
$request->input('element_id'),
$request->input('element_label'),
$request->input('url'),
[
'timestamp' => $request->input('timestamp'),
]
);
}
/**
* Track generic event
*/
protected function trackGenericEvent(Request $request): void
{
$this->tracker->trackEvent(
$request->input('event_type', 'custom'),
$request->input('category', 'general'),
$request->input('action', 'action'),
$request->input('subject_id'),
$request->input('subject_type'),
$request->input('metadata', [])
);
}
}

View File

@@ -0,0 +1,454 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Broadcast;
use App\Models\Marketing\Template;
use App\Models\MarketingAudience;
use App\Services\Marketing\BroadcastService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BroadcastController extends Controller
{
protected BroadcastService $broadcastService;
public function __construct(BroadcastService $broadcastService)
{
$this->broadcastService = $broadcastService;
}
/**
* Display list of broadcasts
*/
public function index(Request $request)
{
$business = $request->user()->currentBusiness;
$query = Broadcast::where('business_id', $business->id)
->with('createdBy', 'template');
// Filter by status
if ($request->has('status') && $request->status) {
$query->where('status', $request->status);
}
// Filter by channel
if ($request->has('channel') && $request->channel) {
$query->where('channel', $request->channel);
}
// Search
if ($request->has('search') && $request->search) {
$query->where(function ($q) use ($request) {
$q->where('name', 'LIKE', "%{$request->search}%")
->orWhere('description', 'LIKE', "%{$request->search}%");
});
}
$broadcasts = $query->orderBy('created_at', 'desc')->paginate(20);
return view('seller.marketing.broadcasts.index', compact('broadcasts'));
}
/**
* Show create form
*/
public function create(Request $request)
{
$business = $request->user()->currentBusiness;
$audiences = MarketingAudience::where('business_id', $business->id)
->orderBy('name')
->get();
$templates = Template::where('business_id', $business->id)
->orderBy('name')
->get();
return view('seller.marketing.broadcasts.create', compact('audiences', 'templates'));
}
/**
* Store new broadcast
*/
public function store(Request $request)
{
$business = $request->user()->currentBusiness;
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => 'required|in:immediate,scheduled',
'channel' => 'required|in:email,sms,push,multi',
'template_id' => 'nullable|exists:templates,id',
'subject' => 'required_if:channel,email|nullable|string|max:255',
'content' => 'required_without:template_id|nullable|string',
'audience_ids' => 'nullable|array',
'audience_ids.*' => 'exists:marketing_audiences,id',
'include_all' => 'boolean',
'exclude_audience_ids' => 'nullable|array',
'scheduled_at' => 'required_if:type,scheduled|nullable|date|after:now',
'timezone' => 'nullable|string',
'track_opens' => 'boolean',
'track_clicks' => 'boolean',
'send_rate_limit' => 'nullable|integer|min:1|max:1000',
]);
$broadcast = Broadcast::create([
'business_id' => $business->id,
'created_by_user_id' => $request->user()->id,
...$validated,
'status' => 'draft',
]);
// Prepare recipients
try {
$count = $this->broadcastService->prepareBroadcast($broadcast);
return redirect()
->route('seller.marketing.broadcasts.show', $broadcast)
->with('success', "Broadcast created with {$count} recipients");
} catch (\Exception $e) {
return back()
->withInput()
->withErrors(['error' => 'Failed to prepare broadcast: '.$e->getMessage()]);
}
}
/**
* Show specific broadcast
*/
public function show(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$broadcast->load(['createdBy', 'template', 'recipients' => function ($query) {
$query->with('user')->latest()->limit(50);
}]);
$stats = $this->broadcastService->getStatistics($broadcast);
// Get event timeline (recent events)
$recentEvents = $broadcast->events()
->with('user')
->latest('occurred_at')
->limit(20)
->get();
return view('seller.marketing.broadcasts.show', compact('broadcast', 'stats', 'recentEvents'));
}
/**
* Show edit form
*/
public function edit(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
if (! $broadcast->isDraft()) {
return back()->with('error', 'Only draft broadcasts can be edited');
}
$audiences = MarketingAudience::where('business_id', $business->id)
->orderBy('name')
->get();
$templates = Template::where('business_id', $business->id)
->orderBy('name')
->get();
return view('seller.marketing.broadcasts.edit', compact('broadcast', 'audiences', 'templates'));
}
/**
* Update broadcast
*/
public function update(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
if (! $broadcast->isDraft()) {
return back()->with('error', 'Only draft broadcasts can be updated');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
'type' => 'required|in:immediate,scheduled',
'channel' => 'required|in:email,sms,push,multi',
'template_id' => 'nullable|exists:templates,id',
'subject' => 'required_if:channel,email|nullable|string|max:255',
'content' => 'required_without:template_id|nullable|string',
'audience_ids' => 'nullable|array',
'include_all' => 'boolean',
'exclude_audience_ids' => 'nullable|array',
'scheduled_at' => 'required_if:type,scheduled|nullable|date|after:now',
'track_opens' => 'boolean',
'track_clicks' => 'boolean',
'send_rate_limit' => 'nullable|integer|min:1|max:1000',
]);
$broadcast->update($validated);
// Re-prepare recipients
try {
$count = $this->broadcastService->prepareBroadcast($broadcast);
return redirect()
->route('seller.marketing.broadcasts.show', $broadcast)
->with('success', "Broadcast updated with {$count} recipients");
} catch (\Exception $e) {
return back()->with('error', 'Failed to update broadcast: '.$e->getMessage());
}
}
/**
* Delete broadcast
*/
public function destroy(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
if (! in_array($broadcast->status, ['draft', 'cancelled', 'failed'])) {
return back()->with('error', 'Cannot delete broadcast in current status');
}
$broadcast->delete();
return redirect()
->route('seller.marketing.broadcasts.index')
->with('success', 'Broadcast deleted');
}
/**
* Send broadcast
*/
public function send(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
try {
$this->broadcastService->sendBroadcast($broadcast);
return back()->with('success', 'Broadcast is now being sent');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Pause broadcast
*/
public function pause(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
try {
$this->broadcastService->pauseBroadcast($broadcast);
return back()->with('success', 'Broadcast paused');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Resume broadcast
*/
public function resume(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
try {
$this->broadcastService->resumeBroadcast($broadcast);
return back()->with('success', 'Broadcast resumed');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Cancel broadcast
*/
public function cancel(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
try {
$this->broadcastService->cancelBroadcast($broadcast);
return back()->with('success', 'Broadcast cancelled');
} catch (\Exception $e) {
return back()->with('error', $e->getMessage());
}
}
/**
* Duplicate broadcast
*/
public function duplicate(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$newBroadcast = $broadcast->replicate();
$newBroadcast->name = $broadcast->name.' (Copy)';
$newBroadcast->status = 'draft';
$newBroadcast->created_by_user_id = $request->user()->id;
$newBroadcast->total_recipients = 0;
$newBroadcast->total_sent = 0;
$newBroadcast->total_delivered = 0;
$newBroadcast->total_failed = 0;
$newBroadcast->total_opened = 0;
$newBroadcast->total_clicked = 0;
$newBroadcast->started_sending_at = null;
$newBroadcast->finished_sending_at = null;
$newBroadcast->save();
// Prepare recipients
$this->broadcastService->prepareBroadcast($newBroadcast);
return redirect()
->route('seller.marketing.broadcasts.show', $newBroadcast)
->with('success', 'Broadcast duplicated');
}
/**
* Get progress (AJAX)
*/
public function progress(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$stats = $this->broadcastService->getStatistics($broadcast);
return response()->json([
'status' => $broadcast->status,
'stats' => $stats,
'progress' => $broadcast->total_recipients > 0
? round(($broadcast->total_sent / $broadcast->total_recipients) * 100, 2)
: 0,
]);
}
/**
* View recipients
*/
public function recipients(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$recipients = $broadcast->recipients()
->with('user')
->when($request->has('status'), function ($query) use ($request) {
$query->where('status', $request->status);
})
->orderBy('created_at', 'desc')
->paginate(50);
return view('seller.marketing.broadcasts.recipients', compact('broadcast', 'recipients'));
}
/**
* View analytics
*/
public function analytics(Request $request, Broadcast $broadcast)
{
$business = $request->user()->currentBusiness;
if ($broadcast->business_id !== $business->id) {
abort(403);
}
$stats = $this->broadcastService->getStatistics($broadcast);
// Get hourly breakdown
$hourlyData = DB::table('broadcast_recipients')
->where('broadcast_id', $broadcast->id)
->whereNotNull('sent_at')
->select(
DB::raw('DATE_FORMAT(sent_at, "%Y-%m-%d %H:00:00") as hour'),
DB::raw('COUNT(*) as count')
)
->groupBy('hour')
->orderBy('hour')
->get();
// Get event breakdown by type
$eventBreakdown = DB::table('broadcast_events')
->where('broadcast_id', $broadcast->id)
->select('event', DB::raw('COUNT(*) as count'))
->groupBy('event')
->pluck('count', 'event');
// Top clicked links
$topLinks = DB::table('broadcast_events')
->where('broadcast_id', $broadcast->id)
->where('event', 'clicked')
->select('link_url', DB::raw('COUNT(*) as count'))
->groupBy('link_url')
->orderByDesc('count')
->limit(10)
->get();
return view('seller.marketing.broadcasts.analytics', compact(
'broadcast',
'stats',
'hourlyData',
'eventBreakdown',
'topLinks'
));
}
}

View File

@@ -0,0 +1,506 @@
<?php
namespace App\Http\Controllers\Seller\Marketing;
use App\Http\Controllers\Controller;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Marketing\Template;
use App\Models\Marketing\TemplateCategory;
use App\Services\Marketing\AIContentService;
use App\Services\Marketing\MergeTagService;
use App\Services\Marketing\TemplateService;
use App\Services\PermissionService;
use Illuminate\Http\Request;
class TemplateController extends Controller
{
public function __construct(
protected TemplateService $templateService,
protected AIContentService $aiContentService,
protected MergeTagService $mergeTagService,
protected PermissionService $permissionService
) {}
public function index(Request $request, Business $business)
{
$this->authorize('viewAny', [Template::class, $business]);
$query = Template::forBusiness($business->id)
->with(['category', 'brands', 'analytics']);
if ($request->filled('search')) {
$query->where(function ($q) use ($request) {
$q->where('name', 'ilike', '%'.$request->search.'%')
->orWhere('description', 'ilike', '%'.$request->search.'%');
});
}
if ($request->filled('category')) {
$query->where('category_id', $request->category);
}
if ($request->filled('type')) {
$query->byType($request->type);
}
if ($request->filled('brand')) {
$query->whereHas('brands', fn ($q) => $q->where('brands.id', $request->brand));
}
$sort = $request->get('sort', 'recent');
match ($sort) {
'popular' => $query->popular(),
'name' => $query->orderBy('name'),
default => $query->latest(),
};
$templates = $query->paginate(24);
$categories = TemplateCategory::sorted()->get();
$brands = Brand::where('business_id', $business->id)->get();
return view('seller.marketing.templates.index', compact(
'business',
'templates',
'categories',
'brands'
));
}
public function create(Request $request, Business $business)
{
$this->authorize('create', [Template::class, $business]);
$categories = TemplateCategory::sorted()->get();
$brands = Brand::where('business_id', $business->id)->get();
$mergeTags = $this->mergeTagService->getAvailableTags();
$templateType = $request->get('type', 'email');
return view('seller.marketing.templates.create', compact(
'business',
'categories',
'brands',
'mergeTags',
'templateType'
));
}
public function store(Request $request, Business $business)
{
$this->authorize('create', [Template::class, $business]);
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category_id' => 'required|exists:template_categories,id',
'template_type' => 'required|in:email,sms,push',
'design_json' => 'nullable|json',
'mjml_content' => 'nullable|string',
'html_content' => 'nullable|string',
'plain_text' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'string|max:50',
'brands' => 'nullable|array',
'brands.*' => 'exists:brands,id',
]);
$template = $this->templateService->create($validated);
if (! empty($validated['brands'])) {
foreach ($validated['brands'] as $brandId) {
$this->templateService->addToBrand($template, $brandId);
}
}
return redirect()
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
->with('success', 'Template created successfully');
}
public function show(Business $business, Template $template)
{
$this->authorize('view', [$template, $business]);
$template->load(['category', 'brands', 'analytics', 'creator', 'updater', 'versions']);
return view('seller.marketing.templates.show', compact('business', 'template'));
}
public function edit(Business $business, Template $template)
{
$this->authorize('update', [$template, $business]);
if (! $template->is_editable) {
return redirect()
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
->with('error', 'This template cannot be edited');
}
$categories = TemplateCategory::sorted()->get();
$brands = Brand::where('business_id', $business->id)->get();
$mergeTags = $this->mergeTagService->getAvailableTags();
return view('seller.marketing.templates.edit', compact(
'business',
'template',
'categories',
'brands',
'mergeTags'
));
}
public function update(Request $request, Business $business, Template $template)
{
$this->authorize('update', [$template, $business]);
if (! $template->is_editable) {
return back()->with('error', 'This template cannot be edited');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category_id' => 'required|exists:template_categories,id',
'design_json' => 'nullable|json',
'mjml_content' => 'nullable|string',
'html_content' => 'nullable|string',
'plain_text' => 'nullable|string',
'tags' => 'nullable|array',
'tags.*' => 'string|max:50',
'change_notes' => 'nullable|string',
'brands' => 'nullable|array',
'brands.*' => 'exists:brands,id',
]);
$this->templateService->update($template, $validated);
if (isset($validated['brands'])) {
$template->brands()->sync($validated['brands']);
}
return redirect()
->route('seller.marketing.templates.show', ['business' => $business, 'template' => $template])
->with('success', 'Template updated successfully');
}
public function destroy(Business $business, Template $template)
{
$this->authorize('delete', [$template, $business]);
if (! $template->canBeDeleted()) {
return back()->with('error', 'This template cannot be deleted because it is in use');
}
$this->templateService->delete($template);
return redirect()
->route('seller.marketing.templates.index', ['business' => $business])
->with('success', 'Template deleted successfully');
}
public function duplicate(Request $request, Business $business, Template $template)
{
$this->authorize('create', [Template::class, $business]);
$validated = $request->validate([
'name' => 'required|string|max:255',
'brand_id' => 'nullable|exists:brands,id',
]);
$duplicate = $this->templateService->duplicate(
$template,
$validated['name'],
$validated['brand_id'] ?? null
);
return redirect()
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $duplicate])
->with('success', 'Template duplicated successfully');
}
public function preview(Request $request, Business $business, Template $template)
{
$this->authorize('view', [$template, $business]);
$brand = null;
if ($request->filled('brand_id')) {
$brand = Brand::where('business_id', $business->id)
->findOrFail($request->brand_id);
}
$sampleData = [
'buyer' => (object) [
'name' => 'John Doe',
'first_name' => 'John',
'last_name' => 'Doe',
'email' => 'john@example.com',
'phone' => '555-0123',
],
'order' => (object) [
'order_number' => 'ORD-12345',
'total' => '$299.99',
'created_at' => now(),
],
'unsubscribe_link' => '#unsubscribe',
'view_in_browser_link' => '#view-browser',
];
$rendered = $this->templateService->render($template, $sampleData, $brand);
return response()->json(['html' => $rendered]);
}
public function sendTest(Request $request, Business $business, Template $template)
{
$this->authorize('view', [$template, $business]);
$validated = $request->validate([
'email' => 'required|email',
'brand_id' => 'nullable|exists:brands,id',
]);
$brand = null;
if (isset($validated['brand_id'])) {
$brand = Brand::where('business_id', $business->id)
->findOrFail($validated['brand_id']);
}
$sampleData = [
'buyer' => (object) [
'name' => auth()->user()->name,
'email' => auth()->user()->email,
],
];
$rendered = $this->templateService->render($template, $sampleData, $brand);
// TODO: Integrate with mail system
// Mail::send([], [], function ($message) use ($validated, $rendered, $template) {
// $message->to($validated['email'])
// ->subject('[TEST] ' . $template->name)
// ->html($rendered);
// });
return back()->with('success', 'Test email sent to '.$validated['email']);
}
public function analytics(Business $business, Template $template)
{
$this->authorize('view', [$template, $business]);
$analytics = $template->analytics()
->with('brand')
->get();
$totalAnalytics = [
'total_sends' => $analytics->sum('total_sends'),
'total_opens' => $analytics->sum('total_opens'),
'total_clicks' => $analytics->sum('total_clicks'),
'total_bounces' => $analytics->sum('total_bounces'),
'avg_open_rate' => $analytics->avg('avg_open_rate'),
'avg_click_rate' => $analytics->avg('avg_click_rate'),
];
return view('seller.marketing.templates.analytics', compact(
'business',
'template',
'analytics',
'totalAnalytics'
));
}
public function versions(Business $business, Template $template)
{
$this->authorize('view', [$template, $business]);
$versions = $template->versions()
->with('creator')
->latest('version_number')
->get();
return view('seller.marketing.templates.versions', compact(
'business',
'template',
'versions'
));
}
public function restoreVersion(Request $request, Business $business, Template $template)
{
$this->authorize('update', [$template, $business]);
$validated = $request->validate([
'version_id' => 'required|exists:template_versions,id',
]);
$version = $template->versions()->findOrFail($validated['version_id']);
$template->restoreVersion($version);
return redirect()
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $template])
->with('success', 'Template restored to version '.$version->version_number);
}
public function addToBrand(Request $request, Business $business, Template $template)
{
$this->authorize('view', [$template, $business]);
$validated = $request->validate([
'brand_id' => 'required|exists:brands,id',
]);
$brand = Brand::where('business_id', $business->id)
->findOrFail($validated['brand_id']);
$this->templateService->addToBrand($template, $brand->id);
return back()->with('success', 'Template added to brand');
}
public function removeFromBrand(Request $request, Business $business, Template $template)
{
$this->authorize('view', [$template, $business]);
$validated = $request->validate([
'brand_id' => 'required|exists:brands,id',
]);
$this->templateService->removeFromBrand($template, $validated['brand_id']);
return back()->with('success', 'Template removed from brand');
}
public function toggleFavorite(Request $request, Business $business, Template $template)
{
$this->authorize('view', [$template, $business]);
$validated = $request->validate([
'brand_id' => 'required|exists:brands,id',
]);
$this->templateService->toggleFavorite($template, $validated['brand_id']);
return back()->with('success', 'Favorite status updated');
}
public function export(Business $business, Template $template, string $format)
{
$this->authorize('view', [$template, $business]);
return match ($format) {
'html' => response($this->templateService->exportToHtml($template))
->header('Content-Type', 'text/html')
->header('Content-Disposition', 'attachment; filename="'.$template->slug.'.html"'),
'mjml' => response($this->templateService->exportToMjml($template))
->header('Content-Type', 'text/plain')
->header('Content-Disposition', 'attachment; filename="'.$template->slug.'.mjml"'),
'zip' => response()
->download($this->templateService->exportAsZip($template), $template->slug.'.zip')
->deleteFileAfterSend(),
default => abort(400, 'Invalid export format'),
};
}
public function import(Request $request, Business $business)
{
$this->authorize('create', [Template::class, $business]);
$validated = $request->validate([
'file' => 'required|file|mimes:html,txt,zip',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'category_id' => 'required|exists:template_categories,id',
]);
$file = $request->file('file');
$extension = $file->getClientOriginalExtension();
if ($extension === 'zip') {
// TODO: Handle ZIP import with metadata extraction
return back()->with('error', 'ZIP import not yet implemented');
}
$html = file_get_contents($file->path());
$template = $this->templateService->importFromHtml($html, [
'name' => $validated['name'],
'description' => $validated['description'],
'category_id' => $validated['category_id'],
]);
return redirect()
->route('seller.marketing.templates.edit', ['business' => $business, 'template' => $template])
->with('success', 'Template imported successfully');
}
public function aiGenerate(Request $request, Business $business)
{
$this->authorize('create', [Template::class, $business]);
$validated = $request->validate([
'prompt' => 'required|string|max:1000',
'brand_id' => 'nullable|exists:brands,id',
]);
$context = ['business' => $business->name];
if (isset($validated['brand_id'])) {
$brand = Brand::where('business_id', $business->id)
->findOrFail($validated['brand_id']);
$context['brand'] = $brand->name;
}
$content = $this->aiContentService->generateEmailContent(
$validated['prompt'],
$context
);
return response()->json(['content' => $content]);
}
public function aiSubjectLines(Request $request, Business $business)
{
$validated = $request->validate([
'content' => 'required|string',
'count' => 'nullable|integer|min:3|max:20',
]);
$subjectLines = $this->aiContentService->generateSubjectLines(
$validated['content'],
$validated['count'] ?? 10
);
return response()->json(['subject_lines' => $subjectLines]);
}
public function aiImproveCopy(Request $request, Business $business)
{
$validated = $request->validate([
'content' => 'required|string',
'tone' => 'required|in:professional,casual,urgent,enthusiastic,educational',
]);
$improved = $this->aiContentService->improveCopy(
$validated['content'],
$validated['tone']
);
return response()->json(['improved' => $improved]);
}
public function aiCheckSpam(Request $request, Business $business)
{
$validated = $request->validate([
'content' => 'required|string',
]);
$result = $this->aiContentService->checkSpamScore($validated['content']);
return response()->json($result);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Seller;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function index()
{
return view('seller.order.index');
}
public function create()
{
return view('seller.order.create');
}
public function store(Request $request)
{
// TODO: Implement store logic
return redirect()->route('seller.business.order.index');
}
public function show($id)
{
return view('seller.order.show');
}
public function edit($id)
{
return view('seller.order.edit');
}
public function update(Request $request, $id)
{
// TODO: Implement update logic
return redirect()->route('seller.business.order.index');
}
public function destroy($id)
{
// TODO: Implement destroy logic
return redirect()->route('seller.business.order.index');
}
public function preview($id)
{
return view('seller.order.preview');
}
}

View File

@@ -0,0 +1,355 @@
<?php
namespace App\Http\Controllers\Seller\Processing;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Component;
use App\Models\Conversion;
use Illuminate\Http\Request;
class ConversionController extends Controller
{
/**
* Display a listing of conversions (history).
*/
public function index(Business $business)
{
$user = auth()->user();
// CRITICAL: Only show conversions for THIS business AND user's departments
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->with(['department', 'operator'])
->orderBy('started_at', 'desc')
->paginate(25);
return view('seller.processing.conversions.index', compact('business', 'conversions'));
}
/**
* Show the form for creating a new conversion.
*/
public function create(Business $business)
{
// Get user's departments
$user = auth()->user();
$userDepartments = $user->departments()
->where('business_id', $business->id)
->get();
// Get input components (with stock available)
$inputComponents = Component::where('business_id', $business->id)
->where('is_active', true)
->where('quantity_on_hand', '>', 0)
->orderBy('name')
->get();
// Get all active components for output selection
$outputComponents = Component::where('business_id', $business->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('seller.processing.conversions.create', compact('business', 'userDepartments', 'inputComponents', 'outputComponents'));
}
/**
* Store a newly created conversion in storage.
*/
public function store(Business $business, Request $request)
{
$user = auth()->user();
$validated = $request->validate([
'department_id' => 'required|exists:departments,id',
'conversion_type' => 'required|string|max:50',
'internal_name' => 'required|string|max:255',
'conversion_date' => 'required|date',
'status' => 'required|in:pending,in_progress,completed,cancelled',
'input_component_id' => 'required|exists:components,id',
'output_component_id' => 'required|exists:components,id',
'input_weight' => 'required|numeric|min:0.01',
'output_weight' => 'required|numeric|min:0',
'waste_weight' => 'nullable|numeric|min:0',
'notes' => 'nullable|string|max:1000',
'operator_id' => 'nullable|exists:users,id',
]);
// Security: Verify user is assigned to the selected department
if (! $user->departments->contains($validated['department_id'])) {
abort(403, 'You are not assigned to this department.');
}
// Verify components belong to this business
$inputComponent = Component::where('business_id', $business->id)
->findOrFail($validated['input_component_id']);
$outputComponent = Component::where('business_id', $business->id)
->findOrFail($validated['output_component_id']);
// Verify sufficient input quantity
if ($inputComponent->quantity_on_hand < $validated['input_weight']) {
return back()->withErrors([
'input_weight' => 'Insufficient quantity available. Only '.number_format($inputComponent->quantity_on_hand, 2).$inputComponent->unit_of_measure.' available.',
])->withInput();
}
// Calculate yield percentage
$yieldPercentage = 0;
if ($validated['input_weight'] > 0) {
$yieldPercentage = ($validated['output_weight'] / $validated['input_weight']) * 100;
}
// Create conversion record
$conversion = Conversion::create([
'business_id' => $business->id,
'department_id' => $validated['department_id'],
'conversion_type' => $validated['conversion_type'],
'internal_name' => $validated['internal_name'],
'started_at' => $validated['conversion_date'],
'completed_at' => $validated['status'] === 'completed' ? $validated['conversion_date'] : null,
'status' => $validated['status'],
'operator_user_id' => $validated['operator_id'] ?? $user->id,
'input_weight' => $validated['input_weight'],
'input_unit' => $inputComponent->unit_of_measure,
'actual_output_quantity' => $validated['output_weight'],
'actual_output_unit' => $outputComponent->unit_of_measure,
'yield_percentage' => $yieldPercentage,
'notes' => $validated['notes'] ?? null,
'metadata' => [
'input_component_id' => $inputComponent->id,
'input_component_name' => $inputComponent->name,
'output_component_id' => $outputComponent->id,
'output_component_name' => $outputComponent->name,
'waste_weight' => $validated['waste_weight'] ?? 0,
],
]);
// Deduct input component quantity
$inputComponent->decrement('quantity_on_hand', $validated['input_weight']);
// Add output component quantity
$outputComponent->increment('quantity_on_hand', $validated['output_weight']);
return redirect()
->route('seller.business.processing.conversions.show', [$business->slug, $conversion->id])
->with('success', 'Conversion created successfully! Input reduced, output added to inventory.');
}
/**
* Display the specified conversion (with input/output/yield/waste details).
*/
public function show(Business $business, Conversion $conversion)
{
// Security check: ensure conversion belongs to this business
if ($conversion->business_id !== $business->id) {
abort(404, 'Conversion not found.');
}
$conversion->load(['department', 'operator']);
return view('seller.processing.conversions.show', compact('business', 'conversion'));
}
/**
* Show the form for editing the specified conversion.
*/
public function edit(Business $business, Conversion $conversion)
{
// Security check: ensure conversion belongs to this business
if ($conversion->business_id !== $business->id) {
abort(404, 'Conversion not found.');
}
return view('seller.processing.conversions.edit', compact('business', 'conversion'));
}
/**
* Update the specified conversion in storage.
*/
public function update(Business $business, Conversion $conversion, Request $request)
{
// Security check: ensure conversion belongs to this business
if ($conversion->business_id !== $business->id) {
abort(404, 'Conversion not found.');
}
$validated = $request->validate([
'input_component_id' => 'required|exists:components,id',
'output_component_id' => 'required|exists:components,id',
'input_weight' => 'required|numeric|min:0',
'output_weight' => 'required|numeric|min:0',
'waste_weight' => 'nullable|numeric|min:0',
'conversion_date' => 'required|date',
'conversion_type' => 'required|string|max:50',
'status' => 'required|in:draft,in_progress,completed,cancelled',
'notes' => 'nullable|string|max:1000',
'operator_id' => 'nullable|exists:users,id',
]);
// Recalculate yield percentage
if ($validated['input_weight'] > 0) {
$validated['yield_percentage'] = ($validated['output_weight'] / $validated['input_weight']) * 100;
}
$conversion->update($validated);
return redirect()
->route('seller.business.processing.conversions.show', [$business->slug, $conversion->id])
->with('success', 'Conversion updated successfully!');
}
/**
* Remove the specified conversion from storage.
*/
public function destroy(Business $business, Conversion $conversion)
{
// Security check: ensure conversion belongs to this business
if ($conversion->business_id !== $business->id) {
abort(404, 'Conversion not found.');
}
$conversion->delete();
return redirect()
->route('seller.business.processing.conversions.index', $business->slug)
->with('success', 'Conversion deleted successfully!');
}
/**
* Display yield analytics dashboard.
*/
public function yields(Business $business)
{
$user = auth()->user();
// Get all conversions with yield data filtered by user's departments
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->where('status', 'completed')
->orderBy('started_at', 'desc')
->get();
// Calculate average yields by conversion type
$yieldsByType = $conversions->groupBy('conversion_type')->map(function ($group) {
return [
'avg_yield' => round($group->avg('yield_percentage'), 2),
'min_yield' => round($group->min('yield_percentage'), 2),
'max_yield' => round($group->max('yield_percentage'), 2),
'count' => $group->count(),
];
});
return view('seller.processing.conversions.yields', compact('business', 'yieldsByType', 'conversions'));
}
/**
* Display waste tracking dashboard.
*/
public function waste(Business $business)
{
$user = auth()->user();
// Get all conversions with waste data filtered by user's departments
$conversions = Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->where('status', 'completed')
->whereNotNull('metadata->waste_weight')
->with(['department', 'operator'])
->orderBy('started_at', 'desc')
->paginate(25);
// Calculate total waste statistics for user's departments
$totalWaste = Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->where('status', 'completed')
->get()
->sum(function ($conversion) {
return $conversion->metadata['waste_weight'] ?? 0;
});
$totalInput = Conversion::where('business_id', $business->id)
->forUserDepartments($user)
->where('status', 'completed')
->sum('input_weight');
$wastePercentage = $totalInput > 0 ? round(($totalWaste / $totalInput) * 100, 2) : 0;
return view('seller.processing.conversions.waste', compact('business', 'conversions', 'totalWaste', 'wastePercentage'));
}
/**
* Add a stage to a conversion.
*/
public function addStage(Business $business, Conversion $conversion, Request $request)
{
// Security check: ensure conversion belongs to this business
if ($conversion->business_id !== $business->id) {
abort(404, 'Conversion not found.');
}
$validated = $request->validate([
'stage_name' => 'required|string|max:100',
'stage_type' => 'required|string|in:extraction,winterization,distillation,filtration,drying,curing,packaging,other',
'start_time' => 'required|date',
'end_time' => 'nullable|date|after:start_time',
'temperature' => 'nullable|numeric',
'pressure' => 'nullable|numeric',
'notes' => 'nullable|string|max:1000',
]);
// Get existing stages from metadata
$stages = $conversion->metadata['stages'] ?? [];
$stages[] = $validated;
// Update metadata
$conversion->update([
'metadata' => array_merge($conversion->metadata ?? [], ['stages' => $stages]),
]);
return redirect()
->route('seller.business.processing.conversions.show', [$business->slug, $conversion->id])
->with('success', 'Stage added successfully!');
}
/**
* Update a stage in a conversion.
*/
public function updateStage(Business $business, Conversion $conversion, int $stageIndex, Request $request)
{
// Security check: ensure conversion belongs to this business
if ($conversion->business_id !== $business->id) {
abort(404, 'Conversion not found.');
}
$validated = $request->validate([
'stage_name' => 'required|string|max:100',
'stage_type' => 'required|string|in:extraction,winterization,distillation,filtration,drying,curing,packaging,other',
'start_time' => 'required|date',
'end_time' => 'nullable|date|after:start_time',
'temperature' => 'nullable|numeric',
'pressure' => 'nullable|numeric',
'notes' => 'nullable|string|max:1000',
]);
// Get existing stages from metadata
$stages = $conversion->metadata['stages'] ?? [];
if (! isset($stages[$stageIndex])) {
abort(404, 'Stage not found.');
}
// Update the specific stage
$stages[$stageIndex] = $validated;
// Update metadata
$conversion->update([
'metadata' => array_merge($conversion->metadata ?? [], ['stages' => $stages]),
]);
return redirect()
->route('seller.business.processing.conversions.show', [$business->slug, $conversion->id])
->with('success', 'Stage updated successfully!');
}
}

Some files were not shown because too many files have changed in this diff Show More