Compare commits

...

42 Commits

Author SHA1 Message Date
Jon Leopard
6db9512a4b chore: trigger CI build 2025-11-20 21:08:30 -07:00
Jon Leopard
ac9ab7e257 fix: use existing testing database for seeder validation
The psql command is not available in the Laravel test runner image.
Instead, reuse the existing 'testing' database - migrate:fresh will
drop and recreate all tables anyway, so there's no conflict.
2025-11-20 21:06:15 -07:00
Jon Leopard
06e35cb296 fix: create seeder_validation database before running seeders in CI 2025-11-20 20:51:22 -07:00
Jon
4b347112c6 Merge pull request 'fix: DevSeeder crashing on K8s deployment causing 0 stock' (#80) from feature/restore-order-ui-improvements into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/80
2025-11-21 03:37:07 +00:00
Jon
632ddce08a Merge branch 'develop' into feature/restore-order-ui-improvements 2025-11-21 03:32:56 +00:00
Jon Leopard
35c603944f feat: add seeder validation to CI/CD pipeline
Prevents deployment failures by validating seeders before K8s deployment.

Problem:
- Dev environment runs migrate:fresh --seed on every K8s deployment
- CI tests use APP_ENV=testing which skips DevSeeder
- K8s uses APP_ENV=development which runs DevSeeder
- Seeder bugs (like fake() crash) passed CI but failed in K8s

Solution:
- Add validate-seeders step to Woodpecker CI
- Use APP_ENV=development to match K8s init container
- Run same command as K8s: migrate:fresh --seed --force
- Runs after tests, before Docker build

Impact:
- Time cost: ~20-30 seconds added to CI pipeline
- Catches: runtime errors, DB constraints, missing relationships
- Prevents: K8s pod crashes from seeder failures

Documentation:
- Updated .woodpecker/README.md with CI pipeline stages
- Explained why seeder validation is needed
- Added to pre-release checklist
2025-11-20 20:28:34 -07:00
Jon Leopard
ea3ed4de0a fix: replace fake() with native PHP functions in DevSeeder
The DevSeeder was crashing during K8s deployment with:
  Call to undefined function Database\Seeders\fake()

This caused dev.cannabrands.app to have 0 stock because the seeder
couldn't complete the batch creation step.

Changes:
- Replace fake()->randomElement() with array_rand()
- Replace fake()->randomFloat() with mt_rand() + rounding
- No external dependencies needed

Why it passed CI/CD:
- Tests use APP_ENV=testing which skips DevSeeder
- DevSeeder only runs in local/development/staging environments
- No seeder validation in CI/CD pipeline
2025-11-20 20:08:04 -07:00
Jon
179c9a7818 Merge pull request 'Order Flow UI Improvements and Picking Workflow Enhancements' (#79) from feature/restore-order-ui-improvements into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/79
2025-11-21 01:15:59 +00:00
Jon Leopard
6835a19b39 fix: pin Dockerfile to PHP 8.4 and add zip extension
- Pin composer-builder to PHP 8.4 (avoids PHP 8.5 compatibility issues)
- Add zip extension required by openspout/openspout dependency
- Use php:8.4-cli-alpine with composer binary copied from composer:2.8
- Ensures consistent build environment across all deployments
2025-11-20 17:23:02 -07:00
Jon
3b9ddd8865 Merge pull request 'Order Flow UI Improvements and Picking Workflow Enhancements' (#78) from feature/restore-order-ui-improvements into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/78
2025-11-21 00:07:29 +00:00
Jon Leopard
d9d8190835 feat: show COA and picked columns for completed orders
- Add 'completed' status to COA column visibility check
- Add 'completed' status to picked column visibility check
- Fix colspan calculation for proper total alignment
- Buyers can now view COAs and picked quantities on completed orders
2025-11-20 16:55:56 -07:00
Jon Leopard
8af01a6772 feat: improve picking workflow and order UI
Picking Workflow:
- Add startPick() method to track picker_id and started_at
- Add "Start Pick" button on picking ticket pages
- Lock quantity inputs until picking ticket is started
- Remove order-level "Start Order" button
- Show picking tickets for accepted orders (fix deadlock)

Order UI:
- Move pickup confirmation banner outside container for full width
- Align "Confirm Pickup Complete" button to right side
- Fix confirm-pickup modal to use POST instead of PATCH
- Allow super-admins to access work orders without business check
- Improve order status visibility and workflow clarity
2025-11-20 16:55:44 -07:00
Jon Leopard
e11a934766 feat: add environment-aware COA PDF generation
- Create CoaController with environment-aware download method
- Local/dev: Generate placeholder PDFs on-demand (no file storage)
- Production/staging: Serve real uploaded PDF files from storage
- Add public route for COA downloads
- Update all COA links to use new route
- Kubernetes-compatible for stateless dev deployments
2025-11-20 16:55:31 -07:00
Jon Leopard
86c2e0cf1c feat: add global formatCurrency function for consistent formatting
- Add window.formatCurrency() helper function in app.js
- Replace .toFixed(2) with formatCurrency() on checkout page
- Replace .toFixed(2) with formatCurrency() on cart page
- Ensures all currency displays show thousand separators ($21,000.00)
- Matches server-side number_format() output for consistency
2025-11-20 16:55:20 -07:00
Jon Leopard
f899e5f8cb fix: allow super admins to access all fulfillment work orders
Updated authorization checks in FulfillmentWorkOrderController to bypass
business ownership verification for users with super-admin role.

This allows platform admins to view and manage work orders across all
businesses. Future RBAC enhancements will provide more granular control.

Also backfilled seller_business_id for 6 existing orders that were
missing this field (created before multi-business checkout was added).
2025-11-20 15:22:24 -07:00
Jon Leopard
f2b1ceebe9 fix: add missing start-picking route for fulfillment work orders
Route seller.work-orders.start-picking was referenced in seller order
show view but not defined in routes/seller.php, causing route not
found error when accepting buyer orders.

Added POST route for starting picking process on work orders.
2025-11-20 15:19:21 -07:00
Jon Leopard
b0e343f2b5 fix: add UUID generation to UserFactory for parallel testing
Kelly's migration added uuid column to users table but UserFactory
was not updated to generate UUIDs, causing test failures in parallel runs.

Uses same 18-char UUIDv7 format as User model's newUniqueId() method
to ensure consistency and prevent unique constraint violations.
2025-11-20 15:15:42 -07:00
Jon Leopard
609d55d5c9 chore: remove obsolete documentation and temporary files
Deleted files:
- .claude/ directory files (now gitignored)
- Obsolete analytics implementation docs
- Temporary session/recovery files
- Kelly's personal Claude rules
- Test scripts and temp check files
- Quick handoff guides (outdated)

These files were either:
- Personal AI context (now handled by CLAUDE.md)
- Temporary debugging artifacts
- Superseded by current implementation
- No longer relevant to active development
2025-11-19 16:36:54 -07:00
Jon Leopard
d649c8239f chore: add .claude/ directory to gitignore
Personal AI preferences and conversation history should not be tracked in git.
The CLAUDE.md file already provides shared project context for the team.
2025-11-19 16:35:43 -07:00
Jon Leopard
86b7d8db4e fix: restore invoice show page and add dropdown menu to orders table
- Restore complete invoice show page from PR #71 including:
  - Payment status alerts
  - Download PDF button
  - Company information (seller business details)
  - Order summary with rejected items tracking
  - Product images in line items table
  - Rejected items styling with strikethrough

- Replace individual action buttons with dropdown menu in orders table:
  - Uses hamburger icon (more-vertical)
  - Contains View Details, Download Invoice, Download Manifest, Accept Order
  - Positioned as dropdown-end with dropdown-top for last row
  - No cancel button (buyers can only request cancellation from order detail page)
2025-11-19 16:33:26 -07:00
Jon Leopard
701534dd6b fix: auto-expand Purchases menu when on orders/invoices pages
The Purchases sidebar menu was collapsing after page load due to
persisted state. Now it automatically expands when the user is on
any orders or invoices page, ensuring the active link is visible.

This prevents the UI flash where the menu briefly appears then
disappears.
2025-11-19 16:26:58 -07:00
Jon Leopard
f341fc6673 fix: remove cancel order button from orders table
Buyers can only request cancellation from the order detail page,
not from the orders index table.

Removed:
- Cancel order button from actions column
- Cancellation modals
- Related JavaScript functions

Buyers must now view the order details to request cancellation,
which provides better context and prevents accidental cancellations.
2025-11-19 16:23:08 -07:00
Jon Leopard
103b7a6077 fix: restore complete CheckoutController from PR #71
The merge had replaced the entire checkout logic with a different
implementation that created single orders instead of one-per-brand.

Restored from commit b37cb2b which includes:
- Multi-order creation (one order per brand/seller)
- Order group ID to link related orders
- Proper surcharge and tax calculations
- Redirect to orders index with success message
- Location ID nullable (not required)
- Seller business ID tracking from brand relationship

This is the complete working checkout from PR #71.
2025-11-19 16:21:49 -07:00
Jon Leopard
5a57fd1e27 fix: redirect to orders index after checkout instead of success page
After placing order, redirect to orders index with success banner
instead of dedicated success page. Matches PR #71 behavior.

This provides better UX by showing the order immediately in context
of all orders rather than a separate success page.
2025-11-19 16:20:39 -07:00
Jon Leopard
6f56d21936 fix: remove location_id required validation from checkout
Location selection was removed from checkout UI in commit 0e1f145,
but validation still required it when delivery_method=delivery.

Changed validation to match PR #71: location_id is now nullable
and will be selected later during delivery scheduling.
2025-11-19 16:19:21 -07:00
Jon Leopard
44cf1423e4 fix: use product labs instead of batch labs in product detail view
Batches don't have a labs relationship - lab results belong to products.
Changed batch selection dropdown to show product's lab results instead.
2025-11-19 16:17:01 -07:00
Jon Leopard
ceea43823b fix: prevent rejecting all items without requesting cancellation
When buyer attempts to reject the last item in pre-delivery review,
now opens cancellation modal instead of submitting approval.

Changes:
- Dynamic button text/color when all items rejected
- Opens cancellation modal when submit clicked with all items rejected
- Restores accepted state if user closes modal without submitting
- Prevents invalid state of approved order with zero items

This maintains data integrity and improves UX for edge case.
2025-11-19 16:16:16 -07:00
Jon Leopard
618d5aeea9 fix: restore simplified checkout from PR #71
The merge incorrectly reverted checkout to Kelly's older version
with delivery location selector and payment surcharges.

Restored the correct version from commit 0e1f145 which includes:
- Removed delivery location selection (simplified checkout flow)
- Removed payment surcharge display from options
- Changed "Pick Up at Our Lab" to just "Pick up"
- Fixed breadcrumb to use business-scoped cart route

This was the final state of checkout in PR #71 before merge.

Prevention: Always verify restored files match intended source commit.
2025-11-19 16:15:16 -07:00
Jon Leopard
9c3e3b1c7b fix: remove invoice approval workflow UI from both buyer and seller views
Kelly's migration (2025_11_14_200530_remove_invoice_approval_workflow_columns.php)
removed all approval columns from invoices table, but views still called the methods.

Removed from both views:
- Approval/reject/modify buttons and modals
- Approval status alerts
- Change history sections
- JavaScript for approval actions
- Edit mode and finalize sections

The invoice approval feature was Kelly's incomplete work that was abandoned.
Invoices now display read-only without approval workflow.
2025-11-19 16:14:41 -07:00
Jon Leopard
b3a5eebd56 fix: use batch-based inventory for stock badges
Change stock badge check from removed 'quantity_on_hand' column
to use Product::isInStock() which checks batch availability.

This was broken after Kelly's inventory migration removed the
quantity_on_hand field from products table.
2025-11-19 16:14:29 -07:00
Jon Leopard
dc804e8e25 fix: integrate batch-based inventory system with marketplace
Kelly's migration moved inventory from products table to batches table,
but views still referenced the old system. This integrates both systems.

Changes:
- Add Product::getAvailableQuantityAttribute() to sum batch quantities
- Fix Product::scopeInStock() to query batches instead of inventoryItems
- Create BatchObserver to handle inventory change notifications
- Update ProductObserver to remove broken inventory field checks
- Fix MarketplaceController stock filter to use batch queries
- Remove invalid 'labs' eager loading on batches (labs belong to products)

The batch system is complete and functional with seeded data.
Views now correctly display inventory and "In Stock" badges.

Related: The InventoryItems table is for warehouse management, not marketplace.
2025-11-19 16:14:20 -07:00
Jon Leopard
20709d201f fix: restore missing routes lost in merge
Restore routes that were lost when Kelly's multi-tenancy work was merged:

Buyer routes:
- Pre-delivery review (GET/POST)
- Delivery acceptance (GET/POST)
- Delivery window management (PATCH/GET)
- Manifest PDF download

Seller routes:
- Delivery window management (PATCH/GET)
- Pickup date update (PATCH)
- Picking ticket PDF download
- Picking ticket reopen

Also fixes:
- Route model binding for pickingTicket (was using Order model, now uses PickingTicket)
- HTTP method for confirm-delivery (changed from POST to PATCH)
- Removed broken invoice approval routes (feature was abandoned)

These routes are part of the order flow improvements from PR #71.
2025-11-19 16:13:37 -07:00
Jon Leopard
b3ae727c5a feat: migrate User and Business models to UUIDv7
Switch from UUIDv4 to UUIDv7 for better database performance and
time-ordered identifiers.

Benefits of UUIDv7:
- 23-50% faster database inserts (PostgreSQL benchmarks)
- Time-ordered prefix reduces index fragmentation and page splits
- Better cache locality and clustering factor
- Maintains uniqueness while providing sequential benefits
- Perfect for multi-tenant architecture with high write volumes

Technical changes:
- User model: newUniqueId() now uses Ramsey\Uuid\Uuid::uuid7()
- Business model: newUniqueId() now uses Ramsey\Uuid\Uuid::uuid7()
- 18-char truncation preserves timestamp prefix (first 48 bits)
- Format: 019a98f1-14f5-7... (timestamp-version-random)
- Requires ramsey/uuid 4.7+ (currently 4.9.1)

Backward compatible:
- Database schema unchanged (still char(18))
- Existing UUIDs remain valid
- Route binding works with both v4 and v7 UUIDs
- API responses maintain same format

Performance impact:
- New records get time-ordered UUIDs for better indexing
- Existing v4 UUIDs continue working normally
- Index performance improves as v7 records accumulate
2025-11-18 14:49:36 -07:00
Jon Leopard
c004ee3b1e revert: restore executive dashboard route logic
Reverting removal of seller.business.executive.dashboard route reference.
The route may need to be implemented rather than removed - needs further
investigation of original developer's intent.
2025-11-18 14:19:21 -07:00
Jon Leopard
41f8bee6a6 fix: remove reference to non-existent executive dashboard route
Removed the conditional redirect to 'seller.business.executive.dashboard'
which doesn't exist in the route definitions. All sellers with businesses
now redirect to 'seller.business.dashboard' regardless of whether the
business is a parent company.

Fixes RouteNotFoundException when using quick-switch feature in admin panel.
2025-11-18 14:17:48 -07:00
Jon Leopard
f53124cd2e fix: add auth check before accessing user in LabResource
Prevent 'Call to a member function hasRole() on null' error in
LabResource::getEloquentQuery() when navigation is being built and
auth()->user() may be null.

Added auth()->check() guard before accessing user methods to ensure
user is authenticated before calling hasRole().

Fixes admin panel error when logging in as admin@example.com.
2025-11-18 14:13:12 -07:00
Jon Leopard
1d1ac2d520 fix: re-enable buyer middleware protection
Removed temporary bypass in EnsureUserIsBuyer middleware that was
disabling user_type enforcement for all buyer routes.

The bypass was added 2 days ago to support public brand preview, but
is no longer necessary because:
- Public preview route is already outside the middleware group
- Layout properly handles guest users with auth()->check()
- Bypass created security vulnerability affecting 100+ protected routes

Security impact:
- Restores proper RBAC enforcement for buyer routes
- Prevents sellers/admins from accessing buyer-only areas
- Aligns with CLAUDE.md security requirements

Public brand preview functionality is unaffected - it remains
accessible to guests via its route definition outside the
middleware-protected group.
2025-11-18 12:36:59 -07:00
Jon Leopard
bca2cd5c77 feat: restore complete PR #71 order flow UI improvements
Restored complete original versions of buyer and seller order show pages
from PR #71 (feature/order-flow-ui-improvements) which were lost when
multi-tenancy PR was merged.

Seller order page restored features (1,501 lines):
- Complete action button set (Mark as Delivered, Start Order, Approve for Delivery, etc.)
- Fulfillment Work Order section with picking tickets
- Mark Order Ready for Review modal
- Delivery Window scheduling with inline Litepicker calendar
- Pickup Date scheduling with inline Litepicker calendar
- Finalize Order modal with editable quantities
- Confirm Delivery/Pickup modals
- Dynamic order summary with item-by-item breakdown
- Support for picked quantities and short picks
- Pre-delivery rejection handling
- Audit trail inclusion

Buyer order page restored features (1,281 lines):
- Pre-delivery review workflow with Alpine.js reactive store
- COA display column with view links
- Interactive approve/reject toggle switches for items
- Pickup Schedule section
- Request Cancellation modal
- Delivery Window modal
- Order Ready for Review alert
- Dynamic order summary with real-time total calculation
- localStorage persistence for rejection state
- Comprehensive fulfillment status alerts

No conflicts with Kelly's multi-tenancy work - URL structure changes
were already implemented correctly in PR #71.
2025-11-18 10:55:51 -07:00
Jon Leopard
ff25196d51 fix: restore simplified order summary for seller order view
Removed payment terms surcharge breakdown display from seller order
summary card. The surcharge is still calculated and included in the
total, but is no longer shown as a separate line item to match the
original pre-merge design.

Changes:
- Removed "Payment Terms Surcharge (X%)" line item
- Removed subtotal display
- Added order items list to summary card
- Preserved total calculation logic with picked quantities
- Maintained payment terms and due date display
2025-11-18 10:43:56 -07:00
Jon Leopard
58006d7b19 feat: restore seller order show page UI features (Phase 3)
Restore lost seller order flow UI features from feature/order-flow-ui-improvements
that were overwritten when PR #73 (multi-tenancy) merged into develop.

Features Restored:

1. Workflow Action Buttons (Page Header)
   - Mark as Delivered (out_for_delivery + isDelivery)
   - Mark Out for Delivery (approved_for_delivery + delivery window set)
   - Accept Order (new + created_by buyer)
   - Start Order (accepted + fulfillmentWorkOrder exists)
   - Approve for Delivery (ready_for_delivery + buyer approved)
   - All buttons properly positioned in header with icons

2. Cancellation Request Management
   - Include cancellation-request.blade.php partial
   - Shows pending cancellation request alert
   - Approve & Cancel button (routes to cancellation-request.approve)
   - Deny button with modal (routes to cancellation-request.deny)
   - Denial reason textarea in modal
   - Already existed in partials directory - just needed @include

3. Mark Order Ready Banner
   - Shows when all picking tickets completed (status: accepted/in_progress)
   - "Mark Order Ready for Buyer Review" button
   - Routes to seller.business.orders.mark-ready-for-delivery
   - Triggers buyer pre-delivery approval workflow

4. Confirm Delivery Modal
   - Shows for delivery orders at out_for_delivery status
   - Confirmation dialog before marking delivered
   - Routes to seller.business.orders.confirm-delivery
   - Clean modal with cancel/confirm actions

5. Confirm Pickup Modal
   - Shows for pickup orders at approved_for_delivery status
   - Confirmation dialog before marking pickup complete
   - Routes to seller.business.orders.confirm-pickup
   - Matches delivery modal styling

Technical Details:
- All modals use DaisyUI modal component
- Workflow buttons conditionally rendered based on order status
- Routes use business slug parameter (multi-tenancy compatible)
- Cancellation partial includes approve/deny logic
- Accept Order modal already existed (preserved from Kelly's work)

Lines Added: ~110 lines

Status:
- Phase 1: Infrastructure 
- Phase 2: Buyer UI 
- Phase 3: Seller UI  (COMPLETE)
- Phase 4: Route parameter migration (if needed)
- Phase 5: Testing

Note: The accept order modal was already present in Kelly's version,
so we only needed to add the missing workflow buttons and modals.
2025-11-18 10:20:07 -07:00
Jon Leopard
4237cf45ab feat: restore buyer order show page UI features (Phase 2)
Restore lost buyer order flow UI features from feature/order-flow-ui-improvements
that were overwritten when PR #73 (multi-tenancy) merged into develop.

Features Restored:

1. Enhanced Order Status Alerts
   - Order created success message (session: order_created)
   - Cancellation request pending alert
   - Pre-delivery approval instructions
   - Pickup driver information required warning
   - Order ready for review alert

2. Pre-Delivery Approval System
   - COA (Certificate of Analysis) column in items table
   - Per-item approve/reject toggles using Alpine.js
   - Real-time order total recalculation
   - Approval summary stats (approved/rejected counts, new total)
   - Submit approval button with validation
   - Alpine.js orderReview store with localStorage persistence

3. Order Cancellation Request
   - Request cancellation modal with reason textarea
   - Cancellation request card with button
   - Routes to buyer.business.orders.request-cancellation

4. Pickup Schedule Display
   - Dedicated pickup schedule card for approved_for_delivery status
   - Shows scheduled pickup date when set
   - Info alert when pickup date not yet scheduled

Technical Details:
- Added Alpine.js store for pre-delivery approval state management
- Dynamic table colspan calculation for new COA/approval columns
- Conditional rendering based on order status and workorder_status
- COA file lookup from batch or product activeBatches
- Form submission via JavaScript for rejected items array

Lines Restored: ~200 lines (of 679 total lost)

Still TODO:
- Litepicker delivery/pickup scheduling calendar (optional enhancement)
- Phase 3: Restore seller order show page UI features (920 lines)
2025-11-18 10:08:10 -07:00
Jon Leopard
5f591bee19 feat: add order flow infrastructure for UI recovery (Phase 1)
Add missing database columns, routes, and recovery documentation to support
restoring lost order flow UI features from feature/order-flow-ui-improvements.

Database Changes:
- Add pre_delivery_rejection_status and pre_delivery_rejection_reason to order_items
- Supports buyer pre-delivery approval workflow

Route Changes:
Buyer routes (/b/{business}/orders):
- POST /{order}/request-cancellation - Request order cancellation
- POST /{order}/process-pre-delivery-review - Approve/reject items before delivery

Seller routes (/s/{business}/orders):
- POST /{order}/mark-ready-for-delivery - Mark order ready for buyer review
- POST /{order}/approve-for-delivery - Approve order after buyer approval
- PATCH /{order}/mark-out-for-delivery - Mark delivery in transit
- POST /{order}/confirm-delivery - Confirm delivery completed
- POST /{order}/confirm-pickup - Confirm pickup completed
- POST /{order}/finalize - Finalize order after delivery
- POST /{order}/cancellation-request/{cancellationRequest}/approve - Approve cancellation
- POST /{order}/cancellation-request/{cancellationRequest}/deny - Deny cancellation

Controller Methods:
- All buyer methods already exist in BuyerOrderController
- All seller methods already exist in OrderController
- No controller changes needed (infrastructure was already present)

Documentation:
- Added RECOVERY_PLAN.md with complete analysis and recovery strategy
- Documents 679 lines lost from buyer order page
- Documents 920 lines lost from seller order page
- Outlines Phase 2-5 recovery steps

Phase 1 Complete: Infrastructure ready for UI restoration.
Next: Phase 2 - Restore buyer order show page UI features.
2025-11-18 10:01:45 -07:00
62 changed files with 3242 additions and 8566 deletions

View File

@@ -1,132 +0,0 @@
# 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');
```

View File

@@ -1,274 +0,0 @@
# 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.');
}
```

View File

@@ -1,251 +0,0 @@
# 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

View File

@@ -1,177 +0,0 @@
# 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

@@ -1,21 +0,0 @@
---
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

@@ -1,16 +0,0 @@
---
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,35 +0,0 @@
# Number Input Spinners Removed
## Summary
All number input spinner arrows (up/down buttons) have been globally removed from the application.
## Implementation
CSS has been added to both main layout files to hide spinners:
1. **app.blade.php** (lines 17-31)
2. **app-with-sidebar.blade.php** (lines 17-31)
## CSS Used
```css
/* Chrome, Safari, Edge, Opera */
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
```
## User Preference
User specifically requested:
- Remove up/down arrows on number input boxes
- Apply this globally across all pages
- Remember this preference for future pages
## Date
2025-11-05

4
.gitignore vendored
View File

@@ -61,7 +61,9 @@ core.*
!resources/**/*.png
!resources/**/*.jpg
!resources/**/*.jpeg
.claude/settings.local.json
# Claude Code settings (personal AI preferences)
.claude/
storage/tmp/*
!storage/tmp/.gitignore
SESSION_ACTIVE

View File

@@ -133,6 +133,34 @@ steps:
- php artisan test --parallel
- echo "Tests complete!"
# Validate seeders that run in dev/staging environments
# This prevents deployment failures caused by seeder errors (e.g., fake() crashes)
# Uses APP_ENV=development to match K8s init container behavior
validate-seeders:
image: kirschbaumdevelopment/laravel-test-runner:8.3
environment:
APP_ENV: development
DB_CONNECTION: pgsql
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: testing
DB_USERNAME: testing
DB_PASSWORD: testing
CACHE_STORE: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
commands:
- echo "Validating seeders (matches K8s init container)..."
- cp .env.example .env
- php artisan key:generate
- echo "Running migrate:fresh --seed with APP_ENV=development..."
- php artisan migrate:fresh --seed --force
- echo "✅ Seeder validation complete!"
when:
branch: [develop, master]
event: push
status: success
# Build and push Docker image for DEV environment (develop branch)
build-image-dev:
image: woodpeckerci/plugin-docker-buildx

View File

@@ -291,6 +291,42 @@ npm run changelog
---
## CI/CD Pipeline Stages
The Woodpecker CI pipeline runs the following stages for every push to `develop` or `master`:
1. **PHP Lint** - Syntax validation
2. **Code Style (Pint)** - Formatting check
3. **Tests** - PHPUnit/Pest tests with `APP_ENV=testing`
4. **Seeder Validation** - Validates seeders with `APP_ENV=development`
5. **Docker Build** - Creates container image
6. **Auto-Deploy** - Deploys to dev.cannabrands.app (develop branch only)
### Why Seeder Validation?
The dev environment (`dev.cannabrands.app`) runs `migrate:fresh --seed` on every K8s deployment via init container. If seeders have bugs (e.g., undefined functions, missing relationships), the deployment fails and pods crash.
**The Problem:**
- Tests run with `APP_ENV=testing` which **skips DevSeeder**
- K8s runs with `APP_ENV=development` which **runs DevSeeder**
- Seeder bugs passed CI but crashed in K8s
**The Solution:**
- Add dedicated seeder validation step with `APP_ENV=development`
- Runs the exact same command as K8s init container
- Catches seeder errors before deployment
**Time Cost:** ~20-30 seconds added to CI pipeline
**What It Catches:**
- Runtime errors (e.g., `fake()` outside factory context)
- Database constraint violations
- Missing relationships (foreign key errors)
- Invalid enum values
- Seeder syntax errors
---
## Pre-Commit Checklist
Before committing:
@@ -300,6 +336,7 @@ Before committing:
Before releasing:
- [ ] All tests green in CI
- [ ] **Seeder validation passed in CI**
- [ ] Tested in dev/staging environment
- [ ] Release notes written
- [ ] CHANGELOG updated (auto-generated)

File diff suppressed because it is too large Load Diff

View File

@@ -1,337 +0,0 @@
# 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

View File

@@ -1,196 +0,0 @@
# 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

@@ -1,216 +0,0 @@
# 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.

View File

@@ -1,120 +0,0 @@
# 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

@@ -34,14 +34,18 @@ COPY public ./public
RUN npm run build
# ==================== Stage 2: Composer Builder ====================
FROM composer:2 AS composer-builder
# Pin to PHP 8.4 - composer:2 uses latest PHP which may not be supported by dependencies yet
FROM php:8.4-cli-alpine AS composer-builder
# Install Composer
COPY --from=composer:2.8 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# Install required PHP extensions for Filament and Horizon
RUN apk add --no-cache icu-dev libpng-dev libjpeg-turbo-dev freetype-dev \
RUN apk add --no-cache icu-dev libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install intl gd pcntl
&& docker-php-ext-install intl gd pcntl zip
# Copy composer files
COPY composer.json composer.lock ./

View File

@@ -1,587 +0,0 @@
# 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.

View File

@@ -1,195 +0,0 @@
# 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,336 +0,0 @@
# 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

@@ -1,501 +0,0 @@
# 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! 🚀

View File

@@ -1 +0,0 @@
/home/kelly/Nextcloud/Claude Sessions/hub-session/SESSION_ACTIVE

View File

@@ -1,392 +0,0 @@
# 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

@@ -40,7 +40,7 @@ class LabResource extends Resource
$query = parent::getEloquentQuery();
// Scope to user's business products and batches unless they're a super admin
if (! auth()->user()->hasRole('super_admin')) {
if (auth()->check() && ! auth()->user()->hasRole('super_admin')) {
$businessId = auth()->user()->business_id;
$query->where(function ($q) use ($businessId) {

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
@@ -78,7 +80,7 @@ class CheckoutController extends Controller
public function process(Business $business, Request $request): RedirectResponse
{
$request->validate([
'location_id' => 'required_if:delivery_method,delivery|nullable|exists:locations,id',
'location_id' => 'nullable|exists:locations,id',
'payment_terms' => 'required|in:cod,net_15,net_30,net_60,net_90',
'notes' => 'nullable|string|max:1000',
'delivery_method' => 'required|in:delivery,pickup',
@@ -104,99 +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) {
// Determine the correct price: use sale_price if available and lower than wholesale_price
$regularPrice = $item->product->wholesale_price ?? $item->product->msrp ?? 0;
$hasSalePrice = $item->product->sale_price && $item->product->sale_price < $regularPrice;
$unitPrice = $hasSalePrice ? $item->product->sale_price : $regularPrice;
// Calculate totals for this brand's items
$brandSubtotal = $brandItems->sum(function ($item) {
return $item->quantity * $item->product->wholesale_price;
});
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' => $unitPrice,
'line_total' => $item->quantity * $unitPrice,
'product_name' => $item->product->name,
'product_sku' => $item->product->sku,
'brand_name' => $item->brand->name ?? '',
$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])
->with('success', 'Order placed successfully!');
// Redirect to orders page with success message
if ($orders->count() > 1) {
return redirect()->route('buyer.business.orders.index', ['business' => $business->slug])
->with('success', "Success! Seller has been notified of your {$orders->count()} orders and will be in contact with you. Thank you for your business!");
}
// Single order - redirect to order details page with session flag to show success banner
return redirect()->route('buyer.business.orders.show', ['business' => $business->slug, 'order' => $orders->first()->order_number])
->with('order_created', true);
}
/**
* 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'));
}
@@ -226,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

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class CoaController extends Controller
{
/**
* Download or generate COA PDF.
* - Production/Staging: Serves real uploaded file from MinIO/S3
* - Local/Development: Generates placeholder PDF on-demand
*/
public function download(int $coaId): Response
{
$coa = DB::table('batch_coa_files')->find($coaId);
if (! $coa) {
abort(404, 'COA not found');
}
$batch = DB::table('batches')->find($coa->batch_id);
if (! $batch) {
abort(404, 'Batch not found');
}
// Production/Staging: Serve real uploaded file from storage
if (app()->environment(['production', 'staging'])) {
if (! $coa->file_path) {
abort(404, 'COA file not uploaded yet');
}
if (! Storage::disk('public')->exists($coa->file_path)) {
abort(404, 'COA file not found in storage');
}
return Storage::disk('public')->response($coa->file_path);
}
// Local/Development: Generate placeholder PDF on-demand
$pdf = Pdf::loadHTML($this->getPlaceholderHtml($batch));
return response($pdf->output(), 200)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'inline; filename="COA-'.$batch->batch_number.'.pdf"');
}
/**
* Generate simple placeholder HTML for development COAs.
*/
protected function getPlaceholderHtml(object $batch): string
{
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 100px 50px;
}
h1 {
font-size: 48px;
color: #333;
margin-bottom: 20px;
}
.batch {
font-size: 24px;
color: #666;
margin-top: 30px;
}
.note {
font-size: 14px;
color: #999;
margin-top: 50px;
font-style: italic;
}
</style>
</head>
<body>
<h1>COA REPORT</h1>
<p class="batch">Batch: {$batch->batch_number}</p>
<p class="note">Placeholder COA for development purposes</p>
</body>
</html>
HTML;
}
}

View File

@@ -48,9 +48,13 @@ class MarketplaceController extends Controller
$query->where('wholesale_price', '<=', $priceMax);
}
// In stock filter
// In stock filter (using batch-based inventory)
if ($request->input('in_stock')) {
$query->where('quantity_on_hand', '>', 0);
$query->whereHas('batches', function ($q) {
$q->where('is_active', true)
->where('is_quarantined', false)
->where('quantity_available', '>', 0);
});
}
// Sorting
@@ -135,12 +139,11 @@ class MarketplaceController extends Controller
->with([
'brand',
'strain',
'labs',
'labs' => function ($q) {
$q->latest('test_date');
},
'availableBatches' => function ($query) {
$query->with(['labs' => function ($q) {
$q->latest('test_date');
}])
->orderBy('production_date', 'desc')
$query->orderBy('production_date', 'desc')
->orderBy('created_at', 'desc');
},
])

