Compare commits
44 Commits
feature/do
...
fix/seeder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9535042fca | ||
|
|
f85be8a676 | ||
|
|
fe0c6b22af | ||
|
|
06e35cb296 | ||
|
|
4b347112c6 | ||
|
|
632ddce08a | ||
|
|
35c603944f | ||
|
|
ea3ed4de0a | ||
|
|
179c9a7818 | ||
|
|
6835a19b39 | ||
|
|
3b9ddd8865 | ||
|
|
d9d8190835 | ||
|
|
8af01a6772 | ||
|
|
e11a934766 | ||
|
|
86c2e0cf1c | ||
|
|
f899e5f8cb | ||
|
|
f2b1ceebe9 | ||
|
|
b0e343f2b5 | ||
|
|
609d55d5c9 | ||
|
|
d649c8239f | ||
|
|
86b7d8db4e | ||
|
|
701534dd6b | ||
|
|
f341fc6673 | ||
|
|
103b7a6077 | ||
|
|
5a57fd1e27 | ||
|
|
6f56d21936 | ||
|
|
44cf1423e4 | ||
|
|
ceea43823b | ||
|
|
618d5aeea9 | ||
|
|
9c3e3b1c7b | ||
|
|
b3a5eebd56 | ||
|
|
dc804e8e25 | ||
|
|
20709d201f | ||
|
|
b3ae727c5a | ||
|
|
c004ee3b1e | ||
|
|
41f8bee6a6 | ||
|
|
f53124cd2e | ||
|
|
1d1ac2d520 | ||
|
|
bca2cd5c77 | ||
|
|
ff25196d51 | ||
|
|
58006d7b19 | ||
|
|
4237cf45ab | ||
|
|
5f591bee19 | ||
|
|
c9fa8d7578 |
@@ -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');
|
||||
```
|
||||
@@ -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.');
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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!
|
||||
@@ -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.
|
||||
120
CLAUDE.local.md
120
CLAUDE.local.md
@@ -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.
|
||||
10
Dockerfile
10
Dockerfile
@@ -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 ./
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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! 🚀
|
||||
@@ -1 +0,0 @@
|
||||
/home/kelly/Nextcloud/Claude Sessions/hub-session/SESSION_ACTIVE
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
95
app/Http/Controllers/CoaController.php
Normal file
95
app/Http/Controllers/CoaController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
])
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Models;
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Panel;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
@@ -19,7 +20,7 @@ use Spatie\Permission\Traits\HasRoles;
|
||||
class User extends Authenticatable implements FilamentUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasFactory, HasRoles, Impersonate, Notifiable;
|
||||
use HasFactory, HasRoles, HasUuids, Impersonate, Notifiable;
|
||||
|
||||
/**
|
||||
* User type constants
|
||||
@@ -53,6 +54,7 @@ class User extends Authenticatable implements FilamentUser
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
@@ -96,6 +98,35 @@ class User extends Authenticatable implements FilamentUser
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the columns that should receive a unique identifier.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function uniqueIds(): array
|
||||
{
|
||||
return ['uuid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
// Generate UUIDv7 using Ramsey UUID directly for time-ordered benefits
|
||||
$fullUuid = (string) \Ramsey\Uuid\Uuid::uuid7();
|
||||
|
||||
return substr($fullUuid, 0, 18); // First 48 bits of timestamp + version = time-ordered
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's full name by combining first and last name
|
||||
* This accessor ensures Filament can display the user's name
|
||||
|
||||
67
app/Observers/BatchObserver.php
Normal file
67
app/Observers/BatchObserver.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
1065
claude-rules-kelly
1065
claude-rules-kelly
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Add UUID column (18-char format to match Business model)
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->char('uuid', 18)->nullable()->unique()->after('id');
|
||||
$table->index('uuid');
|
||||
});
|
||||
|
||||
// Generate UUIDs for existing users
|
||||
$users = DB::table('users')->get();
|
||||
foreach ($users as $user) {
|
||||
$fullUuid = (string) Str::uuid();
|
||||
$shortUuid = substr($fullUuid, 0, 18); // First 16 hex chars + 2 hyphens
|
||||
|
||||
DB::table('users')
|
||||
->where('id', $user->id)
|
||||
->update(['uuid' => $shortUuid]);
|
||||
}
|
||||
|
||||
// Make UUID non-nullable after populating
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->char('uuid', 18)->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropUnique(['uuid']);
|
||||
$table->dropIndex(['uuid']);
|
||||
$table->dropColumn('uuid');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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' => '%',
|
||||
]);
|
||||
|
||||
|
||||
@@ -49,7 +49,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$kelly = User::firstOrCreate(
|
||||
['email' => 'kelly@canopyaz.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'Kelly',
|
||||
'last_name' => 'Martinez',
|
||||
'password' => bcrypt('password'),
|
||||
@@ -60,7 +59,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$lonnie = User::firstOrCreate(
|
||||
['email' => 'lonnie@canopyaz.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'Lonnie',
|
||||
'last_name' => 'Thompson',
|
||||
'password' => bcrypt('password'),
|
||||
@@ -71,7 +69,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$amy = User::firstOrCreate(
|
||||
['email' => 'amy@canopyaz.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'Amy',
|
||||
'last_name' => 'Chen',
|
||||
'password' => bcrypt('password'),
|
||||
@@ -83,7 +80,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$sarah = User::firstOrCreate(
|
||||
['email' => 'sarah@cannabrands.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'Sarah',
|
||||
'last_name' => 'Williams',
|
||||
'password' => bcrypt('password'),
|
||||
@@ -94,7 +90,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$michael = User::firstOrCreate(
|
||||
['email' => 'michael@cannabrands.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'Michael',
|
||||
'last_name' => 'Johnson',
|
||||
'password' => bcrypt('password'),
|
||||
@@ -108,7 +103,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$vinny = User::firstOrCreate(
|
||||
['email' => 'vinny@leopard-az.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'Vinny',
|
||||
'last_name' => 'Rodriguez',
|
||||
'password' => bcrypt('password'),
|
||||
@@ -119,7 +113,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$maria = User::firstOrCreate(
|
||||
['email' => 'maria@curagreenllc.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'Maria',
|
||||
'last_name' => 'Garcia',
|
||||
'password' => bcrypt('password'),
|
||||
@@ -131,7 +124,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$james = User::firstOrCreate(
|
||||
['email' => 'james@leopard-az.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'James',
|
||||
'last_name' => 'Park',
|
||||
'password' => bcrypt('password'),
|
||||
@@ -142,7 +134,6 @@ class ManufacturingSampleDataSeeder extends Seeder
|
||||
$lisa = User::firstOrCreate(
|
||||
['email' => 'lisa@leopard-az.com'],
|
||||
[
|
||||
'uuid' => substr((string) \Illuminate\Support\Str::uuid(), 0, 18),
|
||||
'first_name' => 'Lisa',
|
||||
'last_name' => 'Anderson',
|
||||
'password' => bcrypt('password'),
|
||||
|
||||
401
database/seeders/MarketplaceTestSeeder.php
Normal file
401
database/seeders/MarketplaceTestSeeder.php
Normal 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)];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ metadata:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
nginx.ingress.kubernetes.io/use-forwarded-headers: "true"
|
||||
# WebSocket support for Reverb
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
'
|
||||
Reference in New Issue
Block a user