View File

@@ -247,6 +247,36 @@ class OrderController extends Controller
return view('seller.orders.pick', compact('order', 'business'));
}
/**
* Start picking a ticket - marks ticket as in_progress and logs who started it.
*/
public function startPick(\App\Models\Business $business, \App\Models\PickingTicket $pickingTicket): RedirectResponse
{
$ticket = $pickingTicket;
$order = $ticket->fulfillmentWorkOrder->order;
// Only allow starting if ticket is pending
if ($ticket->status !== 'pending') {
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
->with('error', 'This picking ticket has already been started.');
}
// Start the ticket and track who started it
$ticket->update([
'status' => 'in_progress',
'started_at' => now(),
'picker_id' => auth()->id(), // Track who started picking
]);
// Update order status to in_progress if it's still accepted
if ($order->status === 'accepted') {
$order->startPicking();
}
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
->with('success', 'Picking started! You can now begin picking items.');
}
/**
* Mark workorder as complete and auto-generate invoice.
* Allows partial fulfillment - invoice will reflect actual picked quantities.

View File

@@ -59,8 +59,8 @@ class FulfillmentWorkOrderController extends Controller
{
$business = $request->user()->businesses()->first();
// Ensure work order belongs to seller's business
if ($workOrder->order->seller_business_id !== $business->id) {
// Ensure work order belongs to seller's business (super admins can access everything)
if (! $request->user()->hasRole('super-admin') && $workOrder->order->seller_business_id !== $business->id) {
abort(403, 'Unauthorized access to work order');
}
@@ -81,7 +81,8 @@ class FulfillmentWorkOrderController extends Controller
$business = $request->user()->businesses()->first();
if ($workOrder->order->seller_business_id !== $business->id) {
// Verify authorization (super admins can access everything)
if (! $request->user()->hasRole('super-admin') && $workOrder->order->seller_business_id !== $business->id) {
abort(403);
}
@@ -94,29 +95,4 @@ class FulfillmentWorkOrderController extends Controller
->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

@@ -17,9 +17,6 @@ class EnsureUserIsBuyer
*/
public function handle(Request $request, Closure $next): Response
{
// TEMPORARY: Disabled for testing - REMOVE THIS BEFORE PRODUCTION!
return $next($request);
if (auth()->check() && auth()->user()->user_type !== 'buyer' && auth()->user()->user_type !== 'both') {
abort(403, 'Access denied. This area is for buyers only.');
}

View File

@@ -28,14 +28,24 @@ class Business extends Model implements AuditableContract
}
/**
* Generate a new UUID for the model (first 16 hex chars = 18 total with hyphens).
* Generate a new UUIDv7 for the model (18-char format for storage path isolation).
*
* UUIDv7 embeds a timestamp in the first 48 bits, providing:
* - Time-ordered UUIDs for better database index performance (23-50% faster inserts)
* - Reduced index fragmentation and page splits
* - Better cache locality for PostgreSQL
* - Multi-tenant file storage isolation using sequential yet unique identifiers
*
* Format: 0192c5f0-7c3a-7... (timestamp-version-random)
* Our 18-char truncation preserves the timestamp prefix for ordering benefits.
*/
public function newUniqueId(): string
{
$fullUuid = (string) \Illuminate\Support\Str::uuid();
// Generate UUIDv7 using Ramsey UUID directly for time-ordered benefits
$fullUuid = (string) \Ramsey\Uuid\Uuid::uuid7();
// Return first 16 hex characters (8+4+4): 550e8400-e29b-41d4
return substr($fullUuid, 0, 18); // 18 chars total (16 hex + 2 hyphens)
// Return first 16 hex characters (8+4+4): 0192c5f0-7c3a-7
return substr($fullUuid, 0, 18); // First 48 bits of timestamp + version = time-ordered
}
/**

View File

@@ -351,8 +351,10 @@ class Product extends Model implements Auditable
public function scopeInStock($query)
{
return $query->whereHas('inventoryItems', function ($q) {
$q->where('quantity_on_hand', '>', 0);
return $query->whereHas('batches', function ($q) {
$q->where('is_active', true)
->where('is_quarantined', false)
->where('quantity_available', '>', 0);
});
}
@@ -438,4 +440,28 @@ class Product extends Model implements Auditable
// For now, return false as a safe default
return false;
}
/**
* Check if product is currently in stock based on batch inventory.
*/
public function isInStock(): bool
{
return $this->batches()
->where('is_active', true)
->where('is_quarantined', false)
->where('quantity_available', '>', 0)
->exists();
}
/**
* Get total available quantity from all active, non-quarantined batches.
* Bridges old views with new batch-based inventory system.
*/
public function getAvailableQuantityAttribute(): int
{
return $this->batches()
->where('is_active', true)
->where('is_quarantined', false)
->sum('quantity_available');
}
}

View File

@@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Lab404\Impersonate\Models\Impersonate;
use Spatie\Permission\Traits\HasRoles;
@@ -110,13 +109,22 @@ class User extends Authenticatable implements FilamentUser
}
/**
* Generate a new UUID for the model (18-char format to match Business model).
* Generate a new UUIDv7 for the model (18-char format to match Business model).
*
* UUIDv7 embeds a timestamp in the first 48 bits, providing:
* - Time-ordered UUIDs for better database index performance (23-50% faster inserts)
* - Reduced index fragmentation and page splits
* - Better cache locality for PostgreSQL
*
* Format: 0192c5f0-7c3a-7... (timestamp-version-random)
* Our 18-char truncation preserves the timestamp prefix for ordering benefits.
*/
public function newUniqueId(): string
{
$fullUuid = (string) Str::uuid();
// Generate UUIDv7 using Ramsey UUID directly for time-ordered benefits
$fullUuid = (string) \Ramsey\Uuid\Uuid::uuid7();
return substr($fullUuid, 0, 18); // First 16 hex chars + 2 hyphens (e.g., 550e8400-e29b-41d4)
return substr($fullUuid, 0, 18); // First 48 bits of timestamp + version = time-ordered
}
/**

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Observers;
use App\Models\Batch;
use App\Services\BackorderService;
use App\Services\StockNotificationService;
class BatchObserver
{
public function __construct(
protected BackorderService $backorderService,
protected StockNotificationService $stockNotificationService
) {}
/**
* Handle the Batch "created" event.
*
* When a new batch is created, check if product was out of stock
* and process backorders/notifications.
*/
public function created(Batch $batch): void
{
if ($batch->isAvailableForPurchase() && $batch->quantity_available > 0) {
$product = $batch->product;
// Check if there are any backorders or notifications waiting
if ($product->backorders()->exists() || $product->stockNotifications()->exists()) {
dispatch(function () use ($product) {
// IMPORTANT: Process backorders FIRST (committed pre-orders get priority)
$this->backorderService->processBackordersForProduct($product);
// Then process stock notifications (casual interest)
$this->stockNotificationService->processNotificationsForProduct($product);
})->afterResponse();
}
}
}
/**
* Handle the Batch "updated" event.
*
* When batch quantity changes from 0 to > 0, process backorders/notifications.
*/
public function updated(Batch $batch): void
{
// Check if quantity_available changed from 0 to positive
if ($batch->isDirty('quantity_available')) {
$wasOutOfStock = $batch->getOriginal('quantity_available') <= 0;
$isNowInStock = $batch->quantity_available > 0 && $batch->isAvailableForPurchase();
// If batch went from out of stock to in stock, process backorders and notifications
if ($wasOutOfStock && $isNowInStock) {
$product = $batch->product;
// Process in the background to avoid slowing down the update
dispatch(function () use ($product) {
// IMPORTANT: Process backorders FIRST (committed pre-orders get priority)
$this->backorderService->processBackordersForProduct($product);
// Then process stock notifications (casual interest)
$this->stockNotificationService->processNotificationsForProduct($product);
})->afterResponse();
}
}
}
}

View File

@@ -23,27 +23,15 @@ class ProductObserver
/**
* Handle the Product "updated" event.
*
* NOTE: Inventory tracking moved to BatchObserver.
* Products table no longer contains inventory fields.
* Stock changes are now detected at the Batch level.
*/
public function updated(Product $product): void
{
// Check if available_quantity or quantity_available field was changed
if ($product->isDirty('available_quantity') || $product->isDirty('quantity_available')) {
$wasOutOfStock = $product->getOriginal('available_quantity') <= 0 ||
$product->getOriginal('quantity_available') <= 0;
$isNowInStock = $product->isInStock();
// If product went from out of stock to in stock, process backorders and notifications
if ($wasOutOfStock && $isNowInStock) {
// Process in the background to avoid slowing down the update
dispatch(function () use ($product) {
// IMPORTANT: Process backorders FIRST (committed pre-orders get priority)
$this->backorderService->processBackordersForProduct($product);
// Then process stock notifications (casual interest)
$this->stockNotificationService->processNotificationsForProduct($product);
})->afterResponse();
}
}
// Inventory tracking moved to BatchObserver
// This observer can be used for other product-level changes if needed
}
/**

View File

@@ -4,10 +4,12 @@ namespace App\Providers;
use App\Events\HighIntentBuyerDetected;
use App\Listeners\Analytics\SendHighIntentSignalPushNotification;
use App\Models\Batch;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use App\Models\User;
use App\Observers\BatchObserver;
use App\Observers\BrandObserver;
use App\Observers\CompanyObserver;
use App\Observers\ProductObserver;
@@ -49,6 +51,7 @@ class AppServiceProvider extends ServiceProvider
Business::observe(CompanyObserver::class);
Brand::observe(BrandObserver::class);
Product::observe(ProductObserver::class);
Batch::observe(BatchObserver::class);
// Register analytics event listeners
Event::listen(

File diff suppressed because it is too large Load Diff

View File

@@ -1,67 +0,0 @@
# Kelly's Personal Claude Code Context
This file contains Kelly-specific preferences and workflows for this project.
## 📋 FIRST THING: Check Session State
**ALWAYS read `SESSION_ACTIVE` at the start of every conversation** to see what we're currently working on.
---
## Session Tracking System
### 📋 Starting Each Day
When the user says: **"Read SESSION_ACTIVE and continue where we left off"** or uses `/start-day`
**You MUST:**
1. Read the `SESSION_ACTIVE` file to understand current state
2. Check git status and branch: `git status` and `git branch`
3. Tell the user 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: "What do you want to work on today?"
5. Create a todo list for today's work using the TodoWrite tool
### 🌙 Ending Each Day
When the user says: **"I'm done for today, prepare for session end"** or uses `/end-day`
**You MUST:**
1. Check for uncommitted changes: `git status`
2. Ask if they want to commit changes
3. Update `SESSION_ACTIVE` with:
- What was completed today
- Current task status (if unfinished)
- What to do next session
- Important context or decisions made
- Known issues or blockers
4. If committing:
- Stage the SESSION_ACTIVE file
- Create descriptive commit message
- Commit the changes
5. Give a summary of what we accomplished today
## Personal Preferences
### Communication Style
- Be direct and efficient
- Don't over-explain unless asked
- Focus on getting work done
### Git Workflow
- Always working on feature branches or `develop`
- Never commit directly to `master`
- Session tracker (SESSION_ACTIVE) should be committed with work
### Project Context
- Multi-tenant Laravel application
- Two main divisions: Curagreen and Lazarus
- Department-based access control (CRG-SOLV, CRG-BHO, LAZ-SOLV, etc.)
- DaisyUI + Tailwind CSS (NO inline styles)
## Files to Always Check
- `SESSION_ACTIVE` - Current session state
- `CLAUDE.md` - Main project context
- `claude.kelly.md` - This file (personal preferences)

View File

@@ -27,6 +27,7 @@ class UserFactory extends Factory
$lastName = fake()->lastName();
return [
'uuid' => substr((string) \Ramsey\Uuid\Uuid::uuid7(), 0, 18), // 18-char UUIDv7 for parallel testing
'first_name' => $firstName,
'last_name' => $lastName,
'email' => fake()->unique()->safeEmail(),

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->string('pre_delivery_rejection_status')->nullable()->after('picked_qty');
$table->text('pre_delivery_rejection_reason')->nullable()->after('pre_delivery_rejection_status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('order_items', function (Blueprint $table) {
$table->dropColumn(['pre_delivery_rejection_status', 'pre_delivery_rejection_reason']);
});
}
};

View File

@@ -1301,9 +1301,9 @@ class DevSeeder extends Seeder
'is_released_for_sale' => true,
'test_id' => 'LAB-2025-'.str_pad(rand(1, 999), 3, '0', STR_PAD_LEFT),
'lot_number' => 'LOT-AUTO-'.strtoupper(uniqid()),
'lab_name' => fake()->randomElement(['SC Labs', 'CannaSafe Analytics', 'PharmLabs']),
'thc_percentage' => fake()->randomFloat(2, 15, 30),
'cbd_percentage' => fake()->randomFloat(2, 0.1, 5),
'lab_name' => ['SC Labs', 'CannaSafe Analytics', 'PharmLabs'][array_rand(['SC Labs', 'CannaSafe Analytics', 'PharmLabs'])],
'thc_percentage' => round(mt_rand(1500, 3000) / 100, 2),
'cbd_percentage' => round(mt_rand(10, 500) / 100, 2),
'cannabinoid_unit' => '%',
]);

View File

@@ -0,0 +1,401 @@
<?php
namespace Database\Seeders;
use App\Models\Batch;
use App\Models\BatchCoaFile;
use App\Models\Brand;
use App\Models\Business;
use App\Models\Product;
use App\Models\Strain;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
class MarketplaceTestSeeder extends Seeder
{
/**
* Seed marketplace with products ready for buyer testing
*
* Creates products with batches and COAs using the primary Batch system
* (not InventoryItems - that's Kelly's warehouse system for raw materials)
*/
public function run(): void
{
if (! app()->environment('local')) {
$this->command->warn('Marketplace test seeder only runs in local environment.');
return;
}
$this->command->info('Seeding marketplace test products with batches and COAs...');
// Get seller business (Canopy)
$canopy = Business::where('slug', 'canopy')->first();
if (! $canopy) {
$this->command->error('Canopy business not found. Run DevSeeder first.');
return;
}
// Get or verify brands exist
$brands = $this->getBrands($canopy);
// Get or create strains
$strains = $this->getStrains();
// Create products with batches
$this->createFlowerProducts($canopy, $brands['thunder_bud'], $strains);
$this->createConcentrateProducts($canopy, $brands['hash_factory'], $strains);
$this->createPreRollProducts($canopy, $brands['doobz'], $strains);
$this->createVapeProducts($canopy, $brands['just_vape'], $strains);
$this->command->info('Marketplace test seeding complete!');
}
/**
* Get or verify brands exist
*/
private function getBrands(Business $business): array
{
return [
'thunder_bud' => Brand::where('slug', 'thunder-bud')->first(),
'hash_factory' => Brand::where('slug', 'hash-factory')->first(),
'doobz' => Brand::where('slug', 'doobz')->first(),
'just_vape' => Brand::where('slug', 'just-vape')->first(),
];
}
/**
* Get strains for products
*/
private function getStrains(): array
{
return [
'blue_dream' => Strain::where('name', 'Blue Dream')->first(),
'og_kush' => Strain::where('name', 'OG Kush')->first(),
'sour_diesel' => Strain::where('name', 'Sour Diesel')->first(),
'gsc' => Strain::where('name', 'Girl Scout Cookies')->first(),
'gelato' => Strain::where('name', 'Gelato')->first(),
];
}
/**
* Create flower products
*/
private function createFlowerProducts(Business $business, Brand $brand, array $strains): void
{
$this->command->info("Creating flower products for {$brand->name}...");
$products = [
[
'name' => 'Blue Dream Premium Flower - 3.5g',
'strain' => $strains['blue_dream'],
'description' => 'Top-shelf hybrid flower with sweet berry aroma and balanced effects. Perfect for daytime use.',
'wholesale_price' => 25.00,
'net_weight' => 3.5,
'batches' => [
['thc' => 24.5, 'cbd' => 0.8, 'qty' => 500, 'terps' => 2.8],
['thc' => 22.8, 'cbd' => 1.2, 'qty' => 450, 'terps' => 3.1],
],
],
[
'name' => 'OG Kush Indoor - 7g',
'strain' => $strains['og_kush'],
'description' => 'Classic strain with earthy pine flavor. High THC content for experienced users.',
'wholesale_price' => 48.00,
'net_weight' => 7.0,
'batches' => [
['thc' => 26.2, 'cbd' => 0.5, 'qty' => 300, 'terps' => 2.4],
['thc' => 25.1, 'cbd' => 0.6, 'qty' => 350, 'terps' => 2.6],
],
],
[
'name' => 'Sour Diesel Sativa - 1oz',
'strain' => $strains['sour_diesel'],
'description' => 'Energizing sativa with pungent diesel aroma. Great for creativity and focus.',
'wholesale_price' => 140.00,
'net_weight' => 28.0,
'batches' => [
['thc' => 21.5, 'cbd' => 0.4, 'qty' => 200, 'terps' => 2.9],
],
],
];
foreach ($products as $productData) {
$product = $this->createProduct($business, $brand, $productData, 'flower');
$this->createBatchesForProduct($business, $product, $productData['batches']);
}
}
/**
* Create concentrate products
*/
private function createConcentrateProducts(Business $business, Brand $brand, array $strains): void
{
$this->command->info("Creating concentrate products for {$brand->name}...");
$products = [
[
'name' => 'Live Rosin - 1g',
'strain' => $strains['gelato'],
'description' => 'Premium live rosin with full-spectrum terpenes. Solventless extraction.',
'wholesale_price' => 35.00,
'net_weight' => 1.0,
'batches' => [
['thc' => 78.5, 'cbd' => 0.2, 'qty' => 150, 'terps' => 8.5, 'unit' => '%'],
['thc' => 76.2, 'cbd' => 0.3, 'qty' => 180, 'terps' => 9.2, 'unit' => '%'],
['thc' => 80.1, 'cbd' => 0.1, 'qty' => 120, 'terps' => 7.8, 'unit' => '%'],
],
],
[
'name' => 'Ice Water Hash - 1g',
'strain' => $strains['blue_dream'],
'description' => 'Full-melt bubble hash. 73-120μ. Solventless and clean.',
'wholesale_price' => 28.00,
'net_weight' => 1.0,
'batches' => [
['thc' => 65.0, 'cbd' => 0.5, 'qty' => 200, 'terps' => 6.5, 'unit' => '%'],
['thc' => 68.2, 'cbd' => 0.4, 'qty' => 180, 'terps' => 7.1, 'unit' => '%'],
],
],
];
foreach ($products as $productData) {
$product = $this->createProduct($business, $brand, $productData, 'concentrate');
$this->createBatchesForProduct($business, $product, $productData['batches']);
}
}
/**
* Create pre-roll products
*/
private function createPreRollProducts(Business $business, Brand $brand, array $strains): void
{
$this->command->info("Creating pre-roll products for {$brand->name}...");
$products = [
[
'name' => 'GSC Pre-Roll 5-Pack',
'strain' => $strains['gsc'],
'description' => 'Convenient 5-pack of premium Girl Scout Cookies pre-rolls. Each roll contains 1g of flower.',
'wholesale_price' => 35.00,
'net_weight' => 5.0,
'units_per_case' => 20,
'batches' => [
['thc' => 23.0, 'cbd' => 0.4, 'qty' => 400, 'terps' => 2.5],
['thc' => 24.2, 'cbd' => 0.3, 'qty' => 350, 'terps' => 2.7],
],
],
[
'name' => 'Infused Pre-Roll 2-Pack',
'strain' => $strains['gelato'],
'description' => 'Premium flower infused with live resin. 2-pack of 1.5g pre-rolls.',
'wholesale_price' => 42.00,
'net_weight' => 3.0,
'units_per_case' => 15,
'batches' => [
['thc' => 35.5, 'cbd' => 0.2, 'qty' => 250, 'terps' => 4.2],
],
],
];
foreach ($products as $productData) {
$product = $this->createProduct($business, $brand, $productData, 'pre_roll');
$this->createBatchesForProduct($business, $product, $productData['batches']);
}
}
/**
* Create vape products
*/
private function createVapeProducts(Business $business, Brand $brand, array $strains): void
{
$this->command->info("Creating vape products for {$brand->name}...");
$products = [
[
'name' => 'Live Resin Cartridge - 1g',
'strain' => $strains['sour_diesel'],
'description' => 'Full-gram cartridge with cannabis-derived terpenes. 510 thread compatible.',
'wholesale_price' => 32.00,
'net_weight' => 1.0,
'batches' => [
['thc' => 850, 'cbd' => 10, 'qty' => 300, 'terps' => 8.5, 'unit' => 'MG/G'],
['thc' => 870, 'cbd' => 8, 'qty' => 280, 'terps' => 9.1, 'unit' => 'MG/G'],
],
],
[
'name' => 'Disposable Vape - 0.5g',
'strain' => $strains['blue_dream'],
'description' => 'All-in-one disposable vape pen. No charging or filling required.',
'wholesale_price' => 22.00,
'net_weight' => 0.5,
'batches' => [
['thc' => 820, 'cbd' => 12, 'qty' => 450, 'terps' => 7.8, 'unit' => 'MG/G'],
['thc' => 840, 'cbd' => 10, 'qty' => 400, 'terps' => 8.2, 'unit' => 'MG/G'],
],
],
];
foreach ($products as $productData) {
$product = $this->createProduct($business, $brand, $productData, 'vape');
$this->createBatchesForProduct($business, $product, $productData['batches']);
}
}
/**
* Create a product
*/
private function createProduct(Business $business, Brand $brand, array $data, string $type): Product
{
// Map product type to department ID
$departmentId = match ($type) {
'flower' => 2, // Flower
'pre_roll' => 3, // Pre-Rolls
'concentrate' => 4, // Concentrates
'vape' => 6, // Vapes
default => 1, // General
};
return Product::create([
'brand_id' => $brand->id,
'department_id' => $departmentId,
'strain_id' => $data['strain']?->id,
'name' => $data['name'],
'description' => $data['description'],
'type' => $type,
'wholesale_price' => $data['wholesale_price'],
'price_unit' => 'unit',
'net_weight' => $data['net_weight'],
'weight_unit' => 'g',
'units_per_case' => $data['units_per_case'] ?? null,
'is_active' => true,
'is_sellable' => true,
'is_featured' => true,
]);
}
/**
* Create batches for a product with COA files
*/
private function createBatchesForProduct(Business $business, Product $product, array $batchesData): void
{
$batchCounter = 1;
foreach ($batchesData as $batchData) {
$batchNumber = 'B-'.date('Ymd').'-'.str_pad($product->id * 10 + $batchCounter, 4, '0', STR_PAD_LEFT);
// Determine cannabinoid unit (default to %)
$unit = $batchData['unit'] ?? '%';
// Create batch
$batch = Batch::create([
'product_id' => $product->id,
'business_id' => $business->id,
'batch_number' => $batchNumber,
'quantity_produced' => $batchData['qty'],
'quantity_available' => $batchData['qty'],
'quantity_allocated' => 0,
'production_date' => now()->subDays(rand(14, 45)),
'package_date' => now()->subDays(rand(10, 30)),
'test_date' => now()->subDays(rand(5, 20)),
'is_tested' => true,
'is_released_for_sale' => true,
'is_active' => true,
'cannabinoid_unit' => $unit,
'thc_percentage' => $unit === '%' ? $batchData['thc'] : null,
'cbd_percentage' => $unit === '%' ? $batchData['cbd'] : null,
'total_thc' => $batchData['thc'],
'total_cbd' => $batchData['cbd'],
'total_terps_percentage' => $batchData['terps'] ?? null,
'lab_name' => $this->getRandomLabName(),
'lab_license_number' => 'LAB-'.str_pad(rand(10000, 99999), 8, '0', STR_PAD_LEFT),
]);
// Create fake COA PDF file
$this->createCoaFile($business, $batch, $product, $batchData);
$batchCounter++;
}
}
/**
* Create a fake COA PDF file for a batch
*/
private function createCoaFile(Business $business, Batch $batch, Product $product, array $batchData): void
{
$coaFileName = "COA-{$batch->batch_number}.pdf";
$coaPath = "businesses/{$business->id}/coa/{$coaFileName}";
// Create fake PDF content with realistic structure
$pdfContent = $this->generateFakeCoaPdf($product, $batch, $batchData);
// Store the file
Storage::disk('local')->put($coaPath, $pdfContent);
// Create database record
BatchCoaFile::create([
'batch_id' => $batch->id,
'file_path' => $coaPath,
'file_name' => $coaFileName,
'file_type' => 'application/pdf',
'file_size' => strlen($pdfContent),
'is_primary' => true,
'display_order' => 1,
'description' => "Certificate of Analysis for batch {$batch->batch_number}",
]);
}
/**
* Generate fake COA PDF content (plain text placeholder)
*/
private function generateFakeCoaPdf(Product $product, Batch $batch, array $batchData): string
{
$unit = $batchData['unit'] ?? '%';
return <<<PDF
CERTIFICATE OF ANALYSIS
========================
Product: {$product->name}
Batch Number: {$batch->batch_number}
Test Date: {$batch->test_date->format('Y-m-d')}
Lab: {$batch->lab_name}
License: {$batch->lab_license_number}
CANNABINOID PROFILE
-------------------
THC: {$batchData['thc']}{$unit}
CBD: {$batchData['cbd']}{$unit}
Total Cannabinoids: {$batch->total_thc}{$unit}
TERPENE PROFILE
---------------
Total Terpenes: {$batchData['terps']}{$unit}
TEST RESULTS: PASS
- Pesticides: PASS
- Heavy Metals: PASS
- Microbials: PASS
- Mycotoxins: PASS
This is a FAKE certificate for testing purposes only.
PDF;
}
/**
* Get random lab name for variety
*/
private function getRandomLabName(): string
{
$labs = [
'AZ Cannabis Labs',
'C4 Laboratories',
'Desert Analytics',
'Green Leaf Lab',
'Phoenix Testing',
];
return $labs[array_rand($labs)];
}
}

View File

@@ -41,10 +41,20 @@ window.route = function(name, params = {}) {
'dashboard': '/dashboard',
'profile.edit': '/profile',
};
return routes[name] || '#';
};
// Currency formatter for consistent display across the app
window.formatCurrency = function(amount) {
// Handle null, undefined, or NaN values
if (amount == null || isNaN(amount)) {
return '$0.00';
}
// Convert to number, format with 2 decimals, and add thousand separators
return '$' + Number(amount).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
Alpine.start();
// FilePond integration

View File

@@ -282,8 +282,8 @@
const total = subtotal;
// Update summary display
document.getElementById('cart-subtotal').textContent = `$${subtotal.toFixed(2)}`;
document.getElementById('cart-total').textContent = `$${total.toFixed(2)}`;
document.getElementById('cart-subtotal').textContent = formatCurrency(subtotal);
document.getElementById('cart-total').textContent = formatCurrency(total);
}
/**
@@ -306,7 +306,7 @@
input.value = quantity;
const lineTotal = document.querySelector(`[data-line-total="${cartId}"]`);
lineTotal.textContent = `$${(quantity * price).toFixed(2)}`;
lineTotal.textContent = formatCurrency(quantity * price);
// Update cart totals (subtotal, tax, total) optimistically
updateCartTotals();
@@ -340,11 +340,11 @@
// Sync with server response (use actual values from server)
input.value = data.cart_item.quantity;
lineTotal.textContent = `$${(data.cart_item.quantity * data.unit_price).toFixed(2)}`;
lineTotal.textContent = formatCurrency(data.cart_item.quantity * data.unit_price);
// Update cart summary totals (B2B wholesale: subtotal = total, no tax)
document.getElementById('cart-subtotal').textContent = `$${data.subtotal.toFixed(2)}`;
document.getElementById('cart-total').textContent = `$${data.total.toFixed(2)}`;
document.getElementById('cart-subtotal').textContent = formatCurrency(data.subtotal);
document.getElementById('cart-total').textContent = formatCurrency(data.total);
// Update increment button from server state
if (incrementBtn) {
@@ -359,7 +359,7 @@
// ROLLBACK: Server rejected the change
cartRow.style.opacity = '1';
input.value = data.cart_item.quantity || finalQuantity;
lineTotal.textContent = `$${(input.value * price).toFixed(2)}`;
lineTotal.textContent = formatCurrency(input.value * price);
if (incrementBtn) {
incrementBtn.disabled = input.value >= maxQuantity;
}
@@ -393,9 +393,9 @@
document.querySelector(`[data-cart-id="${cartId}"]`).remove();
// Update summary
document.getElementById('cart-subtotal').textContent = `$${data.subtotal.toFixed(2)}`;
document.getElementById('cart-tax').textContent = `$${data.tax.toFixed(2)}`;
document.getElementById('cart-total').textContent = `$${data.total.toFixed(2)}`;
document.getElementById('cart-subtotal').textContent = formatCurrency(data.subtotal);
document.getElementById('cart-tax').textContent = formatCurrency(data.tax);
document.getElementById('cart-total').textContent = formatCurrency(data.total);
// If cart is empty, morph to empty state (Alpine.js best practice)
if (data.cart_count === 0) {

View File

@@ -7,15 +7,18 @@
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('buyer.dashboard') }}">Dashboard</a></li>
<li><a href="{{ route('buyer.cart.index') }}">Cart</a></li>
<li><a href="{{ route('buyer.business.cart.index', ($business ?? auth()->user()->businesses->first())->slug) }}">Cart</a></li>
<li class="opacity-80">Checkout</li>
</ul>
</div>
</div>
<form action="{{ route('buyer.business.checkout.process', $business ?? auth()->user()->businesses->first()) }}" method="POST" class="mt-6">
<form action="{{ route('buyer.business.checkout.process', ($business ?? auth()->user()->businesses->first())->slug) }}" method="POST" class="mt-6">
@csrf
<!-- Hidden inputs for delivery window -->
<input type="hidden" name="delivery_window_id" id="delivery-window-id-input" value="">
<div class="grid gap-6 lg:grid-cols-3">
<!-- Checkout Form (2/3 width) -->
<div class="lg:col-span-2 space-y-6">
@@ -48,50 +51,6 @@
</div>
</label>
<!-- Delivery Location Selection (conditional) -->
<div id="delivery-location-selector" class="ml-8 mr-4 p-4 bg-base-200 rounded-box">
<p class="font-medium text-sm mb-3">Select Delivery Location</p>
<div class="space-y-2">
@if($locations && count($locations) > 0)
@foreach($locations as $location)
<label class="flex items-start gap-3 p-3 border-2 border-base-300 bg-base-100 rounded-box cursor-pointer hover:border-primary transition-colors">
<input
type="radio"
name="location_id"
value="{{ $location->id }}"
class="radio radio-primary radio-sm mt-0.5"
{{ $loop->first ? 'checked' : '' }}
required
>
<div class="flex-1">
<p class="font-medium text-sm">{{ $location->name }}</p>
@if($location->address)
<p class="text-xs text-base-content/60 mt-1">
{{ $location->address }}
@if($location->unit), Unit {{ $location->unit }}@endif
@if($location->city), {{ $location->city }}@endif
@if($location->state) {{ $location->state }}@endif
@if($location->zipcode) {{ $location->zipcode }}@endif
</p>
@endif
@if($location->delivery_instructions)
<p class="text-xs text-base-content/50 mt-1">
<span class="icon-[lucide--info] size-3 inline"></span>
{{ Str::limit($location->delivery_instructions, 40) }}
</p>
@endif
</div>
</label>
@endforeach
@else
<div class="alert alert-warning alert-sm">
<span class="icon-[lucide--alert-triangle] size-4"></span>
<span class="text-xs">No delivery location found.</span>
</div>
@endif
</div>
</div>
<!-- Pickup Option -->
<label class="flex items-start gap-3 p-4 border-2 border-base-300 rounded-box cursor-pointer hover:border-primary transition-colors delivery-method-option" data-method="pickup">
<input
@@ -104,80 +63,13 @@
<div class="flex-1">
<p class="font-medium flex items-center gap-2">
<span class="icon-[lucide--building-2] size-4"></span>
Pick Up at Our Lab
Pick up
</p>
<p class="text-sm text-base-content/60 mt-1">Collect your order from our facility</p>
</div>
</label>
</div>
<!-- Pickup Driver Information (conditional) -->
<div id="pickup-driver-info" class="mt-6 p-4 bg-base-200 rounded-box hidden">
<p class="font-medium text-sm mb-3">Pickup Driver Information (Optional)</p>
<p class="text-xs text-base-content/60 mb-4">You can provide this information now or update it later before pickup</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text text-sm">First Name</span>
</label>
<input
type="text"
name="pickup_driver_first_name"
class="input input-bordered input-sm"
placeholder="Driver's first name"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-sm">Last Name</span>
</label>
<input
type="text"
name="pickup_driver_last_name"
class="input input-bordered input-sm"
placeholder="Driver's last name"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-sm">Driver's License</span>
</label>
<input
type="text"
name="pickup_driver_license"
class="input input-bordered input-sm"
placeholder="License number"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-sm">Phone Number</span>
</label>
<input
type="tel"
name="pickup_driver_phone"
class="input input-bordered input-sm"
placeholder="(555) 555-5555"
>
</div>
<div class="form-control sm:col-span-2">
<label class="label">
<span class="label-text text-sm">Vehicle License Plate (Optional)</span>
</label>
<input
type="text"
name="pickup_vehicle_plate"
class="input input-bordered input-sm"
placeholder="ABC1234"
>
</div>
</div>
</div>
<!-- Scheduling Message -->
<div class="alert alert-info mt-4">
@@ -198,11 +90,11 @@
<div class="mt-4">
<select name="payment_terms" id="payment-terms-select" class="select select-bordered w-full" required>
<option value="cod" data-surcharge="0">COD (Due immediately) - Best Value</option>
<option value="net_15" data-surcharge="5">Net 15 (Due in 15 days) - +5% surcharge</option>
<option value="net_30" data-surcharge="10">Net 30 (Due in 30 days) - +10% surcharge</option>
<option value="net_60" data-surcharge="15">Net 60 (Due in 60 days) - +15% surcharge</option>
<option value="net_90" data-surcharge="20">Net 90 (Due in 90 days) - +20% surcharge</option>
<option value="cod" data-surcharge="0">COD (Due immediately)</option>
<option value="net_15" data-surcharge="5">Net 15 (Due in 15 days)</option>
<option value="net_30" data-surcharge="10">Net 30 (Due in 30 days)</option>
<option value="net_60" data-surcharge="15">Net 60 (Due in 60 days)</option>
<option value="net_90" data-surcharge="20">Net 90 (Due in 90 days)</option>
</select>
<!-- Payment Term Info Box -->
@@ -251,42 +143,53 @@
<div class="card-body">
<h3 class="font-medium mb-4">Order Summary</h3>
<!-- Cart Items -->
<div class="space-y-3 mb-4">
@foreach($items as $item)
<!-- Cart Items Grouped by Brand -->
<div class="space-y-4 mb-4">
@php
$itemsByBrand = $items->groupBy(fn($item) => $item->product->brand_id);
@endphp
@foreach($itemsByBrand as $brandId => $brandItems)
@php
$regularPrice = $item->product->wholesale_price ?? $item->product->msrp ?? 0;
$hasSalePrice = $item->product->sale_price && $item->product->sale_price < $regularPrice;
$unitPrice = $hasSalePrice ? $item->product->sale_price : $regularPrice;
$brand = $brandItems->first()->product->brand;
@endphp
<div class="flex gap-3">
<div class="bg-base-200 rounded-box w-12 h-12 flex items-center justify-center flex-shrink-0">
@if($item->product->image_path || !empty($item->product->images))
<img src="{{ asset($item->product->image_path ?? ($item->product->images[0] ?? '')) }}" alt="{{ $item->product->name }}" class="w-full h-full object-cover rounded-box">
@else
<span class="icon-[lucide--package] size-5 text-base-content/40"></span>
@endif
<div class="border-2 border-base-300 rounded-box p-3">
<!-- Brand Header -->
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-base-300">
<span class="icon-[lucide--building-2] size-4 text-primary"></span>
<span class="font-medium text-sm text-primary">{{ $brand->name }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{{ $item->product->name }}</p>
<div class="flex items-center gap-2 text-xs text-base-content/60">
<span>Qty: {{ $item->quantity }} × ${{ number_format($unitPrice, 2) }}</span>
@if($hasSalePrice)
<span class="badge badge-success badge-xs gap-1">
<span class="icon-[lucide--tag] size-2"></span>
Sale
</span>
@endif
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium">${{ number_format($item->quantity * $unitPrice, 2) }}</p>
@if($hasSalePrice)
<p class="text-xs text-base-content/40 line-through">${{ number_format($item->quantity * $regularPrice, 2) }}</p>
@endif
<!-- Brand Items -->
<div class="space-y-2">
@foreach($brandItems as $item)
<div class="flex gap-3"
data-product-item
data-base-price="{{ $item->product->wholesale_price }}"
data-quantity="{{ $item->quantity }}">
<div class="flex-1 min-w-0">
<p class="text-xs font-medium truncate">{{ $item->product->name }}</p>
<p class="text-xs text-base-content/60">
Qty: {{ $item->quantity }} ×
<span class="product-unit-price" data-unit-price>${{ number_format($item->product->wholesale_price, 2) }}</span>
</p>
</div>
<div class="text-right">
<p class="text-xs font-medium product-line-total" data-line-total>${{ number_format($item->quantity * $item->product->wholesale_price, 2) }}</p>
</div>
</div>
@endforeach
</div>
</div>
@endforeach
@if(count($itemsByBrand) > 1)
<div class="alert alert-info alert-sm">
<span class="icon-[lucide--info] size-4"></span>
<span class="text-xs">Your order will be split into {{ count($itemsByBrand) }} separate orders (one per brand)</span>
</div>
@endif
</div>
<div class="divider my-2"></div>
@@ -298,11 +201,6 @@
<span class="font-medium" id="order-subtotal">${{ number_format($subtotal, 2) }}</span>
</div>
<div class="flex items-center justify-between text-sm" id="payment-surcharge-row">
<span class="text-base-content/60">Payment Term Surcharge (<span id="surcharge-percentage">0</span>%)</span>
<span class="font-medium" id="surcharge-amount">$0.00</span>
</div>
{{-- Tax line hidden: All transactions are B2B wholesale (tax-exempt with resale certificate) --}}
{{-- <div class="flex items-center justify-between text-sm">
<span class="text-base-content/60">Tax (8%)</span>
@@ -315,6 +213,10 @@
<span class="font-medium">Total</span>
<span class="text-lg font-semibold" id="order-total">${{ number_format($total, 2) }}</span>
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
<span>Payment Terms:</span>
<span id="payment-term-display">COD (Due on Delivery)</span>
</div>
</div>
<!-- Place Order Button -->
@@ -324,7 +226,7 @@
</button>
<!-- Back to Cart -->
<a href="{{ route('buyer.business.cart.index', $business ?? auth()->user()->businesses->first()) }}" class="btn btn-ghost btn-block mt-2">
<a href="{{ route('buyer.business.cart.index', ($business ?? auth()->user()->businesses->first())->slug) }}" class="btn btn-ghost btn-block mt-2">
<span class="icon-[lucide--arrow-left] size-4"></span>
Back to Cart
</a>
@@ -351,8 +253,6 @@
document.addEventListener('DOMContentLoaded', function() {
const deliveryRadio = document.getElementById('method_delivery');
const pickupRadio = document.getElementById('method_pickup');
const deliveryLocationSelector = document.getElementById('delivery-location-selector');
const pickupDriverInfo = document.getElementById('pickup-driver-info');
const fulfillmentType = document.getElementById('fulfillment-type');
const paymentTermsSelect = document.getElementById('payment-terms-select');
@@ -363,49 +263,37 @@
// Payment term info
const paymentTermInfo = {
cod: {
title: 'COD - Best Value',
description: 'Pay on delivery to get our best rates with no additional charges',
title: 'COD',
description: 'Payment due on delivery',
color: 'success'
},
net_15: {
title: 'Net 15 Terms',
description: 'Payment due within 15 days. A 5% surcharge applies to cover extended payment terms',
description: 'Payment due within 15 days',
color: 'warning'
},
net_30: {
title: 'Net 30 Terms',
description: 'Payment due within 30 days. A 10% surcharge applies to cover extended payment terms',
description: 'Payment due within 30 days',
color: 'warning'
},
net_60: {
title: 'Net 60 Terms',
description: 'Payment due within 60 days. A 15% surcharge applies to cover extended payment terms',
description: 'Payment due within 60 days',
color: 'warning'
},
net_90: {
title: 'Net 90 Terms',
description: 'Payment due within 90 days. A 20% surcharge applies to cover extended payment terms',
description: 'Payment due within 90 days',
color: 'error'
}
};
function updateFulfillmentMethod() {
const locationRadios = document.querySelectorAll('input[name="location_id"]');
if (pickupRadio.checked) {
deliveryLocationSelector.classList.add('hidden');
pickupDriverInfo.classList.remove('hidden');
fulfillmentType.textContent = 'Pickup';
// Remove required attribute from location radios when pickup is selected
locationRadios.forEach(radio => radio.removeAttribute('required'));
} else {
deliveryLocationSelector.classList.remove('hidden');
pickupDriverInfo.classList.add('hidden');
fulfillmentType.textContent = 'Delivery';
// Add required attribute to first location radio when delivery is selected
if (locationRadios.length > 0) {
locationRadios[0].setAttribute('required', 'required');
}
}
}
@@ -437,32 +325,63 @@
}
}
function updateProductPrices(surchargePercent) {
// Get all product line items
const productItems = document.querySelectorAll('[data-product-item]');
let calculatedSubtotal = 0;
productItems.forEach(item => {
const basePrice = parseFloat(item.dataset.basePrice);
const quantity = parseFloat(item.dataset.quantity);
// Calculate adjusted price with surcharge
const adjustedUnitPrice = basePrice * (1 + surchargePercent / 100);
const adjustedLineTotal = adjustedUnitPrice * quantity;
// Update unit price display
const unitPriceElement = item.querySelector('[data-unit-price]');
if (unitPriceElement) {
unitPriceElement.textContent = formatCurrency(adjustedUnitPrice);
}
// Update line total display
const lineTotalElement = item.querySelector('[data-line-total]');
if (lineTotalElement) {
lineTotalElement.textContent = formatCurrency(adjustedLineTotal);
}
// Accumulate subtotal
calculatedSubtotal += adjustedLineTotal;
});
return calculatedSubtotal;
}
function calculateTotals() {
// Get selected payment term surcharge
const selectedOption = paymentTermsSelect.options[paymentTermsSelect.selectedIndex];
const surchargePercent = parseFloat(selectedOption.dataset.surcharge) || 0;
const selectedValue = paymentTermsSelect.value;
// Calculate surcharge amount on subtotal
const surchargeAmount = baseSubtotal * (surchargePercent / 100);
// Update individual product prices and get new subtotal
const newSubtotal = updateProductPrices(surchargePercent);
// Calculate subtotal with surcharge
const subtotalWithSurcharge = baseSubtotal + surchargeAmount;
// Update payment term display
const termLabels = {
'cod': 'COD (Due on Delivery)',
'net_15': 'Net 15 (Due in 15 days)',
'net_30': 'Net 30 (Due in 30 days)',
'net_60': 'Net 60 (Due in 60 days)',
'net_90': 'Net 90 (Due in 90 days)'
};
document.getElementById('payment-term-display').textContent = termLabels[selectedValue] || 'COD (Due on Delivery)';
// B2B wholesale: no tax, total = subtotal + surcharge
const total = subtotalWithSurcharge;
// Update subtotal display
document.getElementById('order-subtotal').textContent = formatCurrency(newSubtotal);
// Update display
document.getElementById('surcharge-percentage').textContent = surchargePercent;
document.getElementById('surcharge-amount').textContent = '$' + surchargeAmount.toFixed(2);
document.getElementById('order-total').textContent = '$' + total.toFixed(2);
// Hide surcharge row if 0%
const surchargeRow = document.getElementById('payment-surcharge-row');
if (surchargePercent === 0) {
surchargeRow.style.display = 'none';
} else {
surchargeRow.style.display = 'flex';
}
// Total is same as subtotal (surcharge already included in product prices)
document.getElementById('order-total').textContent = formatCurrency(newSubtotal);
}
// Listen for fulfillment method changes

View File

@@ -1,7 +1,7 @@
@extends('layouts.buyer-app-with-sidebar')
@section('content')
<div class="container-fluid py-6" x-data="invoiceReview()">
<div class="container-fluid py-6">
<!-- Page Header -->
<div class="flex justify-between items-center mb-6">
<div>
@@ -9,106 +9,70 @@
<p class="text-gray-600 mt-1">Issued on {{ $invoice->invoice_date->format('F j, Y') }}</p>
</div>
<div class="flex gap-2">
<a href="{{ route('buyer.business.invoices.index', $business ?? auth()->user()->businesses->first()) }}" class="btn btn-ghost">
<a href="{{ route('buyer.business.invoices.index', ($business ?? auth()->user()->businesses->first())->slug) }}" class="btn btn-ghost">
<span class="icon-[lucide--arrow-left] size-5"></span>
Back to Invoices
</a>
<a href="{{ route('buyer.business.invoices.pdf', [($business ?? auth()->user()->businesses->first())->slug, $invoice]) }}" class="btn btn-primary" target="_blank">
<span class="icon-[lucide--download] size-5"></span>
Download PDF
</a>
</div>
</div>
<!-- Approval Status Alerts -->
@if($invoice->isPendingBuyerApproval())
<!-- Payment Status Alert -->
@if($invoice->isOverdue())
<div class="alert alert-error mb-6">
<span class="icon-[lucide--alert-circle] size-5"></span>
<span>This invoice is overdue. Please submit payment as soon as possible.</span>
</div>
@elseif($invoice->payment_status === 'unpaid')
<div class="alert alert-info mb-6">
<span class="icon-[lucide--info] size-5"></span>
<span>Please review and approve this invoice. You may modify quantities if needed.</span>
<span>Payment is due by {{ $invoice->due_date->format('M j, Y') }}</span>
</div>
@endif
@if($invoice->isSellerModified())
<div class="alert alert-warning mb-6">
<span class="icon-[lucide--alert-triangle] size-5"></span>
<span>The seller has made counter-modifications to your changes. Please review the updated invoice.</span>
</div>
@endif
@if($invoice->isApproved())
@elseif($invoice->payment_status === 'paid')
<div class="alert alert-success mb-6">
<span class="icon-[lucide--check-circle] size-5"></span>
<span>This invoice has been approved and is being processed.</span>
<span>This invoice has been paid in full.</span>
</div>
@endif
@if($invoice->isRejected())
<div class="alert alert-error mb-6">
<span class="icon-[lucide--x-circle] size-5"></span>
<div>
<p class="font-bold">Invoice Rejected</p>
<p class="text-sm">Reason: {{ $invoice->rejection_reason }}</p>
</div>
</div>
@endif
<!-- Action Buttons -->
@if($invoice->canBeEditedByBuyer())
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<div class="flex justify-between items-center">
<div>
<h3 class="font-bold text-lg">Invoice Actions</h3>
<p class="text-sm text-gray-600">Review and take action on this invoice</p>
</div>
<div class="flex gap-2">
<button
@click="toggleEditMode"
class="btn btn-warning"
:class="{ 'btn-active': editMode }"
x-show="!editMode">
<span class="icon-[lucide--edit] size-5"></span>
Modify Invoice
</button>
<button
@click="saveChanges"
class="btn btn-success"
x-show="editMode && hasChanges"
:disabled="saving">
<span class="icon-[lucide--save] size-5"></span>
<span x-text="saving ? 'Saving...' : 'Save Changes'"></span>
</button>
<button
@click="cancelEdit"
class="btn btn-ghost"
x-show="editMode">
<span class="icon-[lucide--x] size-5"></span>
Cancel
</button>
<button
@click="showApproveModal"
class="btn btn-success"
x-show="!editMode">
<span class="icon-[lucide--check-circle] size-5"></span>
Approve Invoice
</button>
<button
@click="showRejectModal"
class="btn btn-error"
x-show="!editMode">
<span class="icon-[lucide--x-circle] size-5"></span>
Reject Invoice
</button>
</div>
</div>
</div>
</div>
@endif
<!-- Invoice Information Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<!-- Company Information -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="font-bold text-lg mb-4">Company Information</h3>
<div class="space-y-3">
@php
// Get seller business from first order item's product's brand
$sellerBusiness = $invoice->order->items->first()?->product?->brand?->business;
@endphp
@if($sellerBusiness)
<div>
<p class="text-sm text-gray-600">Business</p>
<p class="font-semibold">{{ $sellerBusiness->name }}</p>
</div>
@if($sellerBusiness->business_email)
<div>
<p class="text-sm text-gray-600">Email</p>
<p class="font-semibold">{{ $sellerBusiness->business_email }}</p>
</div>
@endif
@if($sellerBusiness->business_phone)
<div>
<p class="text-sm text-gray-600">Phone</p>
<p class="font-semibold">{{ $sellerBusiness->business_phone }}</p>
</div>
@endif
@endif
</div>
</div>
</div>
<!-- Invoice Details -->
<div class="card bg-base-100 shadow-lg">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="font-bold text-lg mb-4">Invoice Details</h3>
<div class="space-y-3">
@@ -129,62 +93,63 @@
</div>
<div>
<p class="text-sm text-gray-600">Related Order</p>
<a href="{{ route('buyer.business.orders.show', [$business ?? auth()->user()->businesses->first(), $invoice->order]) }}" class="text-primary hover:underline">
<a href="{{ route('buyer.business.orders.show', [($business ?? auth()->user()->businesses->first())->slug, $invoice->order]) }}" class="font-semibold font-mono text-primary hover:underline">
{{ $invoice->order->order_number }}
</a>
</div>
<div>
<p class="text-sm text-gray-600">Approval Status</p>
<div class="mt-1">
<span class="badge badge-lg {{ $invoice->isApproved() ? 'badge-success' : ($invoice->isRejected() ? 'badge-error' : 'badge-warning') }}">
{{ str_replace('_', ' ', ucfirst($invoice->approval_status)) }}
</span>
</div>
</div>
</div>
<!-- Order Summary -->
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="font-bold text-lg mb-4">Order Summary</h3>
<!-- Order Items List -->
<div class="space-y-2 mb-4">
@foreach($invoice->order->items as $item)
<div class="flex justify-between text-sm {{ $item->isPreDeliveryRejected() ? 'opacity-50' : '' }}">
<div class="flex-1">
<span class="font-medium {{ $item->isPreDeliveryRejected() ? 'line-through' : '' }}">{{ $item->product_name }}</span>
<span class="text-base-content/60 ml-2">× {{ $item->delivered_qty ?? $item->picked_qty }}</span>
@if($item->isPreDeliveryRejected())
<span class="badge badge-xs badge-ghost ml-2">Rejected</span>
@endif
</div>
<span class="font-semibold {{ $item->isPreDeliveryRejected() ? 'line-through' : '' }}">${{ number_format($item->line_total, 2) }}</span>
</div>
@endforeach
</div>
</div>
</div>
<!-- Company Information -->
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h3 class="font-bold text-lg mb-4">Billed To</h3>
<div class="space-y-3">
<div>
<p class="text-sm text-gray-600">Company</p>
<p class="font-semibold">{{ $invoice->business->name }}</p>
</div>
</div>
</div>
</div>
<div class="divider my-2"></div>
<!-- Payment Summary -->
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h3 class="font-bold text-lg mb-4">Payment Summary</h3>
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600">Subtotal</span>
<span class="font-semibold" x-text="'$' + calculateSubtotal().toFixed(2)">${{ number_format($invoice->subtotal, 2) }}</span>
</div>
@if($invoice->order && $invoice->order->surcharge > 0)
<div class="flex justify-between text-sm">
<span class="text-gray-600">Payment Terms Surcharge ({{ \App\Models\Order::getSurchargePercentage($invoice->order->payment_terms) }}%)</span>
<span class="font-semibold">${{ number_format($invoice->order->surcharge, 2) }}</span>
</div>
@endif
<div class="divider my-2"></div>
<div class="flex justify-between text-lg">
<span class="font-bold">Total</span>
<span class="font-bold text-primary" x-text="'$' + calculateTotal().toFixed(2)">${{ number_format($invoice->total, 2) }}</span>
<span class="font-bold text-primary">${{ number_format($invoice->total, 2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">Amount Paid</span>
<span class="font-semibold text-success">${{ number_format($invoice->amount_paid, 2) }}</span>
<div class="divider my-2"></div>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Payment Terms</span>
<span class="font-semibold">
@if($invoice->order->payment_terms === 'cod')
COD
@else
{{ ucfirst(str_replace('_', ' ', $invoice->order->payment_terms)) }}
@endif
</span>
</div>
<div class="flex justify-between text-lg">
<span class="font-bold">Amount Due</span>
<span class="font-bold text-error" x-text="'$' + (calculateTotal() - {{ $invoice->amount_paid }}).toFixed(2)">${{ number_format($invoice->amount_due, 2) }}</span>
<div class="flex justify-between text-sm">
<span class="text-gray-600">Payment Status</span>
<div>
<span class="badge badge-sm {{ $invoice->payment_status === 'paid' ? 'badge-success' : ($invoice->isOverdue() ? 'badge-error' : 'badge-warning') }}">
{{ ucfirst(str_replace('_', ' ', $invoice->payment_status)) }}
@if($invoice->isOverdue())
(Overdue)
@endif
</span>
</div>
</div>
</div>
</div>
@@ -192,17 +157,14 @@
</div>
<!-- Invoice Line Items -->
<div class="card bg-base-100 shadow-lg">
<div class="card bg-base-100 shadow">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Line Items</h2>
<span x-show="editMode" class="text-sm text-warning">
<span class="icon-[lucide--info] size-4 inline"></span>
You can only reduce quantities or remove items
</span>
</div>
<h2 class="card-title mb-4">
<span class="icon-[lucide--package] size-5"></span>
Line Items
</h2>
<div class="overflow-x-auto">
<table class="table w-full">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Product</th>
@@ -211,86 +173,61 @@
<th>Quantity (Fulfilled/Ordered)</th>
<th>Unit Price</th>
<th>Total</th>
<th x-show="editMode">Actions</th>
</tr>
</thead>
<tbody>
@foreach($invoice->order->items as $index => $item)
<tr x-show="!items[{{ $index }}].deleted" :class="{ 'opacity-50': items[{{ $index }}].deleted }">
@foreach($invoice->order->items as $item)
<tr class="{{ $item->isPreDeliveryRejected() ? 'opacity-60' : '' }}">
<td>
<div class="font-semibold">{{ $item->product_name }}</div>
</td>
<td class="font-mono text-sm">{{ $item->product_sku }}</td>
<td>{{ $item->brand_name }}</td>
<td>
<template x-if="!editMode">
<span class="font-semibold">
<span x-text="items[{{ $index }}].quantity">{{ $item->picked_qty }}</span>
<span class="text-gray-400">/{{ $item->quantity }}</span>
</span>
</template>
<template x-if="editMode">
<div class="flex items-center gap-2">
<input
type="number"
x-model.number="items[{{ $index }}].quantity"
:max="{{ $item->picked_qty }}"
min="0"
class="input input-bordered input-sm w-20"
@change="validateQuantity({{ $index }}, {{ $item->picked_qty }})" />
<span class="text-sm text-gray-500">/{{ $item->quantity }}</span>
<div class="flex items-center gap-3">
@if($item->product)
<div class="avatar">
<div class="w-12 h-12 rounded-lg bg-base-200">
@if($item->product->image_path)
<img src="{{ Storage::url($item->product->image_path) }}" alt="{{ $item->product_name }}">
@else
<span class="icon-[lucide--package] size-6 text-base-content/40"></span>
@endif
</div>
</div>
@endif
<div class="font-semibold {{ $item->isPreDeliveryRejected() ? 'line-through' : '' }}">
{{ $item->product_name }}
@if($item->isPreDeliveryRejected())
<span class="badge badge-sm badge-ghost ml-2">Rejected</span>
@endif
</div>
</template>
</div>
</td>
<td>${{ number_format($item->unit_price, 2) }}</td>
<td class="font-semibold">
<span x-text="'$' + (items[{{ $index }}].quantity * {{ $item->unit_price }}).toFixed(2)">
${{ number_format($item->line_total, 2) }}
<td class="font-mono text-sm {{ $item->isPreDeliveryRejected() ? 'line-through' : '' }}">{{ $item->product_sku }}</td>
<td class="{{ $item->isPreDeliveryRejected() ? 'line-through' : '' }}">{{ $item->brand_name }}</td>
<td>
<span class="font-semibold {{ $item->isPreDeliveryRejected() ? 'line-through' : '' }}">
{{ $item->delivered_qty ?? $item->picked_qty }}
<span class="text-gray-400">/{{ $item->quantity }}</span>
</span>
</td>
<td x-show="editMode">
<button
@click="deleteItem({{ $index }})"
class="btn btn-error btn-sm"
x-show="!items[{{ $index }}].deleted">
<span class="icon-[lucide--trash-2] size-4"></span>
Remove
</button>
<button
@click="restoreItem({{ $index }})"
class="btn btn-success btn-sm"
x-show="items[{{ $index }}].deleted">
<span class="icon-[lucide--undo] size-4"></span>
Restore
</button>
<td class="{{ $item->isPreDeliveryRejected() ? 'line-through' : '' }}">${{ number_format($item->unit_price, 2) }}</td>
<td class="font-semibold {{ $item->isPreDeliveryRejected() ? 'line-through' : '' }}">
${{ number_format($item->line_total, 2) }}
</td>
</tr>
@endforeach
</tbody>
<tfoot>
<tr class="font-bold">
<td colspan="{{ $invoice->canBeEditedByBuyer() ? '5' : '5' }}" class="text-right">Subtotal:</td>
<td x-text="'$' + calculateSubtotal().toFixed(2)">${{ number_format($invoice->subtotal, 2) }}</td>
<td x-show="editMode"></td>
<td colspan="5" class="text-right">Subtotal:</td>
<td>${{ number_format($invoice->subtotal, 2) }}</td>
</tr>
@if($invoice->order && $invoice->order->surcharge > 0)
<tr class="text-sm">
<td colspan="{{ $invoice->canBeEditedByBuyer() ? '5' : '5' }}" class="text-right">Payment Terms Surcharge ({{ \App\Models\Order::getSurchargePercentage($invoice->order->payment_terms) }}%):</td>
<td>${{ number_format($invoice->order->surcharge, 2) }}</td>
<td x-show="editMode"></td>
</tr>
@endif
@if($invoice->tax > 0)
<tr class="text-sm">
<td colspan="{{ $invoice->canBeEditedByBuyer() ? '5' : '5' }}" class="text-right">Tax:</td>
<tr>
<td colspan="5" class="text-right">Tax:</td>
<td>${{ number_format($invoice->tax, 2) }}</td>
<td x-show="editMode"></td>
</tr>
@endif
<tr class="font-bold text-lg">
<td colspan="{{ $invoice->canBeEditedByBuyer() ? '5' : '5' }}" class="text-right">Total:</td>
<td class="text-primary" x-text="'$' + calculateTotal().toFixed(2)">${{ number_format($invoice->total, 2) }}</td>
<td x-show="editMode"></td>
<td colspan="5" class="text-right">Total:</td>
<td class="text-primary">${{ number_format($invoice->total, 2) }}</td>
</tr>
</tfoot>
</table>
@@ -298,374 +235,14 @@
</div>
</div>
<!-- Change History -->
@if($invoice->changes->isNotEmpty())
<div class="card bg-base-100 shadow-lg mt-6">
<div class="card-body">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title">Change History</h2>
<button @click="showChangeHistory" class="btn btn-sm btn-ghost">
<span class="icon-[lucide--history] size-4"></span>
View All Changes
</button>
</div>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Round</th>
<th>Type</th>
<th>Product</th>
<th>Old Value</th>
<th>New Value</th>
<th>Changed By</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($invoice->changes->take(5) as $change)
<tr>
<td>{{ $change->negotiation_round }}</td>
<td>{{ ucfirst(str_replace('_', ' ', $change->change_type)) }}</td>
<td>{{ $change->orderItem?->product_name ?? 'N/A' }}</td>
<td>{{ $change->old_value }}</td>
<td>{{ $change->new_value }}</td>
<td>{{ ucfirst($change->user_type) }}</td>
<td>
<span class="badge badge-sm {{ $change->isApproved() ? 'badge-success' : ($change->isRejected() ? 'badge-error' : 'badge-warning') }}">
{{ ucfirst(str_replace('_', ' ', $change->status)) }}
</span>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
@if($invoice->notes)
<!-- Notes Section -->
<div class="card bg-base-100 shadow-lg mt-6">
<div class="card bg-base-100 shadow mt-6">
<div class="card-body">
<h2 class="card-title mb-4">Notes</h2>
<p class="text-gray-700">{{ $invoice->notes }}</p>
</div>
</div>
@endif
<!-- Approve Modal -->
<dialog id="approveModal" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-2xl mb-4 flex items-center gap-2">
<span class="icon-[lucide--check-circle] size-7 text-success"></span>
Approve Invoice {{ $invoice->invoice_number }}?
</h3>
<div class="py-4 space-y-4">
<p class="text-gray-700">
You are about to approve this invoice for <strong>${{ number_format($invoice->total, 2) }}</strong>.
This action confirms you agree with the invoice details and are ready to proceed with payment.
</p>
<!-- Invoice Summary -->
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold mb-3 flex items-center gap-2">
<span class="icon-[lucide--file-text] size-5"></span>
Invoice Summary
</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-gray-600">Invoice Number:</span>
<p class="font-semibold">{{ $invoice->invoice_number }}</p>
</div>
<div>
<span class="text-gray-600">Invoice Date:</span>
<p class="font-semibold">{{ $invoice->invoice_date->format('M j, Y') }}</p>
</div>
<div>
<span class="text-gray-600">Total Items:</span>
<p class="font-semibold">{{ $invoice->order->items->count() }} items</p>
</div>
<div>
<span class="text-gray-600">Due Date:</span>
<p class="font-semibold">{{ $invoice->due_date->format('M j, Y') }}</p>
</div>
<div>
<span class="text-gray-600">Subtotal:</span>
<p class="font-semibold">${{ number_format($invoice->subtotal, 2) }}</p>
</div>
<div>
<span class="text-gray-600">Invoice Total:</span>
<p class="font-semibold text-primary">${{ number_format($invoice->total, 2) }}</p>
</div>
</div>
</div>
</div>
<div class="alert alert-success">
<span class="icon-[lucide--info] size-5"></span>
<div class="text-sm">
<p class="font-semibold">What happens next:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Invoice will be marked as approved</li>
<li>Order will proceed to manifest creation</li>
<li>Payment will be processed according to terms</li>
<li>Seller will be notified of approval</li>
</ul>
</div>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" onclick="document.getElementById('approveModal').close()">
<span class="icon-[lucide--x] size-5"></span>
Cancel
</button>
<button type="button" class="btn btn-success btn-lg" @click="confirmApproval" :disabled="approving">
<span class="icon-[lucide--check-circle] size-5"></span>
<span x-text="approving ? 'Processing...' : 'Confirm - Approve Invoice'"></span>
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- Reject Modal -->
<dialog id="rejectModal" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-2xl mb-4 flex items-center gap-2">
<span class="icon-[lucide--x-circle] size-7 text-error"></span>
Reject Invoice {{ $invoice->invoice_number }}?
</h3>
<div class="py-4 space-y-4">
<p class="text-gray-700">
You are about to reject this invoice. This will notify the seller that you do not approve
of the invoice in its current state.
</p>
<!-- Invoice Summary -->
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="font-semibold mb-3 flex items-center gap-2">
<span class="icon-[lucide--file-text] size-5"></span>
Invoice Summary
</h4>
<div class="grid grid-cols-2 gap-3 text-sm">
<div>
<span class="text-gray-600">Invoice Number:</span>
<p class="font-semibold">{{ $invoice->invoice_number }}</p>
</div>
<div>
<span class="text-gray-600">Invoice Total:</span>
<p class="font-semibold text-primary">${{ number_format($invoice->total, 2) }}</p>
</div>
<div>
<span class="text-gray-600">Total Items:</span>
<p class="font-semibold">{{ $invoice->order->items->count() }} items</p>
</div>
<div>
<span class="text-gray-600">Due Date:</span>
<p class="font-semibold">{{ $invoice->due_date->format('M j, Y') }}</p>
</div>
</div>
</div>
</div>
<div class="alert alert-warning">
<span class="icon-[lucide--alert-triangle] size-5"></span>
<div class="text-sm">
<p class="font-semibold">What happens next:</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Invoice will be marked as rejected</li>
<li>Seller will be notified with your reason</li>
<li>Order will be put on hold pending resolution</li>
<li>You may need to contact the seller to resolve the issue</li>
</ul>
</div>
</div>
<form method="POST" action="{{ route('buyer.business.invoices.reject', [$business ?? auth()->user()->businesses->first(), $invoice]) }}">
@csrf
<div class="form-control w-full">
<label class="label">
<span class="label-text font-semibold">Rejection Reason *</span>
<span class="label-text-alt text-gray-500">Required</span>
</label>
<textarea
name="reason"
class="textarea textarea-bordered w-full h-24"
placeholder="Please explain in detail why you're rejecting this invoice. Be specific about items, quantities, or pricing issues..."
required></textarea>
<label class="label">
<span class="label-text-alt text-gray-500">This will be shared with the seller</span>
</label>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" onclick="document.getElementById('rejectModal').close()">
<span class="icon-[lucide--x] size-5"></span>
Cancel
</button>
<button type="submit" class="btn btn-error btn-lg">
<span class="icon-[lucide--x-circle] size-5"></span>
Confirm - Reject Invoice
</button>
</div>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
@push('scripts')
<script>
function invoiceReview() {
return {
editMode: false,
saving: false,
approving: false,
hasChanges: false,
items: @json($invoiceItems),
toggleEditMode() {
this.editMode = !this.editMode;
},
cancelEdit() {
// Reset all items to original state
this.items.forEach((item, index) => {
item.quantity = item.originalQuantity;
item.deleted = false;
});
this.editMode = false;
this.hasChanges = false;
},
validateQuantity(index, maxQty) {
if (this.items[index].quantity > maxQty) {
this.items[index].quantity = maxQty;
alert('You can only reduce quantities, not increase them.');
}
if (this.items[index].quantity < 0) {
this.items[index].quantity = 0;
}
this.checkForChanges();
},
deleteItem(index) {
this.items[index].deleted = true;
this.checkForChanges();
},
restoreItem(index) {
this.items[index].deleted = false;
this.checkForChanges();
},
checkForChanges() {
this.hasChanges = this.items.some((item, index) => {
return item.deleted || item.quantity !== item.originalQuantity;
});
},
calculateSubtotal() {
return this.items.reduce((sum, item) => {
if (item.deleted) return sum;
return sum + (item.quantity * item.unit_price);
}, 0);
},
calculateTotal() {
if (!this.editMode) {
return {{ $invoice->total }};
}
// Calculate: subtotal + surcharge + tax
const subtotal = this.calculateSubtotal();
const surcharge = {{ $invoice->order->surcharge ?? 0 }};
const tax = {{ $invoice->tax ?? 0 }};
return subtotal + surcharge + tax;
},
async saveChanges() {
if (!this.hasChanges) return;
this.saving = true;
try {
const response = await fetch('{{ route("buyer.business.invoices.modify", [$business ?? auth()->user()->businesses->first(), $invoice]) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
},
body: JSON.stringify({
items: this.items
})
});
const data = await response.json();
if (response.ok) {
window.location.reload();
} else {
alert(data.message || 'Failed to save changes');
}
} catch (error) {
alert('An error occurred while saving changes');
console.error(error);
} finally {
this.saving = false;
}
},
showApproveModal() {
document.getElementById('approveModal').showModal();
},
async confirmApproval() {
this.approving = true;
try {
const response = await fetch('{{ route("buyer.business.invoices.approve", [$business ?? auth()->user()->businesses->first(), $invoice]) }}', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
}
});
if (response.ok) {
window.location.reload();
} else {
alert('Failed to approve invoice');
}
} catch (error) {
alert('An error occurred');
console.error(error);
} finally {
this.approving = false;
}
},
showRejectModal() {
document.getElementById('rejectModal').showModal();
},
showChangeHistory() {
// TODO: Implement change history modal
alert('Change history modal coming soon');
}
}
}
</script>
@endpush
@endsection

View File

@@ -267,7 +267,7 @@
@endif
<!-- Stock Badge -->
@if($product->quantity_on_hand > 0)
@if($product->isInStock())
<div class="badge badge-success absolute top-2 right-2">In Stock</div>
@else
<div class="badge badge-error absolute top-2 right-2">Out of Stock</div>

View File

@@ -179,9 +179,9 @@
<option
value="{{ $batch->id }}"
data-available="{{ $batch->quantity_available }}"
data-thc="{{ $batch->labs->first()?->thc_percentage ?? 'N/A' }}"
data-cbd="{{ $batch->labs->first()?->cbd_percentage ?? 'N/A' }}"
data-test-date="{{ $batch->labs->first()?->test_date?->format('M j, Y') ?? 'Not tested' }}">
data-thc="{{ $product->labs->first()?->thc_percentage ?? 'N/A' }}"
data-cbd="{{ $product->labs->first()?->cbd_percentage ?? 'N/A' }}"
data-test-date="{{ $product->labs->first()?->test_date?->format('M j, Y') ?? 'Not tested' }}">
{{ $batch->batch_number }}
- {{ $batch->quantity_available }} units
@if($batch->production_date)

View File

@@ -61,7 +61,7 @@
<p class="text-xs font-medium text-base-content/60 mb-2">Certificates of Analysis:</p>
<div class="flex flex-wrap gap-2">
@foreach($item->batch->coaFiles as $coaFile)
<a href="{{ Storage::url($coaFile->file_path) }}" target="_blank" class="btn btn-xs btn-outline gap-1">
<a href="{{ route('coa.download', $coaFile->id) }}" target="_blank" class="btn btn-xs btn-outline gap-1">
<span class="icon-[lucide--file-text] size-3"></span>
@if($coaFile->is_primary)
Primary COA

View File

@@ -92,42 +92,45 @@
@include('buyer.orders.partials.status-badge', ['status' => $order->status])
</td>
<td>
<div class="flex items-center gap-1">
<!-- View Details -->
<a href="{{ route('buyer.business.orders.show', [($business ?? auth()->user()->businesses->first())->slug, $order]) }}" class="btn btn-ghost btn-sm" title="View Details">
<span class="icon-[lucide--eye] size-4"></span>
</a>
<!-- Download Invoice -->
@if($order->invoice)
<a href="{{ route('buyer.business.invoices.pdf', [($business ?? auth()->user()->businesses->first())->slug, $order->invoice]) }}" target="_blank" class="btn btn-ghost btn-sm" title="Download Invoice">
<span class="icon-[lucide--file-text] size-4"></span>
</a>
@endif
<!-- Download Manifest -->
@if($order->manifest)
<a href="{{ route('buyer.business.orders.manifest.pdf', [($business ?? auth()->user()->businesses->first())->slug, $order]) }}" target="_blank" class="btn btn-ghost btn-sm" title="Download Manifest">
<span class="icon-[lucide--truck] size-4"></span>
</a>
@endif
<!-- Accept Order (for seller-created orders) -->
@if($order->status === 'new' && $order->created_by === 'seller')
<form method="POST" action="{{ route('buyer.business.orders.accept', [($business ?? auth()->user()->businesses->first())->slug, $order]) }}" class="inline">
@csrf
<button type="submit" class="btn btn-ghost btn-sm text-success" onclick="return confirm('Accept this order?')" title="Accept Order">
<span class="icon-[lucide--check-circle] size-4"></span>
</button>
</form>
@endif
<!-- Cancel Order (only for 'new' status - direct cancel without seller approval) -->
@if($order->status === 'new' && $order->canBeCancelledByBuyer())
<button onclick="document.getElementById('cancelRequestModal{{ $order->id }}').showModal()" class="btn btn-ghost btn-sm text-error" title="Cancel Order">
<span class="icon-[lucide--x-circle] size-4"></span>
<div class="dropdown {{ $loop->last ? 'dropdown-top' : '' }} dropdown-end">
<button tabindex="0" role="button" class="btn btn-ghost btn-sm">
<span class="icon-[lucide--more-vertical] size-4"></span>
</button>
@endif
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-52 p-2 z-[100] {{ $loop->last ? 'mb-1' : '' }}">
<li>
<a href="{{ route('buyer.business.orders.show', [($business ?? auth()->user()->businesses->first())->slug, $order]) }}">
<span class="icon-[lucide--eye] size-4"></span>
View Details
</a>
</li>
@if($order->invoice)
<li>
<a href="{{ route('buyer.business.invoices.pdf', [($business ?? auth()->user()->businesses->first())->slug, $order->invoice]) }}" target="_blank">
<span class="icon-[lucide--file-text] size-4"></span>
Download Invoice
</a>
</li>
@endif
@if($order->manifest)
<li>
<a href="{{ route('buyer.business.orders.manifest.pdf', [($business ?? auth()->user()->businesses->first())->slug, $order]) }}" target="_blank">
<span class="icon-[lucide--truck] size-4"></span>
Download Manifest
</a>
</li>
@endif
@if($order->status === 'new' && $order->created_by === 'seller')
<li>
<form method="POST" action="{{ route('buyer.business.orders.accept', [($business ?? auth()->user()->businesses->first())->slug, $order]) }}" class="w-full">
@csrf
<button type="submit" class="w-full text-left" onclick="return confirm('Accept this order?')">
<span class="icon-[lucide--check-circle] size-4"></span>
Accept Order
</button>
</form>
</li>
@endif
</ul>
</div>
</td>
</tr>
@@ -143,85 +146,5 @@
</div>
</div>
</div>
<!-- Cancellation Modals -->
@foreach($orders as $order)
<dialog id="cancelRequestModal{{ $order->id }}" class="modal">
<div class="modal-box">
@if($order->canBeCancelledByBuyer())
{{-- Direct cancellation for 'new' orders --}}
<h3 class="font-bold text-lg mb-4">Cancel Order {{ $order->order_number }}</h3>
<p class="text-sm text-base-content/60 mb-4">This order has not been accepted yet and will be cancelled immediately. Please provide a reason:</p>
<form method="POST" action="{{ route('buyer.business.orders.cancel', [($business ?? auth()->user()->businesses->first())->slug, $order]) }}">
@csrf
<div class="form-control w-full mb-4">
<label class="label">
<span class="label-text">Cancellation Reason *</span>
</label>
<textarea
name="reason"
class="textarea textarea-bordered w-full"
placeholder="Please explain why you're cancelling this order..."
rows="3"
required></textarea>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" onclick="document.getElementById('cancelRequestModal{{ $order->id }}').close()">
Nevermind
</button>
<button type="submit" class="btn btn-error">
<span class="icon-[lucide--x-circle] size-4"></span>
Cancel Order
</button>
</div>
</form>
@elseif($order->canRequestCancellation())
{{-- Cancellation request for accepted orders --}}
<h3 class="font-bold text-lg mb-4">Request Cancellation for Order {{ $order->order_number }}</h3>
<p class="text-sm text-base-content/60 mb-4">The seller will review your cancellation request. Please provide a reason:</p>
<form method="POST" action="{{ route('buyer.business.orders.request-cancellation', [($business ?? auth()->user()->businesses->first())->slug, $order]) }}">
@csrf
<div class="form-control w-full mb-4">
<label class="label">
<span class="label-text">Cancellation Reason *</span>
</label>
<textarea
name="reason"
class="textarea textarea-bordered w-full"
placeholder="Please explain why you're requesting to cancel this order..."
rows="3"
required></textarea>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" onclick="document.getElementById('cancelRequestModal{{ $order->id }}').close()">
Nevermind
</button>
<button type="submit" class="btn btn-warning">
<span class="icon-[lucide--send] size-4"></span>
Submit Request
</button>
</div>
</form>
@endif
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
@endforeach
</div>
@endsection
@push('scripts')
<script>
@foreach($orders as $order)
function showCancelRequestModal{{ $order->id }}() {
document.getElementById('cancelRequestModal{{ $order->id }}').showModal();
}
@endforeach
</script>
@endpush

View File

@@ -87,7 +87,7 @@
<p class="text-xs font-medium text-base-content/60 mb-2">Certificates of Analysis:</p>
<div class="flex flex-wrap gap-2">
@foreach($item->batch->coaFiles as $coaFile)
<a href="{{ Storage::url($coaFile->file_path) }}" target="_blank" class="btn btn-xs btn-outline gap-1">
<a href="{{ route('coa.download', $coaFile->id) }}" target="_blank" class="btn btn-xs btn-outline gap-1">
<span class="icon-[lucide--file-text] size-3"></span>
@if($coaFile->is_primary)
Primary COA

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,13 @@
}"
@scroll.debounce.150ms="localStorage.setItem('buyer-sidebar-scroll-position', $el.scrollTop)">
<div class="mb-3 space-y-0.5 px-2.5" x-data="{
init() {
// Auto-expand sections when user is on a page within that section
const isOnOrdersOrInvoices = {{ request()->routeIs('buyer.business.orders*', 'buyer.business.invoices*') ? 'true' : 'false' }};
if (isOnOrdersOrInvoices) {
this.menuPurchases = true;
}
},
menuDashboard: $persist(true).as('buyer-sidebar-menu-dashboard'),
menuPurchases: $persist(true).as('buyer-sidebar-menu-purchases'),
menuBusiness: $persist(true).as('buyer-sidebar-menu-business')

View File

@@ -1,7 +1,7 @@
@extends('layouts.app-with-sidebar')
@section('content')
<div class="container-fluid py-6" x-data="invoiceEditor()">
<div class="container-fluid py-6">
<!-- Page Header -->
<div class="flex justify-between items-center mb-6">
<div>
@@ -9,15 +9,6 @@
<p class="text-gray-600 mt-1">Issued on {{ $invoice->invoice_date->format('F j, Y') }}</p>
</div>
<div class="flex gap-2">
@if($invoice->isPendingBuyerApproval() && !$invoice->isBuyerModified())
<button
type="button"
class="btn btn-primary"
@click="toggleEditMode()"
x-text="editMode ? 'Cancel Editing' : 'Edit Invoice'">
Edit Invoice
</button>
@endif
<a href="{{ route('seller.business.orders.show', [$business->slug, $invoice->order]) }}" class="btn btn-ghost">
<span class="icon-[lucide--arrow-left] size-5"></span>
Back to Order
@@ -25,40 +16,6 @@
</div>
</div>
<!-- Status Alerts -->
<div x-show="editMode" x-cloak class="alert alert-info mb-6">
<span class="icon-[lucide--edit] size-5"></span>
<div class="flex-1">
<p class="font-semibold">Edit Mode Active</p>
<p class="text-sm">Modify the picked quantities below. Changes will be logged and applied immediately.</p>
</div>
</div>
{{-- Picking Status for Manual Invoices --}}
@if($invoice->order->created_by === 'seller' && $invoice->order->status === 'ready_for_invoice')
<div class="alert alert-info mb-6">
<span class="icon-[lucide--package-check] size-5"></span>
<div class="flex-1">
<p class="font-semibold">Picking Complete - Ready to Finalize</p>
<p class="text-sm">Lab has completed picking for this manual invoice. Review the picked quantities below and finalize to send to buyer for approval.</p>
@if($invoice->order->picking_ticket_number)
<p class="text-sm mt-1">
<a href="{{ route('seller.business.pick', [$business->slug, $invoice->order->picking_ticket_number]) }}" class="text-primary hover:underline">
View Picking Ticket: {{ $invoice->order->picking_ticket_number }}
</a>
</p>
@endif
</div>
<form method="POST" action="{{ route('seller.business.invoices.finalize', [$business->slug, $invoice->invoice_number]) }}">
@csrf
<button type="submit" class="btn btn-success">
<span class="icon-[lucide--send] size-5"></span>
Finalize & Send to Buyer
</button>
</form>
</div>
@endif
{{-- Show picking in progress for manual invoices --}}
@if($invoice->order->created_by === 'seller' && $invoice->order->status === 'accepted' && $invoice->order->picking_ticket_number)
<div class="alert alert-warning mb-6">
@@ -79,38 +36,6 @@
</div>
@endif
{{-- Only show "Awaiting buyer approval" if invoice has actually been sent to buyer --}}
@if($invoice->isPendingBuyerApproval() && $invoice->order->status === 'awaiting_invoice_approval')
<div class="alert alert-warning mb-6">
<span class="icon-[lucide--clock] size-5"></span>
<span>Awaiting buyer approval. The buyer will review and approve this invoice before proceeding.</span>
</div>
@endif
@if($invoice->isApproved())
<div class="alert alert-success mb-6">
<span class="icon-[lucide--check-circle] size-5"></span>
<span>This invoice has been approved by the buyer and is being processed.</span>
</div>
@endif
@if($invoice->isRejected())
<div class="alert alert-error mb-6">
<span class="icon-[lucide--x-circle] size-5"></span>
<div>
<p class="font-bold">Invoice Rejected by Buyer</p>
<p class="text-sm">Reason: {{ $invoice->rejection_reason }}</p>
</div>
</div>
@endif
@if($invoice->isBuyerModified())
<div class="alert alert-info mb-6">
<span class="icon-[lucide--edit] size-5"></span>
<span>The buyer has requested modifications to this invoice. Review the changes below.</span>
</div>
@endif
<!-- Invoice Information Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
<!-- Invoice Details -->
@@ -142,14 +67,6 @@
{{ $invoice->order->order_number }}
</a>
</div>
<div>
<p class="text-sm text-gray-600">Approval Status</p>
<div class="mt-1">
<span class="badge badge-lg {{ $invoice->isApproved() ? 'badge-success' : ($invoice->isRejected() ? 'badge-error' : 'badge-warning') }}">
{{ str_replace('_', ' ', ucfirst($invoice->approval_status)) }}
</span>
</div>
</div>
</div>
</div>
</div>
@@ -295,56 +212,22 @@
$totalUnits = $item->quantity * $unitsPerCase;
$pickedUnits = $item->picked_qty * $unitsPerCase;
@endphp
<!-- Static View -->
<div x-show="!editMode">
@if($unitsPerCase > 1)
<div class="flex flex-col gap-1">
<span class="font-semibold">
{{ $item->picked_qty }}/{{ $item->quantity }} {{ \Illuminate\Support\Str::plural('Case', $item->quantity) }}
</span>
<span class="text-xs text-base-content/60">
({{ $pickedUnits }}/{{ $totalUnits }} units)
</span>
</div>
@else
@if($unitsPerCase > 1)
<div class="flex flex-col gap-1">
<span class="font-semibold">
{{ $item->picked_qty }}
<span class="text-gray-400">/{{ $item->quantity }}</span>
{{ \Illuminate\Support\Str::plural('Unit', $item->quantity) }}
{{ $item->picked_qty }}/{{ $item->quantity }} {{ \Illuminate\Support\Str::plural('Case', $item->quantity) }}
</span>
@endif
</div>
<!-- Edit Mode -->
<div x-show="editMode" x-cloak class="flex items-center gap-2">
<button
type="button"
class="btn btn-circle btn-outline btn-xs"
@click="items[{{ $item->id }}].editableQty = Math.max(0, items[{{ $item->id }}].editableQty - 1)"
:disabled="items[{{ $item->id }}].editableQty <= 0">
<span class="icon-[lucide--minus] size-3"></span>
</button>
<input
type="number"
class="input input-bordered input-sm w-16 text-center"
x-model.number="items[{{ $item->id }}].editableQty"
min="0"
max="{{ $item->quantity }}"
step="1">
<button
type="button"
class="btn btn-circle btn-outline btn-xs"
@click="items[{{ $item->id }}].editableQty = Math.min(items[{{ $item->id }}].orderedQty, items[{{ $item->id }}].editableQty + 1)"
:disabled="items[{{ $item->id }}].editableQty >= {{ $item->quantity }}">
<span class="icon-[lucide--plus] size-3"></span>
</button>
<span class="text-xs text-gray-400">/{{ $item->quantity }}</span>
<span x-show="items[{{ $item->id }}].editableQty !== items[{{ $item->id }}].originalQty" class="badge badge-warning badge-xs">Modified</span>
</div>
<span class="text-xs text-base-content/60">
({{ $pickedUnits }}/{{ $totalUnits }} units)
</span>
</div>
@else
<span class="font-semibold">
{{ $item->picked_qty }}
<span class="text-gray-400">/{{ $item->quantity }}</span>
{{ \Illuminate\Support\Str::plural('Unit', $item->quantity) }}
</span>
@endif
</td>
<td>
@php
@@ -367,8 +250,7 @@
@endif
</td>
<td class="font-semibold">
<span x-show="!editMode">${{ number_format($item->line_total, 2) }}</span>
<span x-show="editMode" x-text="'$' + (items[{{ $item->id }}].editableQty * items[{{ $item->id }}].unitPrice).toFixed(2)" x-cloak></span>
${{ number_format($item->line_total, 2) }}
</td>
</tr>
@endforeach
@@ -376,7 +258,7 @@
<tfoot>
<tr class="font-bold">
<td colspan="5" class="text-right">Subtotal:</td>
<td x-text="'$' + calculateSubtotal().toFixed(2)">${{ number_format($invoice->subtotal, 2) }}</td>
<td>${{ number_format($invoice->subtotal, 2) }}</td>
</tr>
@if($invoice->order && $invoice->order->surcharge > 0)
<tr class="text-sm">
@@ -392,79 +274,14 @@
@endif
<tr class="font-bold text-lg">
<td colspan="5" class="text-right">Total:</td>
<td class="text-primary" x-text="'$' + calculateTotal().toFixed(2)">${{ number_format($invoice->total, 2) }}</td>
<td class="text-primary">${{ number_format($invoice->total, 2) }}</td>
</tr>
</tfoot>
</table>
</div>
<!-- Save Changes Button -->
<div x-show="editMode" x-cloak class="mt-6 flex justify-end gap-2">
<button
type="button"
class="btn btn-ghost"
@click="toggleEditMode()">
Cancel
</button>
<button
type="button"
class="btn btn-success"
@click="saveChanges()"
:disabled="saving || !hasChanges()">
<span x-show="!saving" class="icon-[lucide--save] size-5"></span>
<span x-show="saving" class="loading loading-spinner loading-sm"></span>
<span x-text="saving ? 'Saving...' : 'Save Changes'">Save Changes</span>
</button>
</div>
</div>
</div>
<!-- Change History -->
@if($invoice->changes->isNotEmpty())
<div class="card bg-base-100 shadow-lg mt-6">
<div class="card-body">
<h2 class="card-title mb-4">
<span class="icon-[lucide--history] size-5"></span>
Change History
</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Round</th>
<th>Type</th>
<th>Product</th>
<th>Old Value</th>
<th>New Value</th>
<th>Changed By</th>
<th>Status</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
@foreach($invoice->changes as $change)
<tr>
<td>{{ $change->negotiation_round }}</td>
<td>{{ ucfirst(str_replace('_', ' ', $change->change_type)) }}</td>
<td>{{ $change->orderItem?->product_name ?? 'N/A' }}</td>
<td>{{ $change->old_value }}</td>
<td>{{ $change->new_value }}</td>
<td>{{ ucfirst($change->user_type) }}</td>
<td>
<span class="badge badge-sm {{ $change->isApproved() ? 'badge-success' : ($change->isRejected() ? 'badge-error' : 'badge-warning') }}">
{{ ucfirst(str_replace('_', ' ', $change->status)) }}
</span>
</td>
<td class="text-sm text-gray-600">{{ $change->created_at->format('M j, Y g:i A') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
@if($invoice->notes)
<!-- Notes Section -->
<div class="card bg-base-100 shadow-lg mt-6">
@@ -479,120 +296,4 @@
@endif
</div>
@push('scripts')
<script>
function invoiceEditor() {
return {
editMode: false,
saving: false,
items: {},
init() {
// Initialize items store
@foreach($invoice->order->items as $item)
this.items[{{ $item->id }}] = {
originalQty: {{ $item->picked_qty }},
editableQty: {{ $item->picked_qty }},
orderedQty: {{ $item->quantity }},
unitPrice: {{ $item->unit_price }}
};
@endforeach
},
toggleEditMode() {
this.editMode = !this.editMode;
// Reset all quantities when canceling
if (!this.editMode) {
Object.keys(this.items).forEach(itemId => {
this.items[itemId].editableQty = this.items[itemId].originalQty;
});
}
},
hasChanges() {
return Object.values(this.items).some(item =>
item.editableQty !== item.originalQty
);
},
calculateSubtotal() {
if (!this.editMode) {
return {{ $invoice->subtotal }};
}
return Object.values(this.items).reduce((sum, item) => {
return sum + (item.editableQty * item.unitPrice);
}, 0);
},
calculateTotal() {
if (!this.editMode) {
return {{ $invoice->total }};
}
// Calculate: subtotal + surcharge + tax
const subtotal = this.calculateSubtotal();
const surcharge = {{ $invoice->order->surcharge ?? 0 }};
const tax = {{ $invoice->tax ?? 0 }};
return subtotal + surcharge + tax;
},
async saveChanges() {
if (!this.hasChanges()) {
alert('No changes to save');
return;
}
this.saving = true;
// Collect all modifications
const modifications = {};
Object.keys(this.items).forEach(itemId => {
const item = this.items[itemId];
if (item.editableQty !== item.originalQty) {
modifications[itemId] = {
picked_qty: item.editableQty
};
}
});
console.log('Saving modifications:', modifications);
try {
const response = await fetch('{{ route('seller.business.invoices.update', [$business->slug, $invoice]) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json'
},
body: JSON.stringify({ modifications })
});
if (!response.ok) {
const errorText = await response.text();
console.error('Server response:', errorText);
throw new Error(`Server error: ${response.status}`);
}
const data = await response.json();
if (data.success) {
// Reload the page to show updated invoice
window.location.reload();
} else {
alert('Failed to save changes: ' + (data.message || 'Unknown error'));
this.saving = false;
}
} catch (error) {
console.error('Error saving changes:', error);
alert('Failed to save changes: ' + error.message);
this.saving = false;
}
}
};
}
</script>
@endpush
@endsection

View File

@@ -210,7 +210,7 @@
<span class="badge badge-primary badge-xs mt-1">Primary</span>
@endif
</div>
<a href="{{ Storage::url($coaFile->file_path) }}" target="_blank" class="btn btn-ghost btn-sm btn-square" title="View file">
<a href="{{ route('coa.download', $coaFile->id) }}" target="_blank" class="btn btn-ghost btn-sm btn-square" title="View file">
<span class="icon-[lucide--external-link] size-4"></span>
</a>
</div>

View File

@@ -127,7 +127,7 @@
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300 {{ $loop->last ? 'mb-1' : '' }}">
@if($lab->coaFiles->count() > 0)
<li>
<a href="{{ $lab->getPrimaryCoa() ? Storage::url($lab->getPrimaryCoa()->file_path) : Storage::url($lab->coaFiles->first()->file_path) }}" target="_blank">
<a href="{{ route('coa.download', $lab->getPrimaryCoa() ? $lab->getPrimaryCoa()->id : $lab->coaFiles->first()->id) }}" target="_blank">
<span class="icon-[lucide--download] size-4"></span>
Download COA
</a>

View File

@@ -4,6 +4,7 @@
<div class="container-fluid py-6" x-data="pickingTicket()">
@php
$isTicketCompleted = isset($ticket) ? $ticket->status === 'completed' : false;
$isTicketPending = isset($ticket) ? $ticket->status === 'pending' : false;
@endphp
<!-- Print-only header with key information -->
@@ -36,6 +37,17 @@
</div>
</div>
<!-- Pending Ticket Alert -->
@if($isTicketPending)
<div class="alert alert-info mb-6">
<span class="icon-[lucide--info] size-6"></span>
<div class="flex-1">
<h3 class="font-semibold">Ticket Not Started</h3>
<p class="text-sm opacity-90">Click "Start Pick" to begin picking items for this ticket. Quantities are locked until you start.</p>
</div>
</div>
@endif
<!-- Completed Ticket Alert -->
@if($isTicketCompleted)
<div class="alert alert-success mb-6">
@@ -69,23 +81,42 @@
<span class="icon-[lucide--printer] size-5"></span>
Print Ticket
</a>
@if(!$isTicketCompleted)
<button
type="button"
onclick="document.getElementById('completeTicketModal').showModal()"
class="btn btn-success">
<span class="icon-[lucide--check-circle] size-5"></span>
Complete Ticket
</button>
@elseif($isTicketCompleted && in_array($order->status, ['accepted', 'in_progress']))
<button
type="button"
onclick="document.getElementById('reopenTicketModal').showModal()"
class="btn btn-warning">
<span class="icon-[lucide--rotate-ccw] size-5"></span>
Re-open Picking Ticket
</button>
@endif
@isset($ticket)
@if($ticket->status === 'pending')
<form method="POST" action="{{ route('seller.business.pick.start', [$business->slug, $ticket->ticket_number]) }}">
@csrf
<button type="submit" class="btn btn-primary">
<span class="icon-[lucide--play] size-5"></span>
Start Pick
</button>
</form>
@elseif(!$isTicketCompleted)
<button
type="button"
onclick="document.getElementById('completeTicketModal').showModal()"
class="btn btn-success">
<span class="icon-[lucide--check-circle] size-5"></span>
Complete Ticket
</button>
@elseif($isTicketCompleted && in_array($order->status, ['accepted', 'in_progress']))
<button
type="button"
onclick="document.getElementById('reopenTicketModal').showModal()"
class="btn btn-warning">
<span class="icon-[lucide--rotate-ccw] size-5"></span>
Re-open Picking Ticket
</button>
@endif
@else
{{-- Old system: No ticket, show complete button --}}
<button
type="button"
onclick="document.getElementById('completeTicketModal').showModal()"
class="btn btn-success">
<span class="icon-[lucide--check-circle] size-5"></span>
Complete Ticket
</button>
@endisset
<a href="{{ route('seller.business.orders.show', [$business->slug, $order]) }}" class="btn btn-ghost">
<span class="icon-[lucide--arrow-left] size-5"></span>
Back to Order
@@ -248,8 +279,11 @@
<span class="font-semibold text-lg" x-text="ordered">{{ $item->quantity }}</span>
</td>
<td class="text-center">
@if($isTicketCompleted)
@if($isTicketCompleted || $isTicketPending)
<span class="font-semibold text-lg" x-text="picked">{{ $item->picked_qty }}</span>
@if($isTicketPending)
<div class="text-xs text-base-content/60 mt-1">Click "Start Pick" to enable</div>
@endif
@else
<div class="flex items-center justify-center gap-2">
<button
@@ -280,7 +314,13 @@
</div>
@endif
</td>
<td class="text-center">
<td class="text-center print:hidden">
@if($isTicketPending)
<span class="badge badge-ghost badge-sm">
<span class="icon-[lucide--lock] size-3"></span>
Locked
</span>
@else
<template x-if="picked === 0">
<span class="badge badge-ghost badge-sm">Not Started</span>
</template>
@@ -295,6 +335,7 @@
Complete
</span>
</template>
@endif
</td>
</tr>
@endforeach
@@ -395,7 +436,7 @@
<span class="loading loading-spinner loading-xs"></span> Saving...
</div>
</td>
<td class="text-center">
<td class="text-center print:hidden">
<template x-if="picked === 0">
<span class="badge badge-ghost badge-sm">Not Started</span>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -185,8 +185,20 @@ Route::prefix('b')->name('buyer.')->group(function () {
Route::get('/orders', [\App\Http\Controllers\Buyer\OrderController::class, 'index'])->name('business.orders.index');
Route::get('/orders/{order}', [\App\Http\Controllers\Buyer\OrderController::class, 'show'])->name('business.orders.show');
Route::patch('/orders/{order}/update-fulfillment', [\App\Http\Controllers\Buyer\OrderController::class, 'updateFulfillment'])->name('business.orders.update-fulfillment');
Route::patch('/orders/{order}/update-delivery-window', [\App\Http\Controllers\Buyer\OrderController::class, 'updateDeliveryWindow'])->name('business.orders.update-delivery-window');
Route::get('/orders/{order}/available-delivery-windows', [\App\Http\Controllers\Buyer\OrderController::class, 'getAvailableDeliveryWindows'])->name('business.orders.available-delivery-windows');
Route::post('/orders/{order}/accept', [\App\Http\Controllers\Buyer\OrderController::class, 'accept'])->name('business.orders.accept');
Route::post('/orders/{order}/cancel', [\App\Http\Controllers\Buyer\OrderController::class, 'cancel'])->name('business.orders.cancel');
Route::post('/orders/{order}/request-cancellation', [\App\Http\Controllers\Buyer\OrderController::class, 'requestCancellation'])->name('business.orders.request-cancellation');
// Pre-delivery review (Review #1: Before delivery - buyer reviews COAs and can reject items)
Route::get('/orders/{order}/pre-delivery-review', [\App\Http\Controllers\Buyer\OrderController::class, 'showPreDeliveryApproval'])->name('business.orders.pre-delivery-review');
Route::post('/orders/{order}/process-pre-delivery-review', [\App\Http\Controllers\Buyer\OrderController::class, 'processPreDeliveryApproval'])->name('business.orders.process-pre-delivery-review');
// Delivery acceptance (Review #2: After delivery - buyer accepts/rejects delivered items)
Route::get('/orders/{order}/acceptance', [\App\Http\Controllers\Buyer\OrderController::class, 'showAcceptance'])->name('business.orders.acceptance');
Route::post('/orders/{order}/process-acceptance', [\App\Http\Controllers\Buyer\OrderController::class, 'processAcceptance'])->name('business.orders.process-acceptance');
Route::get('/orders/{order}/manifest/pdf', [\App\Http\Controllers\Buyer\OrderController::class, 'downloadManifestPdf'])->name('business.orders.manifest.pdf');
// Favorites and wishlists (business-scoped)
@@ -198,9 +210,6 @@ Route::prefix('b')->name('buyer.')->group(function () {
Route::get('/invoices', [\App\Http\Controllers\Buyer\InvoiceController::class, 'index'])->name('business.invoices.index');
Route::get('/invoices/{invoice}', [\App\Http\Controllers\Buyer\InvoiceController::class, 'show'])->name('business.invoices.show');
Route::get('/invoices/{invoice}/pdf', [\App\Http\Controllers\Buyer\InvoiceController::class, 'downloadPdf'])->name('business.invoices.pdf');
Route::post('/invoices/{invoice}/approve', [\App\Http\Controllers\Buyer\InvoiceController::class, 'approve'])->name('business.invoices.approve');
Route::post('/invoices/{invoice}/reject', [\App\Http\Controllers\Buyer\InvoiceController::class, 'reject'])->name('business.invoices.reject');
Route::post('/invoices/{invoice}/modify', [\App\Http\Controllers\Buyer\InvoiceController::class, 'modify'])->name('business.invoices.modify');
// ========================================
// MODULE ROUTES (Isolated Features)

View File

@@ -30,7 +30,7 @@ Route::bind('order', function (string $value) {
// Custom route model binding for picking tickets
// Picking tickets are stored with PT- prefix in database
Route::bind('pickingTicket', function (string $value) {
return \App\Models\Order::where('picking_ticket_number', $value)
return \App\Models\PickingTicket::where('ticket_number', $value)
->firstOrFail();
});
@@ -162,6 +162,23 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::post('/{order}/reject', [\App\Http\Controllers\OrderController::class, 'reject'])->name('reject');
Route::post('/{order}/cancel', [\App\Http\Controllers\OrderController::class, 'cancel'])->name('cancel');
// Workflow actions
Route::post('/{order}/mark-ready-for-delivery', [\App\Http\Controllers\OrderController::class, 'markReadyForDelivery'])->name('mark-ready-for-delivery');
Route::post('/{order}/approve-for-delivery', [\App\Http\Controllers\OrderController::class, 'approveForDelivery'])->name('approve-for-delivery');
Route::patch('/{order}/mark-out-for-delivery', [\App\Http\Controllers\OrderController::class, 'markOutForDelivery'])->name('mark-out-for-delivery');
Route::patch('/{order}/confirm-delivery', [\App\Http\Controllers\OrderController::class, 'confirmDelivery'])->name('confirm-delivery');
Route::post('/{order}/confirm-pickup', [\App\Http\Controllers\OrderController::class, 'confirmPickup'])->name('confirm-pickup');
Route::post('/{order}/finalize', [\App\Http\Controllers\OrderController::class, 'finalizeOrder'])->name('finalize');
// Delivery window management
Route::patch('/{order}/update-delivery-window', [\App\Http\Controllers\OrderController::class, 'updateDeliveryWindow'])->name('update-delivery-window');
Route::get('/{order}/available-delivery-windows', [\App\Http\Controllers\OrderController::class, 'getAvailableDeliveryWindows'])->name('available-delivery-windows');
Route::patch('/{order}/update-pickup-date', [\App\Http\Controllers\OrderController::class, 'updatePickupDate'])->name('update-pickup-date');
// Cancellation request management
Route::post('/{order}/cancellation-request/{cancellationRequest}/approve', [\App\Http\Controllers\OrderController::class, 'approveCancellationRequest'])->name('cancellation-request.approve');
Route::post('/{order}/cancellation-request/{cancellationRequest}/deny', [\App\Http\Controllers\OrderController::class, 'denyCancellationRequest'])->name('cancellation-request.deny');
// Manifest Management
Route::prefix('{order}/manifest')->name('manifest.')->group(function () {
Route::get('/create', [\App\Http\Controllers\OrderController::class, 'createManifest'])->name('create');
@@ -175,7 +192,10 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
// Picking Ticket Management (shareable PT-XXXXX URLs)
Route::get('/pick/{pickingTicket}', [\App\Http\Controllers\OrderController::class, 'pick'])->name('pick');
Route::get('/pick/{pickingTicket}/pdf', [\App\Http\Controllers\OrderController::class, 'downloadPickingTicketPdf'])->name('pick.pdf');
Route::post('/pick/{pickingTicket}/start', [\App\Http\Controllers\OrderController::class, 'startPick'])->name('pick.start');
Route::post('/pick/{pickingTicket}/complete', [\App\Http\Controllers\OrderController::class, 'complete'])->name('pick.complete');
Route::post('/pick/{pickingTicket}/reopen', [\App\Http\Controllers\OrderController::class, 'reopen'])->name('pick.reopen');
// AJAX endpoint for updating picked quantities
Route::post('/workorders/update-pickedqty', [\App\Http\Controllers\Api\WorkorderController::class, 'updatePickedQuantity'])
@@ -188,8 +208,6 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::post('/', [\App\Http\Controllers\Seller\InvoiceController::class, 'store'])->name('store');
Route::get('/contacts', [\App\Http\Controllers\Seller\InvoiceController::class, 'getContacts'])->name('get-contacts'); // AJAX endpoint
Route::get('/{invoice}', [\App\Http\Controllers\Seller\InvoiceController::class, 'show'])->name('show');
Route::post('/{invoice}', [\App\Http\Controllers\Seller\InvoiceController::class, 'update'])->name('update');
Route::post('/{invoice}/finalize', [\App\Http\Controllers\Seller\InvoiceController::class, 'finalize'])->name('finalize');
Route::get('/{invoice}/pdf', [\App\Http\Controllers\Seller\InvoiceController::class, 'downloadPdf'])->name('pdf');
});

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\CoaController;
use App\Http\Controllers\ImageController;
use App\Http\Controllers\ProfileController;
use App\Models\User;
@@ -13,6 +14,9 @@ use Illuminate\Support\Facades\Route;
Route::get('/images/brand-logo/{brand}/{width?}', [ImageController::class, 'brandLogo'])->name('image.brand-logo');
Route::get('/images/brand-banner/{brand}/{width?}', [ImageController::class, 'brandBanner'])->name('image.brand-banner');
// COA download route (public, no auth required - can add auth later if needed)
Route::get('/coa/{coaId}/download', [CoaController::class, 'download'])->name('coa.download');
Route::get('/', function () {
// Redirect unauthenticated users to registration page
if (! Auth::check()) {

View File

@@ -1,332 +0,0 @@
@extends('layouts.app-with-sidebar')
@section('content')
<!-- Page Title and Breadcrumbs -->
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Order Settings</h1>
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li><a href="{{ route('seller.business.settings.orders', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Orders</li>
</ul>
</div>
</div>
<form action="{{ route('seller.business.settings.orders.update', $business->slug) }}" method="POST">
@csrf
@method('PUT')
<!-- Order Preferences -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Preferences</h2>
<div class="space-y-4">
<!-- Separate Orders by Brand -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="separate_orders_by_brand"
value="1"
class="checkbox checkbox-primary"
{{ old('separate_orders_by_brand', $business->separate_orders_by_brand) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Separate Orders by Brand</span>
<p class="text-xs text-base-content/60">Create individual orders for each brand in multi-brand purchases</p>
</div>
</label>
</div>
<!-- Auto Increment Order IDs -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="auto_increment_order_ids"
value="1"
class="checkbox checkbox-primary"
{{ old('auto_increment_order_ids', $business->auto_increment_order_ids) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Auto Increment Order IDs</span>
<p class="text-xs text-base-content/60">Automatically generate sequential order numbers</p>
</div>
</label>
</div>
<!-- Show Mark as Paid -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="show_mark_as_paid"
value="1"
class="checkbox checkbox-primary"
{{ old('show_mark_as_paid', $business->show_mark_as_paid ?? true) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Show Mark as Paid</span>
<p class="text-xs text-base-content/60">Display "Mark as Paid" option in order management</p>
</div>
</label>
</div>
<!-- Display CRM License on Orders -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="display_crm_license_on_orders"
value="1"
class="checkbox checkbox-primary"
{{ old('display_crm_license_on_orders', $business->display_crm_license_on_orders) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Display CRM License on Orders</span>
<p class="text-xs text-base-content/60">Show business license number on order documents</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Financial Settings -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Financial Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Order Minimum -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Order Minimum</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="order_minimum"
value="{{ old('order_minimum', $business->order_minimum) }}"
class="input input-bordered w-full @error('order_minimum') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Minimum order amount required</span>
</label>
@error('order_minimum')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Default Shipping Charge -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Default Shipping Charge</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="default_shipping_charge"
value="{{ old('default_shipping_charge', $business->default_shipping_charge) }}"
class="input input-bordered w-full @error('default_shipping_charge') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Standard shipping fee per order</span>
</label>
@error('default_shipping_charge')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Free Shipping Minimum -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Free Shipping Minimum</span>
</label>
<label class="input-group">
<span class="bg-base-200">$</span>
<input
type="number"
name="free_shipping_minimum"
value="{{ old('free_shipping_minimum', $business->free_shipping_minimum) }}"
class="input input-bordered w-full @error('free_shipping_minimum') input-error @enderror"
placeholder="0.00"
step="0.01"
min="0"
/>
</label>
<label class="label">
<span class="label-text-alt">Order amount for free shipping</span>
</label>
@error('free_shipping_minimum')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
</div>
<!-- Order Documents -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Documents</h2>
<!-- Order Disclaimer -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Order Disclaimer</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<textarea
name="order_disclaimer"
rows="4"
class="textarea textarea-bordered @error('order_disclaimer') textarea-error @enderror"
placeholder="Enter any disclaimer text to appear on orders..."
>{{ old('order_disclaimer', $business->order_disclaimer) }}</textarea>
<label class="label">
<span class="label-text-alt">Displayed on order confirmations and invoices</span>
</label>
@error('order_disclaimer')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
<!-- Order Invoice Footer -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Order Invoice Footer Copy</span>
<span class="label-text-alt text-base-content/60">Optional</span>
</label>
<textarea
name="order_invoice_footer"
rows="3"
class="textarea textarea-bordered @error('order_invoice_footer') textarea-error @enderror"
placeholder="Enter footer text for invoices..."
>{{ old('order_invoice_footer', $business->order_invoice_footer) }}</textarea>
<label class="label">
<span class="label-text-alt">Appears at the bottom of all invoices</span>
</label>
@error('order_invoice_footer')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Order Management -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Order Management</h2>
<!-- Prevent Order Editing -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Prevent Order Editing</span>
</label>
<select
name="prevent_order_editing"
class="select select-bordered @error('prevent_order_editing') select-error @enderror"
>
<option value="never" {{ old('prevent_order_editing', $business->prevent_order_editing ?? 'never') == 'never' ? 'selected' : '' }}>
Never - Always allow editing
</option>
<option value="after_approval" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'after_approval' ? 'selected' : '' }}>
After Approval - Lock once approved
</option>
<option value="after_fulfillment" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'after_fulfillment' ? 'selected' : '' }}>
After Fulfillment - Lock once fulfilled
</option>
<option value="always" {{ old('prevent_order_editing', $business->prevent_order_editing) == 'always' ? 'selected' : '' }}>
Always - Prevent all editing
</option>
</select>
<label class="label">
<span class="label-text-alt">Control when orders can no longer be edited</span>
</label>
@error('prevent_order_editing')
<label class="label">
<span class="label-text-alt text-error">{{ $message }}</span>
</label>
@enderror
</div>
</div>
</div>
<!-- Arizona Compliance Features -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h2 class="text-lg font-semibold mb-4">Arizona Compliance Features</h2>
<div class="space-y-4">
<!-- Require Patient Count -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="az_require_patient_count"
value="1"
class="checkbox checkbox-primary"
{{ old('az_require_patient_count', $business->az_require_patient_count) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Require Patient Count</span>
<p class="text-xs text-base-content/60">Require customer to provide patient count with orders (medical licenses)</p>
</div>
</label>
</div>
<!-- Require Allotment Verification -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
name="az_require_allotment_verification"
value="1"
class="checkbox checkbox-primary"
{{ old('az_require_allotment_verification', $business->az_require_allotment_verification) ? 'checked' : '' }}
/>
<div>
<span class="label-text font-medium">Require Allotment Verification</span>
<p class="text-xs text-base-content/60">Verify customer allotment availability before order confirmation</p>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-4">
<a href="{{ route('seller.business.dashboard', $business->slug) }}" class="btn btn-ghost gap-2">
<span class="icon-[lucide--x] size-4"></span>
Cancel
</a>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[lucide--save] size-4"></span>
Save Settings
</button>
</div>
</form>
@endsection

View File

@@ -1,45 +0,0 @@
#!/bin/bash
set -e
echo "======================================"
echo "Testing CI Composer Install Locally"
echo "======================================"
echo ""
# Use the same PHP image as CI
docker run --rm -v "$(pwd)":/app -w /app php:8.3-cli bash -c '
echo "Installing system dependencies..."
apt-get update -qq
apt-get install -y -qq git zip unzip libicu-dev libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libpq-dev
echo "Installing PHP extensions..."
docker-php-ext-configure gd --with-freetype --with-jpeg
docker-php-ext-install -j$(nproc) intl pdo pdo_pgsql zip gd pcntl
echo "Installing Composer..."
curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer --quiet
echo "Creating minimal .env for package discovery..."
cat > .env << "EOF"
APP_NAME="Cannabrands Hub"
APP_ENV=testing
APP_KEY=base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
APP_DEBUG=true
CACHE_STORE=array
SESSION_DRIVER=array
QUEUE_CONNECTION=sync
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=testing
DB_USERNAME=testing
DB_PASSWORD=testing
EOF
echo ""
echo "Running composer install..."
composer install --no-interaction --prefer-dist --optimize-autoloader --no-progress
echo ""
echo "✅ Success! CI composer install would pass."
'