Compare commits
252 Commits
docs/add-f
...
feature/br
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc8cb45533 | ||
|
|
a48051f0bb | ||
|
|
7c3f5a27a3 | ||
|
|
13d2fa3ac7 | ||
|
|
fab181128a | ||
|
|
fbb1619c38 | ||
|
|
9a9bfeae35 | ||
|
|
c7f3af5f39 | ||
|
|
0db14bda0e | ||
|
|
0ff3b64f80 | ||
|
|
2ca4338e7e | ||
|
|
d905805980 | ||
|
|
2f5cb5c0e7 | ||
|
|
86fef4d021 | ||
|
|
5b95c8b365 | ||
|
|
5c1863218f | ||
|
|
ee30c65c34 | ||
|
|
d10357758d | ||
|
|
59cd1c5a6b | ||
|
|
2b865f2633 | ||
|
|
d7f79c6a5b | ||
|
|
7eb658ef6c | ||
|
|
150ecb9124 | ||
|
|
2fe3e7abd9 | ||
|
|
dc975a4206 | ||
|
|
04668d1b29 | ||
|
|
d13184819f | ||
|
|
f05211c924 | ||
|
|
5cd86ed463 | ||
|
|
7dd4cd314f | ||
|
|
29c95be27b | ||
|
|
b37cb2b5c9 | ||
|
|
a95d875564 | ||
|
|
41e65bf3b0 | ||
|
|
fbac9498fd | ||
|
|
a155999bbb | ||
|
|
9978e1efcc | ||
|
|
6fbcc1a451 | ||
|
|
3fd89291e7 | ||
|
|
e4588ec8b6 | ||
|
|
82bd313d21 | ||
|
|
ada6ede429 | ||
|
|
549bdf0e93 | ||
|
|
b8ed494c41 | ||
|
|
0f843fa0f2 | ||
|
|
01859205f5 | ||
|
|
5e4ce9f21b | ||
|
|
a9f30cdfaa | ||
|
|
09c0d1bbe8 | ||
|
|
13908d0d3a | ||
|
|
43f852b618 | ||
|
|
0df1694dad | ||
|
|
4d0c9698d6 | ||
|
|
0ed49f947c | ||
|
|
aa788e9fe2 | ||
|
|
0e1f145c45 | ||
|
|
b926a627f2 | ||
|
|
e017ddf762 | ||
|
|
6eee8d8c07 | ||
|
|
0b62d8371f | ||
|
|
60a375960f | ||
|
|
8e1162a1c9 | ||
|
|
0d4d57c51f | ||
|
|
ceb0526f0f | ||
|
|
cc2bedff41 | ||
|
|
1cfc8983a9 | ||
|
|
90dd3f415d | ||
|
|
281fc7f5a1 | ||
|
|
d20162c5b2 | ||
|
|
3318880afd | ||
|
|
0a06a02bf6 | ||
|
|
ffe059a4d5 | ||
|
|
59ed05dd53 | ||
|
|
19eee0d36f | ||
|
|
9967e39dc8 | ||
|
|
6a4bd75b33 | ||
|
|
61a680b7e3 | ||
|
|
0f248ca178 | ||
|
|
00782038d3 | ||
|
|
3c1a7da11a | ||
|
|
9833cc592d | ||
|
|
54e8ff474f | ||
|
|
efc61680c9 | ||
|
|
8a72453cc2 | ||
|
|
07c5a1e336 | ||
|
|
d16c1a3746 | ||
|
|
81745fbf70 | ||
|
|
6c3be5221b | ||
|
|
1e6cb75422 | ||
|
|
b4bc8c129f | ||
|
|
86e656a89b | ||
|
|
c7c15fa484 | ||
|
|
1e60212644 | ||
|
|
33607ff982 | ||
|
|
bb34d24e1b | ||
|
|
94e67c5955 | ||
|
|
7606484317 | ||
|
|
e57212437d | ||
|
|
c9b68ba61e | ||
|
|
bd9abe29b9 | ||
|
|
6223dcc024 | ||
|
|
3de733a528 | ||
|
|
eccaedf219 | ||
|
|
a4e465c428 | ||
|
|
b96f5d6d59 | ||
|
|
28d1701904 | ||
|
|
4cb6b87134 | ||
|
|
e3f7181558 | ||
|
|
456b44681c | ||
|
|
e60accf724 | ||
|
|
66db854ebc | ||
|
|
2d02493b24 | ||
|
|
e3c7d14001 | ||
|
|
966d381740 | ||
|
|
1eff01496b | ||
|
|
bf83c4bc63 | ||
|
|
aec4a12af8 | ||
|
|
49ef373cbe | ||
|
|
9a40e1945e | ||
|
|
99e34832a0 | ||
|
|
e1ebf245b2 | ||
|
|
10688606ca | ||
|
|
f36aad8d6d | ||
|
|
f543fe930a | ||
|
|
62be464ebe | ||
|
|
3b245b421f | ||
|
|
8f45d86315 | ||
|
|
629831cdd8 | ||
|
|
3ac21c22ec | ||
|
|
60362f5792 | ||
|
|
078e4f380c | ||
|
|
2457d81061 | ||
|
|
dec35f9eea | ||
|
|
6840f0a583 | ||
|
|
759bbe90b0 | ||
|
|
3a7e49f176 | ||
|
|
ca661b8649 | ||
|
|
430f7efe5c | ||
|
|
d06c66f703 | ||
|
|
0b2a22c5c9 | ||
|
|
33deab99b2 | ||
|
|
5696db0023 | ||
|
|
394e0ba201 | ||
|
|
d8b7230512 | ||
|
|
20b9fa8dc7 | ||
|
|
c5878de5d2 | ||
|
|
85936a643b | ||
|
|
4d50ab2fab | ||
|
|
163168d561 | ||
|
|
afab8bc2c9 | ||
|
|
492890b2d8 | ||
|
|
e907e3d610 | ||
|
|
2db314509f | ||
|
|
46314b16c0 | ||
|
|
ef49a5566d | ||
|
|
fbdd770d69 | ||
|
|
d183cf6ec1 | ||
|
|
d257f5b8a3 | ||
|
|
b73439ae90 | ||
|
|
9c1313171c | ||
|
|
8b379a3653 | ||
|
|
53fe654340 | ||
|
|
1c3f0e1efb | ||
|
|
37cc8994ad | ||
|
|
2dc6119e98 | ||
|
|
56464e0f5b | ||
|
|
a7a0ee9ce8 | ||
|
|
c8538e155c | ||
|
|
37db77cbb2 | ||
|
|
e2f4667818 | ||
|
|
2ca5cb048b | ||
|
|
6426016c2e | ||
|
|
d08d080937 | ||
|
|
8c7beccdc8 | ||
|
|
0584111357 | ||
|
|
87174f80c5 | ||
|
|
bd01908b52 | ||
|
|
af8666bd42 | ||
|
|
4f5faa5d39 | ||
|
|
2831def53a | ||
|
|
a0baf3ad39 | ||
|
|
16e002ccb9 | ||
|
|
bf0dea6ee3 | ||
|
|
602c060a0a | ||
|
|
2c0d1d5658 | ||
|
|
f8d1f9dc91 | ||
|
|
7887a695f7 | ||
|
|
654a76c5db | ||
|
|
a339d8fc75 | ||
|
|
482789ca41 | ||
|
|
28a66fba92 | ||
|
|
8903759335 | ||
|
|
ecade68740 | ||
|
|
64b77477fb | ||
|
|
1e763882c6 | ||
|
|
ddf6d2470b | ||
|
|
e538b45d5b | ||
|
|
b922ab2556 | ||
|
|
9207453164 | ||
|
|
5d17cbccfb | ||
|
|
4d46f29404 | ||
|
|
dd598ccd50 | ||
|
|
6049658ad9 | ||
|
|
96791a7611 | ||
|
|
7bffe6dbf7 | ||
|
|
7eff3f74be | ||
|
|
cc44f47a3f | ||
|
|
c19617244e | ||
|
|
18381bb2fe | ||
|
|
1dcf78621b | ||
|
|
a38906d91e | ||
|
|
603a50931b | ||
|
|
d5ddccc318 | ||
|
|
615d221c0c | ||
|
|
5227def0d8 | ||
|
|
745a41b811 | ||
|
|
4f8bafc6dd | ||
|
|
d56bc5d21a | ||
|
|
3a26392bd0 | ||
|
|
8a23f5438b | ||
|
|
1d837c0bf0 | ||
|
|
d8739a71a5 | ||
|
|
9821984630 | ||
|
|
63f1fb6bf9 | ||
|
|
7a26ae7ac9 | ||
|
|
b4a057b5f7 | ||
|
|
7e2438c44f | ||
|
|
48a80e8e76 | ||
|
|
490ef0ae0a | ||
|
|
5f99fba396 | ||
|
|
84f364de74 | ||
|
|
39c955cdc4 | ||
|
|
e02ca54187 | ||
|
|
ac46ee004b | ||
|
|
17a6eb260d | ||
|
|
5ea80366be | ||
|
|
99aa0cb980 | ||
|
|
3de53a76d0 | ||
|
|
7fa9b6aff8 | ||
|
|
19b86d9f0e | ||
|
|
62c617a8db | ||
|
|
7616c5e7f4 | ||
|
|
0406d13b92 | ||
|
|
d0ad85c943 | ||
|
|
8f41e08bc6 | ||
|
|
2c82099bdd | ||
|
|
dd967ff223 | ||
|
|
569e84562e | ||
|
|
a51398a336 | ||
|
|
6e97798f5b | ||
|
|
25181ec31b | ||
|
|
e8a1a62898 |
132
.claude/DEPARTMENTS.md
Normal file
132
.claude/DEPARTMENTS.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Department & Permission System
|
||||
|
||||
## Department Structure
|
||||
|
||||
### Curagreen Departments
|
||||
- **CRG-SOLV** - Solventless (ice water extraction, pressing, rosin)
|
||||
- **CRG-BHO** - BHO/Hydrocarbon (butane/propane extraction, distillation)
|
||||
- **CRG-CULT** - Cultivation (growing operations)
|
||||
- **CRG-DELV** - Delivery (fleet management, drivers)
|
||||
|
||||
### Lazarus Departments
|
||||
- **LAZ-SOLV** - Solventless (ice water extraction, pressing, rosin)
|
||||
- **LAZ-PKG** - Packaging (finished product packaging)
|
||||
- **LAZ-MFG** - Manufacturing (product assembly)
|
||||
|
||||
### CBD Sales Departments
|
||||
- **CBD-SALES** - Sales & Ecommerce
|
||||
- **CBD-MKTG** - Marketing
|
||||
|
||||
## How Departments Work
|
||||
|
||||
### Assignment Model
|
||||
```php
|
||||
// Users are assigned to departments via pivot table: department_user
|
||||
// Pivot columns: user_id, department_id, role (member|manager)
|
||||
|
||||
// Users can be in MULTIPLE departments across MULTIPLE divisions
|
||||
// Example: User can be in CRG-SOLV and LAZ-SOLV simultaneously
|
||||
|
||||
$user->departments // Returns all departments across all businesses
|
||||
$user->departments()->wherePivot('role', 'manager') // Just manager roles
|
||||
```
|
||||
|
||||
### Access Control Levels
|
||||
|
||||
**1. Business Owner** (`$business->owner_user_id === $user->id`)
|
||||
- Full access to everything in their business
|
||||
- Can manage all departments
|
||||
- Can see all reports and settings
|
||||
- Only ones who see business onboarding banners
|
||||
|
||||
**2. Super Admin** (`$user->hasRole('super-admin')`)
|
||||
- Access to everything across all businesses
|
||||
- Access to Filament admin panel
|
||||
- Bypass all permission checks
|
||||
|
||||
**3. Department Managers** (pivot role = 'manager')
|
||||
- Manage their department's operations
|
||||
- Assign work within department
|
||||
- View department reports
|
||||
|
||||
**4. Department Members** (pivot role = 'member')
|
||||
- Access their department's work orders
|
||||
- View only their department's conversions/data
|
||||
- Cannot manage other users
|
||||
|
||||
### Department-Based Data Filtering
|
||||
|
||||
**Critical Pattern:**
|
||||
```php
|
||||
// ALWAYS filter by user's departments for conversion data
|
||||
$userDepartments = auth()->user()->departments->pluck('id');
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->whereIn('department_id', $userDepartments)
|
||||
->get();
|
||||
|
||||
// Or use the scope
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->forUserDepartments(auth()->user())
|
||||
->get();
|
||||
```
|
||||
|
||||
## Menu Visibility Logic
|
||||
|
||||
```php
|
||||
// Ecommerce Section
|
||||
$isSalesDept = $userDepartments->whereIn('code', ['CBD-SALES', 'CBD-MKTG'])->isNotEmpty();
|
||||
$hasEcommerceAccess = $isSalesDept || $isBusinessOwner || $isSuperAdmin;
|
||||
|
||||
// Processing Section
|
||||
$hasSolventless = $userDepartments->whereIn('code', ['CRG-SOLV', 'LAZ-SOLV'])->isNotEmpty();
|
||||
$hasBHO = $userDepartments->where('code', 'CRG-BHO')->isNotEmpty();
|
||||
$hasAnyProcessing = $hasSolventless || $hasBHO || $hasCultivation || ...;
|
||||
|
||||
// Fleet Section
|
||||
$hasDeliveryAccess = $userDepartments->where('code', 'CRG-DELV')->isNotEmpty();
|
||||
$canManageFleet = $hasDeliveryAccess || $isBusinessOwner;
|
||||
```
|
||||
|
||||
## Nested Menu Structure
|
||||
|
||||
Processing menu is nested by department:
|
||||
```
|
||||
Processing
|
||||
├─ My Work Orders (all departments)
|
||||
├─ Solventless (if user has CRG-SOLV or LAZ-SOLV)
|
||||
│ ├─ Idle Fresh Frozen
|
||||
│ ├─ Conversion history
|
||||
│ ├─ Washing
|
||||
│ ├─ Pressing
|
||||
│ ├─ Yield percentages
|
||||
│ └─ Waste tracking
|
||||
└─ BHO (if user has CRG-BHO)
|
||||
├─ Conversion history
|
||||
├─ Extraction
|
||||
├─ Distillation
|
||||
├─ Yield percentages
|
||||
└─ Waste tracking
|
||||
```
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Data Isolation**: Users ONLY see data for their assigned departments
|
||||
2. **Cross-Division**: Users can be in departments across multiple subdivisions
|
||||
3. **Business Scoping**: Department filtering happens AFTER business_id check
|
||||
4. **Owner Override**: Business owners see everything in their business
|
||||
5. **Explicit Assignment**: Department access must be explicitly granted via pivot table
|
||||
|
||||
## Common Checks
|
||||
|
||||
```php
|
||||
// Check if user has access to department
|
||||
if (!$user->departments->contains($departmentId)) {
|
||||
abort(403, 'Not assigned to this department');
|
||||
}
|
||||
|
||||
// Check if business owner
|
||||
$isOwner = $business->owner_user_id === $user->id;
|
||||
|
||||
// Check if can manage business settings
|
||||
$canManageBusiness = $isOwner || $user->hasRole('super-admin');
|
||||
```
|
||||
274
.claude/MODELS.md
Normal file
274
.claude/MODELS.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Key Models & Relationships
|
||||
|
||||
## Business Hierarchy
|
||||
|
||||
```php
|
||||
Business (Parent Company)
|
||||
├─ id, name, slug, parent_business_id, owner_user_id
|
||||
├─ hasMany: departments
|
||||
├─ hasMany: divisions (where parent_business_id = this id)
|
||||
└─ belongsTo: parentBusiness (nullable)
|
||||
|
||||
// Methods:
|
||||
$business->isParentCompany() // true if parent_business_id is null
|
||||
```
|
||||
|
||||
## Departments
|
||||
|
||||
```php
|
||||
Department
|
||||
├─ id, name, code (e.g., 'CRG-SOLV'), business_id
|
||||
├─ belongsTo: business
|
||||
└─ belongsToMany: users (pivot: department_user with 'role' column)
|
||||
|
||||
// Pivot relationship:
|
||||
department_user
|
||||
├─ user_id
|
||||
├─ department_id
|
||||
├─ role ('member' | 'manager')
|
||||
|
||||
// Usage:
|
||||
$user->departments // All departments across all businesses
|
||||
$user->departments()->wherePivot('role', 'manager')->get()
|
||||
$department->users
|
||||
```
|
||||
|
||||
## Users
|
||||
|
||||
```php
|
||||
User
|
||||
├─ id, name, email, user_type ('buyer'|'seller'|'admin')
|
||||
├─ belongsToMany: departments (pivot: department_user)
|
||||
├─ hasMany: businesses (where owner_user_id = this user)
|
||||
└─ hasRoles via Spatie permissions
|
||||
|
||||
// Key checks:
|
||||
$user->hasRole('super-admin')
|
||||
$user->departments->contains($departmentId)
|
||||
$user->departments->pluck('id')
|
||||
```
|
||||
|
||||
## Conversions
|
||||
|
||||
```php
|
||||
Conversion
|
||||
├─ id, business_id, department_id
|
||||
├─ conversion_type ('washing', 'pressing', 'extraction', etc.)
|
||||
├─ internal_name (unique identifier)
|
||||
├─ operator_user_id (who performed it)
|
||||
├─ started_at, completed_at
|
||||
├─ status ('pending'|'in_progress'|'completed'|'cancelled')
|
||||
├─ input_weight, input_unit
|
||||
├─ actual_output_quantity, actual_output_unit
|
||||
├─ yield_percentage (auto-calculated)
|
||||
├─ metadata (JSON - stores stages, waste_weight, etc.)
|
||||
├─ belongsTo: business
|
||||
├─ belongsTo: department
|
||||
├─ belongsTo: operator (User)
|
||||
├─ belongsToMany: inputBatches (pivot: conversion_inputs)
|
||||
└─ hasOne: batchCreated
|
||||
|
||||
// Scopes:
|
||||
Conversion::forBusiness($businessId)
|
||||
Conversion::ofType('washing')
|
||||
Conversion::forUserDepartments($user) // Filters by user's dept assignments
|
||||
```
|
||||
|
||||
## Batches
|
||||
|
||||
```php
|
||||
Batch
|
||||
├─ id, business_id, component_id, conversion_id
|
||||
├─ quantity, unit
|
||||
├─ batch_number (metrc tracking)
|
||||
├─ belongsTo: business
|
||||
├─ belongsTo: component
|
||||
├─ belongsTo: conversion (nullable - which conversion created this)
|
||||
└─ belongsToMany: conversions (as inputs via conversion_inputs)
|
||||
|
||||
// Flow:
|
||||
// Input batches → Conversion → Output batch
|
||||
$conversion->inputBatches() // Used in conversion
|
||||
$conversion->batchCreated() // Created by conversion
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
```php
|
||||
Component
|
||||
├─ id, business_id, name, type
|
||||
├─ belongsTo: business
|
||||
└─ hasMany: batches
|
||||
|
||||
// Examples:
|
||||
- Fresh Frozen (type: raw_material)
|
||||
- Hash 6star (type: intermediate)
|
||||
- Rosin (type: finished_good)
|
||||
|
||||
// CRITICAL: Always scope by business
|
||||
Component::where('business_id', $business->id)->findOrFail($id)
|
||||
```
|
||||
|
||||
## Products
|
||||
|
||||
```php
|
||||
Product
|
||||
├─ id, brand_id, name, sku
|
||||
├─ belongsTo: brand
|
||||
├─ belongsTo: business (through brand)
|
||||
└─ hasMany: orderItems
|
||||
|
||||
// Products are sold to buyers
|
||||
// Different from Components (internal inventory)
|
||||
```
|
||||
|
||||
## Orders
|
||||
|
||||
```php
|
||||
Order
|
||||
├─ id, business_id (buyer's business)
|
||||
├─ belongsTo: business (buyer)
|
||||
├─ hasMany: items
|
||||
└─ Each item->product->brand->business is the seller
|
||||
|
||||
// Cross-business relationship:
|
||||
// Buyer's order → contains items → from seller's products
|
||||
```
|
||||
|
||||
## Work Orders
|
||||
|
||||
```php
|
||||
WorkOrder
|
||||
├─ id, business_id, department_id
|
||||
├─ assigned_to (user_id)
|
||||
├─ related_conversion_id (nullable)
|
||||
├─ belongsTo: business
|
||||
├─ belongsTo: department
|
||||
├─ belongsTo: assignedUser
|
||||
└─ belongsTo: conversion (nullable)
|
||||
|
||||
// Work orders can trigger conversions
|
||||
// Users see work orders for their departments only
|
||||
```
|
||||
|
||||
## Critical Relationships
|
||||
|
||||
### User → Business → Department Flow
|
||||
```php
|
||||
// User can access multiple businesses via department assignments
|
||||
$user->departments->pluck('business_id')->unique()
|
||||
|
||||
// User can be in multiple departments across multiple businesses
|
||||
$user->departments()->where('business_id', $business->id)->get()
|
||||
```
|
||||
|
||||
### Conversion → Batch Flow
|
||||
```php
|
||||
// Creating a conversion:
|
||||
1. Select input batches (existing inventory)
|
||||
2. Perform conversion
|
||||
3. Create output batch(es)
|
||||
4. Link everything together
|
||||
|
||||
$conversion->inputBatches()->attach($batchId, [
|
||||
'quantity_used' => 500,
|
||||
'unit' => 'g',
|
||||
'role' => 'primary_input',
|
||||
]);
|
||||
|
||||
$outputBatch = Batch::create([
|
||||
'business_id' => $business->id,
|
||||
'component_id' => $outputComponentId,
|
||||
'conversion_id' => $conversion->id,
|
||||
'quantity' => 75,
|
||||
'unit' => 'g',
|
||||
]);
|
||||
```
|
||||
|
||||
### Department-Based Access Pattern
|
||||
```php
|
||||
// Always check three things for data access:
|
||||
1. Business scoping: where('business_id', $business->id)
|
||||
2. Department scoping: whereIn('department_id', $user->departments->pluck('id'))
|
||||
3. User authorization: $user->departments->contains($departmentId)
|
||||
|
||||
// Example:
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->whereIn('department_id', $user->departments->pluck('id'))
|
||||
->get();
|
||||
|
||||
// Or using scope:
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->forUserDepartments($user)
|
||||
->get();
|
||||
```
|
||||
|
||||
## Model Scopes Reference
|
||||
|
||||
```php
|
||||
// Business
|
||||
Business::isParentCompany()
|
||||
|
||||
// Conversion
|
||||
Conversion::forBusiness($businessId)
|
||||
Conversion::ofType($type)
|
||||
Conversion::forUserDepartments($user)
|
||||
|
||||
// Always combine scopes:
|
||||
Conversion::where('business_id', $business->id)
|
||||
->forUserDepartments($user)
|
||||
->ofType('washing')
|
||||
->get();
|
||||
```
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
```php
|
||||
// Get all conversions for user's departments
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->forUserDepartments(auth()->user())
|
||||
->with(['department', 'operator', 'inputBatches', 'batchCreated'])
|
||||
->latest('started_at')
|
||||
->paginate(25);
|
||||
|
||||
// Get user's departments in current business
|
||||
$userDepartments = auth()->user()
|
||||
->departments()
|
||||
->where('business_id', $business->id)
|
||||
->get();
|
||||
|
||||
// Check if user can access department
|
||||
$canAccess = auth()->user()
|
||||
->departments
|
||||
->contains($departmentId);
|
||||
|
||||
// Get all departments user manages
|
||||
$managedDepts = auth()->user()
|
||||
->departments()
|
||||
->wherePivot('role', 'manager')
|
||||
->get();
|
||||
```
|
||||
|
||||
## Validation Patterns
|
||||
|
||||
```php
|
||||
// When creating conversion, validate user has dept access
|
||||
$validated = $request->validate([
|
||||
'department_id' => 'required|exists:departments,id',
|
||||
// ...
|
||||
]);
|
||||
|
||||
// Security check
|
||||
if (!auth()->user()->departments->contains($validated['department_id'])) {
|
||||
abort(403, 'You are not assigned to this department.');
|
||||
}
|
||||
|
||||
// Ensure conversion belongs to business AND user's departments
|
||||
if ($conversion->business_id !== $business->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
if (!auth()->user()->departments->contains($conversion->department_id)) {
|
||||
abort(403, 'Not authorized to view this conversion.');
|
||||
}
|
||||
```
|
||||
251
.claude/PROCESSING.md
Normal file
251
.claude/PROCESSING.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Processing Operations Architecture
|
||||
|
||||
## Processing vs Manufacturing
|
||||
|
||||
**Processing** = Biomass transformation (fresh frozen → hash → rosin)
|
||||
- Ice water extraction (washing)
|
||||
- Pressing (hash → rosin)
|
||||
- BHO extraction
|
||||
- Distillation
|
||||
- Winterization
|
||||
|
||||
**Manufacturing** = Component assembly into finished products
|
||||
- Packaging
|
||||
- Product creation from components
|
||||
- BOMs (Bill of Materials)
|
||||
|
||||
## Department Types
|
||||
|
||||
### Solventless Departments (CRG-SOLV, LAZ-SOLV)
|
||||
**Processes:**
|
||||
- Ice water extraction (washing) - Fresh Frozen → Hash grades
|
||||
- Pressing - Hash → Rosin
|
||||
- Dry sifting - Flower → Kief
|
||||
- Flower rosin - Flower → Rosin
|
||||
|
||||
**Materials:**
|
||||
- Input: Fresh Frozen, Cured Flower, Trim
|
||||
- Output: Hash (6star, 5star, 4star, 3star, 73-159u, 160-219u), Rosin, Kief
|
||||
|
||||
**No solvents used** - purely mechanical/physical separation
|
||||
|
||||
### BHO Departments (CRG-BHO)
|
||||
**Processes:**
|
||||
- Closed-loop extraction - Biomass → Crude/BHO
|
||||
- Distillation - Crude → Distillate
|
||||
- Winterization - Extract → Refined
|
||||
- Filtration
|
||||
|
||||
**Materials:**
|
||||
- Input: Biomass (fresh or cured), Crude
|
||||
- Output: BHO, Crude, Distillate, Wax, Shatter, Live Resin
|
||||
|
||||
**Uses chemical solvents** - butane, propane, CO2, ethanol
|
||||
|
||||
## Conversions System
|
||||
|
||||
**Core Model: `Conversion`**
|
||||
```php
|
||||
$conversion = Conversion::create([
|
||||
'business_id' => $business->id,
|
||||
'department_id' => $department->id, // REQUIRED
|
||||
'conversion_type' => 'washing', // washing, pressing, extraction, distillation, etc.
|
||||
'internal_name' => 'Blue Dream Hash Wash #20251114-143000',
|
||||
'started_at' => now(),
|
||||
'status' => 'in_progress',
|
||||
'input_weight' => 1000.00, // grams
|
||||
'output_weight' => 150.00, // grams (calculated from batches)
|
||||
'yield_percentage' => 15.00, // auto-calculated
|
||||
'metadata' => [
|
||||
'waste_weight' => 50.00,
|
||||
'stages' => [
|
||||
['stage_type' => 'extraction', 'temperature' => 32, ...],
|
||||
],
|
||||
],
|
||||
]);
|
||||
```
|
||||
|
||||
**Relationships:**
|
||||
- `inputBatches()` - Many-to-many via `conversion_inputs` pivot
|
||||
- `batchCreated()` - Has one output batch
|
||||
- `department()` - Belongs to department
|
||||
- `operator()` - Belongs to user (who performed it)
|
||||
|
||||
## Wash Batches vs Conversions
|
||||
|
||||
**Current State:** Two systems doing the same thing
|
||||
|
||||
**Wash System** (existing):
|
||||
- Fresh Frozen → Multiple hash grades
|
||||
- Stage 1 and Stage 2 tracking
|
||||
- Multiple outputs with quality grades
|
||||
- Very specialized UI
|
||||
|
||||
**Conversions System** (new):
|
||||
- Generic input → output tracking
|
||||
- Works for ALL conversion types
|
||||
- Department-scoped
|
||||
- Yield and waste analytics
|
||||
|
||||
**Integration Plan:**
|
||||
- Wash batches ARE conversions with `conversion_type = 'washing'`
|
||||
- Keep specialized wash UI
|
||||
- When wash batch created, also creates conversion record
|
||||
- Wash batches show up in conversion history
|
||||
- Conversions dashboard shows wash yields alongside other yields
|
||||
|
||||
## Data Filtering Rules
|
||||
|
||||
**CRITICAL:** Conversions MUST be filtered by department
|
||||
|
||||
```php
|
||||
// Get conversions for user's departments
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->forUserDepartments(auth()->user())
|
||||
->get();
|
||||
|
||||
// Vinny (CRG-SOLV) only sees solventless conversions
|
||||
// BHO tech only sees BHO conversions
|
||||
```
|
||||
|
||||
**In Controllers:**
|
||||
```php
|
||||
public function index(Business $business)
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->forUserDepartments($user)
|
||||
->with(['department', 'operator', 'inputBatches', 'batchCreated'])
|
||||
->orderBy('started_at', 'desc')
|
||||
->paginate(25);
|
||||
|
||||
return view('seller.processing.conversions.index', compact('business', 'conversions'));
|
||||
}
|
||||
```
|
||||
|
||||
## Conversion Types by Department
|
||||
|
||||
**Solventless:**
|
||||
- `washing` - Fresh Frozen → Hash
|
||||
- `pressing` - Hash → Rosin
|
||||
- `sifting` - Flower → Kief
|
||||
- `flower_rosin` - Flower → Rosin
|
||||
|
||||
**BHO:**
|
||||
- `extraction` - Biomass → Crude/BHO
|
||||
- `distillation` - Crude → Distillate
|
||||
- `winterization` - Extract → Refined
|
||||
- `filtration` - Various refinement
|
||||
|
||||
**Shared:**
|
||||
- `other` - Custom processes
|
||||
|
||||
## Yield Tracking
|
||||
|
||||
**Yield Percentage:**
|
||||
```php
|
||||
$yield = ($output_weight / $input_weight) * 100;
|
||||
```
|
||||
|
||||
**Performance Thresholds:**
|
||||
- Excellent: ≥80%
|
||||
- Good: 60-79%
|
||||
- Below Average: <60%
|
||||
|
||||
**Waste Tracking:**
|
||||
```php
|
||||
$waste = $input_weight - $output_weight;
|
||||
$wastePercent = ($waste / $input_weight) * 100;
|
||||
```
|
||||
|
||||
**Stored in metadata:**
|
||||
```php
|
||||
$conversion->metadata = [
|
||||
'waste_weight' => 50.00,
|
||||
// Other metadata...
|
||||
];
|
||||
```
|
||||
|
||||
## Stage Tracking
|
||||
|
||||
Conversions can have multiple processing stages stored in metadata:
|
||||
|
||||
```php
|
||||
$conversion->metadata = [
|
||||
'stages' => [
|
||||
[
|
||||
'stage_name' => 'Initial Extraction',
|
||||
'stage_type' => 'extraction',
|
||||
'start_time' => '2025-11-14 10:00:00',
|
||||
'end_time' => '2025-11-14 12:00:00',
|
||||
'temperature' => 32,
|
||||
'pressure' => 1500,
|
||||
'notes' => 'Used 5 micron bags',
|
||||
],
|
||||
[
|
||||
'stage_name' => 'Refinement',
|
||||
'stage_type' => 'filtration',
|
||||
// ...
|
||||
],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
## Components vs Batches
|
||||
|
||||
**Components:** Inventory types (Fresh Frozen, Hash 6star, Rosin, etc.)
|
||||
- Master data
|
||||
- Defines what can be used in conversions
|
||||
|
||||
**Batches:** Actual inventory units
|
||||
- Specific quantities
|
||||
- Traceability
|
||||
- Created from conversions
|
||||
|
||||
**Example Flow:**
|
||||
```
|
||||
1. Start with Fresh Frozen batch (500g)
|
||||
2. Create conversion (washing)
|
||||
3. Conversion creates new batches:
|
||||
- Hash 6star batch (50g)
|
||||
- Hash 5star batch (75g)
|
||||
- Hash 4star batch (100g)
|
||||
4. Each batch linked back to conversion
|
||||
```
|
||||
|
||||
## Menu Structure
|
||||
|
||||
**Nested by department in sidebar:**
|
||||
|
||||
```
|
||||
Processing
|
||||
├─ My Work Orders (all departments)
|
||||
├─ Solventless (if has CRG-SOLV or LAZ-SOLV)
|
||||
│ ├─ Idle Fresh Frozen
|
||||
│ ├─ Conversion history
|
||||
│ ├─ Washing
|
||||
│ ├─ Pressing
|
||||
│ ├─ Yield percentages
|
||||
│ └─ Waste tracking
|
||||
└─ BHO (if has CRG-BHO)
|
||||
├─ Conversion history
|
||||
├─ Extraction
|
||||
├─ Distillation
|
||||
├─ Yield percentages
|
||||
└─ Waste tracking
|
||||
```
|
||||
|
||||
## Key Controllers
|
||||
|
||||
- `ConversionController` - CRUD for conversions, yields, waste dashboards
|
||||
- `WashReportController` - Specialized wash batch interface
|
||||
- `WorkOrderController` - Work order management (separate from conversions)
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Department is required** - Every conversion must have a department_id
|
||||
2. **User must be assigned** - Can only create conversions for departments they're in
|
||||
3. **Business scoping** - Always filter by business_id first
|
||||
4. **Department filtering** - Then filter by user's departments
|
||||
5. **Yield auto-calculation** - Happens on save, based on input/output weights
|
||||
177
.claude/ROUTING.md
Normal file
177
.claude/ROUTING.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Routing & Business Architecture
|
||||
|
||||
## Business Slug Routing Pattern
|
||||
|
||||
**All seller routes use business slug:**
|
||||
```
|
||||
/s/{business_slug}/dashboard
|
||||
/s/{business_slug}/orders
|
||||
/s/{business_slug}/processing/conversions
|
||||
/s/{business_slug}/settings/users
|
||||
```
|
||||
|
||||
**Route Binding:**
|
||||
```php
|
||||
Route::prefix('s/{business}')->name('seller.business.')->group(function () {
|
||||
// Laravel automatically resolves {business} to Business model via slug column
|
||||
Route::get('dashboard', [DashboardController::class, 'show'])->name('dashboard');
|
||||
});
|
||||
```
|
||||
|
||||
**In Controllers:**
|
||||
```php
|
||||
public function show(Business $business)
|
||||
{
|
||||
// $business is already resolved from slug
|
||||
// User authorization already checked by middleware
|
||||
|
||||
// CRITICAL: Always scope queries by this business
|
||||
$orders = Order::where('business_id', $business->id)->get();
|
||||
}
|
||||
```
|
||||
|
||||
## Business Hierarchy
|
||||
|
||||
### Parent Companies
|
||||
```
|
||||
Creationshop (parent_business_id = null)
|
||||
├─ Curagreen (parent_business_id = creationshop_id)
|
||||
├─ Lazarus (parent_business_id = creationshop_id)
|
||||
└─ CBD Supply Co (parent_business_id = creationshop_id)
|
||||
```
|
||||
|
||||
**Check if parent:**
|
||||
```php
|
||||
$business->isParentCompany() // Returns true if parent_business_id is null
|
||||
```
|
||||
|
||||
**Parent companies see:**
|
||||
- Executive Dashboard (cross-division)
|
||||
- Manage Divisions menu item
|
||||
- Aggregate analytics
|
||||
|
||||
### Subdivisions (Divisions)
|
||||
|
||||
Each subdivision has its own:
|
||||
- Business record with unique slug
|
||||
- Set of departments
|
||||
- Users (can be shared across divisions)
|
||||
- Products, inventory, components
|
||||
|
||||
**Key Principle:** Users can work in MULTIPLE subdivisions by being assigned to departments in each.
|
||||
|
||||
Example:
|
||||
- User Vinny is in CRG-SOLV (Curagreen Solventless)
|
||||
- Same user could also be in LAZ-SOLV (Lazarus Solventless)
|
||||
- When Vinny views `/s/curagreen/processing/conversions`, he sees only Curagreen's solventless conversions
|
||||
- When Vinny views `/s/lazarus/processing/conversions`, he sees only Lazarus's solventless conversions
|
||||
|
||||
## Cross-Division Security
|
||||
|
||||
**CRITICAL: Business slug in URL determines scope**
|
||||
|
||||
```php
|
||||
// URL: /s/curagreen/processing/conversions/123
|
||||
|
||||
// Route binding resolves to Curagreen business
|
||||
public function show(Business $business, Conversion $conversion)
|
||||
{
|
||||
// MUST verify conversion belongs to THIS business
|
||||
if ($conversion->business_id !== $business->id) {
|
||||
abort(404, 'Conversion not found.');
|
||||
}
|
||||
|
||||
// Also verify user has department access
|
||||
if (!auth()->user()->departments->contains($conversion->department_id)) {
|
||||
abort(403, 'Not authorized.');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:** Prevents URL manipulation attacks:
|
||||
- User tries to access `/s/curagreen/processing/conversions/456`
|
||||
- Conversion 456 belongs to Lazarus
|
||||
- Check fails, returns 404
|
||||
- User cannot enumerate other division's data
|
||||
|
||||
## Route Middleware Patterns
|
||||
|
||||
```php
|
||||
// All seller routes need authentication + seller type
|
||||
Route::middleware(['auth', 'verified', 'seller', 'approved'])->group(function () {
|
||||
// Business-scoped routes
|
||||
Route::prefix('s/{business}')->group(function () {
|
||||
// Routes here...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Department-Scoped Routes
|
||||
|
||||
**Pattern for department-filtered data:**
|
||||
```php
|
||||
Route::get('processing/conversions', function (Business $business) {
|
||||
$user = auth()->user();
|
||||
|
||||
// Get conversions for THIS business AND user's departments
|
||||
$conversions = Conversion::where('business_id', $business->id)
|
||||
->forUserDepartments($user)
|
||||
->get();
|
||||
|
||||
return view('seller.processing.conversions.index', compact('business', 'conversions'));
|
||||
});
|
||||
```
|
||||
|
||||
## Common Route Parameters
|
||||
|
||||
- `{business}` - Business model (resolved via slug)
|
||||
- `{conversion}` - Conversion model (resolved via ID)
|
||||
- `{order}` - Order model
|
||||
- `{product}` - Product model
|
||||
- `{component}` - Component model
|
||||
|
||||
**Always verify related model belongs to business:**
|
||||
```php
|
||||
// WRONG - security hole
|
||||
$component = Component::findOrFail($id);
|
||||
|
||||
// RIGHT - scoped to business first
|
||||
$component = Component::where('business_id', $business->id)
|
||||
->findOrFail($id);
|
||||
```
|
||||
|
||||
## URL Structure Examples
|
||||
|
||||
```
|
||||
# Dashboard
|
||||
/s/curagreen/dashboard
|
||||
/s/lazarus/dashboard
|
||||
|
||||
# Processing (department-filtered)
|
||||
/s/curagreen/processing/conversions
|
||||
/s/curagreen/processing/conversions/create
|
||||
/s/curagreen/processing/conversions/yields
|
||||
/s/curagreen/processing/idle-fresh-frozen
|
||||
|
||||
# Settings (owner-only)
|
||||
/s/curagreen/settings/company-information
|
||||
/s/curagreen/settings/users
|
||||
/s/curagreen/settings/notifications
|
||||
|
||||
# Ecommerce (sales dept or owner)
|
||||
/s/curagreen/orders
|
||||
/s/curagreen/products
|
||||
/s/curagreen/customers
|
||||
|
||||
# Parent company features
|
||||
/s/creationshop/executive/dashboard
|
||||
/s/creationshop/corporate/divisions
|
||||
```
|
||||
|
||||
## Business Switcher
|
||||
|
||||
Users can switch between businesses they have access to:
|
||||
- Component: `<x-brand-switcher />`
|
||||
- Shows in sidebar
|
||||
- Changes URL slug to switch context
|
||||
- User must have department assignment in target business
|
||||
21
.claude/commands/end-day.md
Normal file
21
.claude/commands/end-day.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
description: End the coding session by updating the session tracker and preparing for next time
|
||||
---
|
||||
|
||||
# Ending Session
|
||||
|
||||
Please do the following:
|
||||
|
||||
1. Check for uncommitted changes with `git status`
|
||||
2. If there are uncommitted changes, ask me if I want to commit them
|
||||
3. Update the `SESSION_ACTIVE` file with:
|
||||
- What was completed today
|
||||
- What task we're currently on (if unfinished)
|
||||
- What to do next session
|
||||
- Any important context or decisions made
|
||||
4. Note any known issues or blockers
|
||||
5. If I want to commit:
|
||||
- Stage the SESSION_ACTIVE file
|
||||
- Create a descriptive commit message
|
||||
- Commit the changes
|
||||
6. Give me a summary of what we accomplished today
|
||||
16
.claude/commands/start-day.md
Normal file
16
.claude/commands/start-day.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: Start a new coding session by reading the session tracker and continuing where we left off
|
||||
---
|
||||
|
||||
# Starting New Session
|
||||
|
||||
Please do the following:
|
||||
|
||||
1. Read the `SESSION_ACTIVE` file to understand current state
|
||||
2. Check git status and current branch
|
||||
3. Summarize where we left off:
|
||||
- What was completed in the last session
|
||||
- What task we were working on
|
||||
- What's next on the list
|
||||
4. Ask me what I want to work on today
|
||||
5. Create a todo list for today's work using the TodoWrite tool
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(test:*)",
|
||||
"Bash(docker exec:*)",
|
||||
"Bash(docker stats:*)",
|
||||
"Bash(docker logs:*)",
|
||||
"Bash(docker-compose down:*)",
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(php --version:*)",
|
||||
"Bash(docker-compose build:*)",
|
||||
"Bash(docker-compose restart:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(docker ps:*)",
|
||||
"Bash(php -l:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(docker update:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(php artisan:*)",
|
||||
"Bash(php check_blade.php:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
51
.env.example
51
.env.example
@@ -8,6 +8,10 @@ APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
# Stock Notification Settings
|
||||
# Number of days before stock notification requests expire (default: 30)
|
||||
STOCK_NOTIFICATION_EXPIRATION_DAYS=30
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
@@ -77,19 +81,42 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# AWS/MinIO S3 Storage Configuration
|
||||
# Local development: Use FILESYSTEM_DISK=public (default)
|
||||
# Production: Use FILESYSTEM_DISK=s3 with MinIO credentials below
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_ENDPOINT=
|
||||
AWS_URL=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# MinIO/S3 Storage Configuration
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# Versioning is enabled in all environments for asset recovery
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# Production MinIO Configuration (example):
|
||||
# FILESYSTEM_DISK=s3
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ LOCAL DEVELOPMENT (Docker MinIO) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# Use local MinIO container for development (versioning enabled)
|
||||
# Access MinIO Console: http://localhost:9001 (minioadmin/minioadmin)
|
||||
FILESYSTEM_DISK=minio
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=media
|
||||
AWS_ENDPOINT=http://minio:9000
|
||||
AWS_URL=http://localhost:9000/media
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ STAGING/DEVELOP (media-dev bucket) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# FILESYSTEM_DISK=minio
|
||||
# AWS_ACCESS_KEY_ID=<staging-access-key>
|
||||
# AWS_SECRET_ACCESS_KEY=<staging-secret-key>
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
# AWS_BUCKET=media-dev
|
||||
# AWS_ENDPOINT=https://cdn.cannabrands.app
|
||||
# AWS_URL=https://cdn.cannabrands.app/media-dev
|
||||
# AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# ┌─────────────────────────────────────────────────────────────────────┐
|
||||
# │ PRODUCTION (media bucket) │
|
||||
# └─────────────────────────────────────────────────────────────────────┘
|
||||
# FILESYSTEM_DISK=minio
|
||||
# AWS_ACCESS_KEY_ID=TrLoFnMOVQC2CqLm9711
|
||||
# AWS_SECRET_ACCESS_KEY=4tfik06LitWz70L4VLIA45yXla4gi3zQI2IA3oSZ
|
||||
# AWS_DEFAULT_REGION=us-east-1
|
||||
|
||||
@@ -23,10 +23,11 @@ chmod +x .githooks/*
|
||||
|
||||
### `pre-commit` - Laravel Pint Auto-formatter ✅ ENFORCED
|
||||
**What it does:**
|
||||
- Runs Laravel Pint on staged files only (`--dirty`)
|
||||
- Runs Laravel Pint on staged PHP files only (not unstaged files)
|
||||
- Auto-formats code to match team standards
|
||||
- Automatically stages formatted files
|
||||
- Automatically re-stages the formatted files
|
||||
- Fast feedback (runs in seconds)
|
||||
- Safe: Won't format or stage files you haven't explicitly added
|
||||
|
||||
**When it runs:**
|
||||
- Every time you run `git commit`
|
||||
|
||||
@@ -1,22 +1,37 @@
|
||||
#!/bin/sh
|
||||
# Laravel Pint Pre-commit Hook
|
||||
# Automatically format code before committing
|
||||
# Automatically format staged PHP files before committing
|
||||
|
||||
echo "🎨 Running Laravel Pint..."
|
||||
|
||||
# Run Pint on staged files only
|
||||
./vendor/bin/pint --dirty
|
||||
# Get only staged PHP files
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.php$')
|
||||
|
||||
# Check if Pint made changes
|
||||
if ! git diff --quiet; then
|
||||
echo "✅ Code formatted! Files have been updated."
|
||||
echo " Changes have been staged automatically."
|
||||
|
||||
# Stage the formatted files
|
||||
git add -u
|
||||
|
||||
exit 0
|
||||
else
|
||||
echo "✅ Code style looks good!"
|
||||
# Exit early if no PHP files are staged
|
||||
if [ -z "$STAGED_FILES" ]; then
|
||||
echo "✅ No PHP files staged"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run Pint only on staged files
|
||||
echo "$STAGED_FILES" | xargs ./vendor/bin/pint
|
||||
|
||||
# Check if Pint made changes to any of the staged files
|
||||
CHANGED=false
|
||||
for file in $STAGED_FILES; do
|
||||
if ! git diff --quiet "$file" 2>/dev/null; then
|
||||
CHANGED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Re-stage the formatted files (only the ones that were already staged)
|
||||
if [ "$CHANGED" = true ]; then
|
||||
echo "✅ Code formatted! Files have been updated."
|
||||
echo " Changes have been staged automatically."
|
||||
echo "$STAGED_FILES" | xargs git add
|
||||
else
|
||||
echo "✅ Code style looks good!"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Pre-push hook - Runs tests before pushing (supports both Sail and K8s)
|
||||
# Pre-push hook - Optionally run tests before pushing
|
||||
# Can be skipped with: git push --no-verify
|
||||
#
|
||||
# This is OPTIONAL - CI/CD will run comprehensive tests automatically.
|
||||
# Running tests locally can catch issues faster, but it's not required.
|
||||
#
|
||||
|
||||
echo "🧪 Running tests before push..."
|
||||
echo " (Use 'git push --no-verify' to skip)"
|
||||
echo "🚀 Preparing to push..."
|
||||
echo ""
|
||||
|
||||
# Detect which environment is running
|
||||
SAIL_RUNNING=false
|
||||
K8S_RUNNING=false
|
||||
|
||||
# Check if Sail is running
|
||||
if docker ps --format '{{.Names}}' | grep -q "sail" 2>/dev/null; then
|
||||
# Check if Sail is running (use vendor/bin/sail ps which works for all project names)
|
||||
if [ -f ./vendor/bin/sail ] && ./vendor/bin/sail ps 2>/dev/null | grep -q "Up"; then
|
||||
SAIL_RUNNING=true
|
||||
echo "📦 Detected Sail environment"
|
||||
fi
|
||||
|
||||
# Check if k8s namespace exists for this worktree
|
||||
@@ -24,41 +25,46 @@ K8S_NS=$(echo "$BRANCH" | sed 's/feature\//feat-/' | sed 's/bugfix\//fix-/' | se
|
||||
|
||||
if kubectl get namespace "$K8S_NS" >/dev/null 2>&1; then
|
||||
K8S_RUNNING=true
|
||||
echo "☸️ Detected K8s environment (namespace: $K8S_NS)"
|
||||
fi
|
||||
|
||||
# Run tests in appropriate environment
|
||||
if [ "$SAIL_RUNNING" = true ]; then
|
||||
./vendor/bin/sail artisan test --parallel
|
||||
TEST_EXIT_CODE=$?
|
||||
elif [ "$K8S_RUNNING" = true ]; then
|
||||
echo " Running tests in k8s pod..."
|
||||
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
|
||||
TEST_EXIT_CODE=$?
|
||||
else
|
||||
echo "⚠️ No environment running (Sail or K8s)"
|
||||
echo " Skipping tests - please run tests manually"
|
||||
# Offer to run tests if environment is available
|
||||
if [ "$SAIL_RUNNING" = true ] || [ "$K8S_RUNNING" = true ]; then
|
||||
echo "💡 Tests will run automatically in CI/CD"
|
||||
echo ""
|
||||
read -p "Continue push anyway? (y/n) " -n 1 -r
|
||||
read -p "Run tests locally before push? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
if [ ! "$REPLY" = "y" ] && [ ! "$REPLY" = "Y" ]; then
|
||||
echo "Push aborted"
|
||||
exit 1
|
||||
echo ""
|
||||
|
||||
if [ "$REPLY" = "y" ] || [ "$REPLY" = "Y" ]; then
|
||||
echo "🧪 Running tests..."
|
||||
echo ""
|
||||
|
||||
if [ "$SAIL_RUNNING" = true ]; then
|
||||
./vendor/bin/sail artisan test --parallel
|
||||
TEST_EXIT_CODE=$?
|
||||
elif [ "$K8S_RUNNING" = true ]; then
|
||||
kubectl -n "$K8S_NS" exec deploy/web -- php artisan test
|
||||
TEST_EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Tests failed!"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " 1. Fix the failing tests (recommended)"
|
||||
echo " 2. Push anyway - CI will catch failures: git push --no-verify"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ All tests passed!"
|
||||
echo ""
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check test results
|
||||
if [ $TEST_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Tests failed!"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " 1. Fix the failing tests (recommended)"
|
||||
echo " 2. Push anyway with: git push --no-verify"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "⚡ Pushing to remote (CI will run full test suite)..."
|
||||
echo ""
|
||||
echo "✅ All tests passed! Pushing..."
|
||||
|
||||
exit 0
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -30,6 +30,9 @@ yarn-error.log
|
||||
# Node symlink (for ARM-based machines)
|
||||
/node
|
||||
|
||||
# Git worktrees directory
|
||||
/.worktrees/
|
||||
|
||||
# Database backups
|
||||
*.gz
|
||||
*.sql.gz
|
||||
@@ -59,3 +62,6 @@ core.*
|
||||
!resources/**/*.jpg
|
||||
!resources/**/*.jpeg
|
||||
.claude/settings.local.json
|
||||
storage/tmp/*
|
||||
!storage/tmp/.gitignore
|
||||
SESSION_ACTIVE
|
||||
|
||||
@@ -35,7 +35,7 @@ steps:
|
||||
- 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
|
||||
- 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..."
|
||||
|
||||
1710
01-analytics-system.md
Normal file
1710
01-analytics-system.md
Normal file
File diff suppressed because it is too large
Load Diff
337
ANALYTICS_IMPLEMENTATION.md
Normal file
337
ANALYTICS_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Analytics System Implementation - Complete
|
||||
|
||||
## Overview
|
||||
Comprehensive analytics system for Cannabrands B2B marketplace with multi-tenancy security, real-time notifications, and advanced buyer intelligence.
|
||||
|
||||
## IMPORTANT: Automatic Tracking for All Buyer Pages
|
||||
**Analytics tracking is AUTOMATIC and requires NO CODE in individual views.**
|
||||
|
||||
- Tracking is included in the buyer layout file: `layouts/buyer-app-with-sidebar.blade.php`
|
||||
- Any page that extends this layout automatically gets tracking (page views, scroll depth, time on page, sessions)
|
||||
- When creating new buyer pages, simply extend the layout - tracking is automatic
|
||||
- NO need to add analytics code to individual views
|
||||
|
||||
```blade
|
||||
{{-- Example: Any new buyer page --}}
|
||||
@extends('layouts.buyer-app-with-sidebar')
|
||||
|
||||
@section('content')
|
||||
{{-- Your content here - tracking happens automatically --}}
|
||||
@endsection
|
||||
```
|
||||
|
||||
**Optional:** Add `data-track-click` attributes to specific elements for granular click tracking, but basic analytics work without any additional code.
|
||||
|
||||
## Features Implemented
|
||||
|
||||
### 1. Multi-Tenancy Security
|
||||
- **BusinessScope**: All analytics models use global scope for automatic data isolation
|
||||
- **Auto-scoping**: business_id automatically set on model creation
|
||||
- **Permission System**: Granular analytics permissions via business_user.permissions JSON
|
||||
- **Cross-Business Protection**: Users cannot access other businesses' analytics data
|
||||
|
||||
### 2. Analytics Models (10 Total)
|
||||
All models in `app/Models/Analytics/`:
|
||||
|
||||
1. **AnalyticsEvent** - Raw event stream (all interactions)
|
||||
2. **ProductView** - Product engagement tracking
|
||||
3. **EmailCampaign** - Email campaign management
|
||||
4. **EmailInteraction** - Individual recipient tracking
|
||||
5. **EmailClick** - Email link click tracking
|
||||
6. **ClickTracking** - General click event tracking
|
||||
7. **UserSession** - Session tracking and conversion funnel
|
||||
8. **IntentSignal** - High-intent buyer detection
|
||||
9. **BuyerEngagementScore** - Calculated engagement metrics
|
||||
10. **EmailCampaign** - (already mentioned above)
|
||||
|
||||
### 3. Database Migrations (7 Tables)
|
||||
All in `database/migrations/2025_11_08_*`:
|
||||
|
||||
- `analytics_events` - Event stream
|
||||
- `product_views` - Product engagement
|
||||
- `email_campaigns`, `email_interactions`, `email_clicks` - Email analytics
|
||||
- `click_tracking` - Click events
|
||||
- `user_sessions`, `intent_signals`, `buyer_engagement_scores` - Buyer intelligence
|
||||
|
||||
**Important**: All composite indexes start with `business_id` for query performance.
|
||||
|
||||
### 4. Services & Jobs
|
||||
|
||||
**AnalyticsTracker Service** (`app/Services/AnalyticsTracker.php`):
|
||||
- `trackProductView()` - Track product page views with signals
|
||||
- `trackClick()` - Track general click events
|
||||
- `trackEmailInteraction()` - Track email actions
|
||||
- `startSession()` - Initialize/update user sessions
|
||||
- `detectIntentSignals()` - Automatic high-intent detection
|
||||
|
||||
**Queue Jobs**:
|
||||
- `CalculateEngagementScore` - Compute buyer engagement scores
|
||||
- `ProcessAnalyticsEvent` - Async event processing
|
||||
|
||||
### 5. Real-Time Features
|
||||
|
||||
**Reverb Event** (`app/Events/HighIntentBuyerDetected.php`):
|
||||
- Broadcasts when high-intent signals detected
|
||||
- Channel: `business.{id}.analytics`
|
||||
- Event: `high-intent-buyer-detected`
|
||||
|
||||
### 6. Controllers (5 Total)
|
||||
All in `app/Http/Controllers/Analytics/`:
|
||||
|
||||
1. **AnalyticsDashboardController** - Overview dashboard
|
||||
2. **ProductAnalyticsController** - Product performance
|
||||
3. **MarketingAnalyticsController** - Email campaigns
|
||||
4. **SalesAnalyticsController** - Sales funnel
|
||||
5. **BuyerIntelligenceController** - Buyer engagement
|
||||
|
||||
### 7. Views (4 Main Pages)
|
||||
All in `resources/views/seller/analytics/`:
|
||||
|
||||
- `dashboard.blade.php` - Analytics overview
|
||||
- `products.blade.php` - Product analytics
|
||||
- `marketing.blade.php` - Marketing analytics
|
||||
- `sales.blade.php` - Sales analytics
|
||||
- `buyers.blade.php` - Buyer intelligence
|
||||
|
||||
**Design**:
|
||||
- DaisyUI/Nexus components
|
||||
- ApexCharts for visualizations
|
||||
- Anime.js for counter animations
|
||||
- Responsive grid layouts
|
||||
|
||||
### 8. Navigation
|
||||
Updated `resources/views/components/seller-sidebar.blade.php`:
|
||||
- Dashboard - Single top-level item
|
||||
- Analytics - Parent with subsections (Products, Marketing, Sales, Buyers)
|
||||
- Reports - Separate future section
|
||||
- Permission-based visibility
|
||||
|
||||
### 9. Permissions System
|
||||
|
||||
**Available Permissions**:
|
||||
- `analytics.overview` - Main dashboard
|
||||
- `analytics.products` - Product analytics
|
||||
- `analytics.marketing` - Marketing analytics
|
||||
- `analytics.sales` - Sales analytics
|
||||
- `analytics.buyers` - Buyer intelligence
|
||||
- `analytics.export` - Data export
|
||||
|
||||
**Permission UI** (`resources/views/business/users/index.blade.php`):
|
||||
- Modal for managing user permissions
|
||||
- Available to business owners only
|
||||
- Real-time updates via AJAX
|
||||
|
||||
### 10. Client-Side Tracking
|
||||
|
||||
**analytics-tracker.js**:
|
||||
- Automatic page view tracking
|
||||
- Scroll depth tracking
|
||||
- Time on page tracking
|
||||
- Click tracking via `data-track-click` attributes
|
||||
- ProductPageTracker for enhanced product tracking
|
||||
|
||||
**reverb-analytics-listener.js**:
|
||||
- Real-time high-intent buyer notifications
|
||||
- Toast notifications
|
||||
- Auto-navigation to buyer details
|
||||
- Notification badge updates
|
||||
|
||||
### 11. Security Tests
|
||||
Comprehensive test suite in `tests/Feature/Analytics/AnalyticsSecurityTest.php`:
|
||||
- ✓ Data scoped to business
|
||||
- ✓ Permission enforcement
|
||||
- ✓ Cross-business access prevention
|
||||
- ✓ Auto business_id assignment
|
||||
- ✓ forBusiness scope functionality
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Run Composer Autoload
|
||||
```bash
|
||||
docker compose exec laravel.test composer dump-autoload
|
||||
```
|
||||
|
||||
### 2. Run Migrations
|
||||
```bash
|
||||
docker compose exec laravel.test php artisan migrate
|
||||
```
|
||||
|
||||
### 3. Include JavaScript Files
|
||||
Add to your layout file:
|
||||
```html
|
||||
<script src="{{ asset('js/analytics-tracker.js') }}"></script>
|
||||
<script src="{{ asset('js/reverb-analytics-listener.js') }}"></script>
|
||||
|
||||
<!-- Add business ID meta tag -->
|
||||
<meta name="business-id" content="{{ currentBusinessId() }}">
|
||||
```
|
||||
|
||||
### 4. Queue Configuration
|
||||
Ensure Redis queues are configured in `.env`:
|
||||
```env
|
||||
QUEUE_CONNECTION=redis
|
||||
```
|
||||
|
||||
Start queue worker:
|
||||
```bash
|
||||
docker compose exec laravel.test php artisan queue:work --queue=analytics
|
||||
```
|
||||
|
||||
### 5. Reverb Configuration
|
||||
Reverb should already be configured. Verify broadcasting is enabled:
|
||||
```env
|
||||
BROADCAST_DRIVER=reverb
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Assigning Analytics Permissions
|
||||
1. Navigate to Business > Users
|
||||
2. Click "Permissions" button on user card (owner only)
|
||||
3. Select analytics permissions
|
||||
4. Save
|
||||
|
||||
### Tracking Product Views (Server-Side)
|
||||
```php
|
||||
use App\Services\AnalyticsTracker;
|
||||
|
||||
$tracker = new AnalyticsTracker();
|
||||
$tracker->trackProductView($product, [
|
||||
'zoomed_image' => true,
|
||||
'time_on_page' => 120,
|
||||
'added_to_cart' => true
|
||||
]);
|
||||
```
|
||||
|
||||
### Tracking Product Views (Client-Side)
|
||||
```html
|
||||
<!-- Product page -->
|
||||
<script>
|
||||
const productTracker = new ProductPageTracker({{ $product->id }});
|
||||
</script>
|
||||
|
||||
<!-- Mark trackable elements -->
|
||||
<button data-product-add-cart>Add to Cart</button>
|
||||
<a data-product-spec-download href="/spec.pdf">Download Spec</a>
|
||||
<div data-product-image-zoom>
|
||||
<img src="/product.jpg">
|
||||
</div>
|
||||
```
|
||||
|
||||
### Tracking Clicks
|
||||
```html
|
||||
<button data-track-click="cta_button"
|
||||
data-track-id="123"
|
||||
data-track-label="Buy Now">
|
||||
Buy Now
|
||||
</button>
|
||||
```
|
||||
|
||||
### Listening to Real-Time Events
|
||||
```javascript
|
||||
window.addEventListener('analytics:high-intent-buyer', (event) => {
|
||||
console.log('High intent buyer:', event.detail);
|
||||
// Update UI, show notification, etc.
|
||||
});
|
||||
```
|
||||
|
||||
### Calculating Engagement Scores
|
||||
```php
|
||||
use App\Jobs\CalculateEngagementScore;
|
||||
|
||||
CalculateEngagementScore::dispatch($sellerBusinessId, $buyerBusinessId)
|
||||
->onQueue('analytics');
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Analytics Routes
|
||||
All routes prefixed with `/s/{business}/analytics`:
|
||||
|
||||
- `GET /` - Analytics dashboard
|
||||
- `GET /products` - Product analytics
|
||||
- `GET /products/{product}` - Product detail
|
||||
- `GET /marketing` - Marketing analytics
|
||||
- `GET /marketing/campaigns/{campaign}` - Campaign detail
|
||||
- `GET /sales` - Sales analytics
|
||||
- `GET /buyers` - Buyer intelligence
|
||||
- `GET /buyers/{buyer}` - Buyer detail
|
||||
|
||||
### Permission Management
|
||||
- `POST /s/{business}/users/{user}/permissions` - Update user permissions
|
||||
|
||||
## Database Indexes
|
||||
|
||||
All analytics tables have composite indexes starting with `business_id`:
|
||||
- `idx_business_created` - (business_id, created_at)
|
||||
- `idx_business_type_created` - (business_id, event_type, created_at)
|
||||
- `idx_business_product_time` - (business_id, product_id, viewed_at)
|
||||
|
||||
This ensures optimal query performance for multi-tenant queries.
|
||||
|
||||
## Helper Functions
|
||||
|
||||
Global helpers available everywhere:
|
||||
|
||||
```php
|
||||
// Get current business
|
||||
$business = currentBusiness();
|
||||
|
||||
// Get current business ID
|
||||
$businessId = currentBusinessId();
|
||||
|
||||
// Check permission
|
||||
if (hasBusinessPermission('analytics.overview')) {
|
||||
// User has permission
|
||||
}
|
||||
|
||||
// Get business from product
|
||||
$sellerBusiness = \App\Helpers\BusinessHelper::fromProduct($product);
|
||||
```
|
||||
|
||||
## Git Commits
|
||||
|
||||
Total of 6 commits:
|
||||
|
||||
1. **Foundation** - Helpers, migrations, base models
|
||||
2. **Backend Logic** - Remaining models, services, jobs, events, controllers, routes
|
||||
3. **Navigation** - Updated seller sidebar
|
||||
4. **Views** - 4 analytics dashboards
|
||||
5. **Permissions** - User permission management UI
|
||||
6. **JavaScript** - Client-side tracking and Reverb listeners
|
||||
7. **Tests** - Security test suite
|
||||
|
||||
## Testing
|
||||
|
||||
Run analytics security tests:
|
||||
```bash
|
||||
docker compose exec laravel.test php artisan test tests/Feature/Analytics/AnalyticsSecurityTest.php
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All analytics data is scoped to business_id automatically via BusinessScope
|
||||
- Permission checks use `hasBusinessPermission()` helper
|
||||
- High-intent signals trigger real-time Reverb events
|
||||
- Engagement scores calculated asynchronously via queue
|
||||
- Client-side tracking uses sendBeacon for reliability
|
||||
- All views use DaisyUI components (no inline styles)
|
||||
|
||||
## Next Steps (Optional Enhancements)
|
||||
|
||||
1. Add more granular permissions (view vs export)
|
||||
2. Implement scheduled engagement score recalculation
|
||||
3. Add email templates for high-intent buyer alerts
|
||||
4. Create analytics export functionality (CSV/PDF)
|
||||
5. Add custom date range selectors
|
||||
6. Implement analytics API for third-party integrations
|
||||
7. Add more chart types and visualizations
|
||||
8. Create analytics widgets for main dashboard
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check migration files for table structure
|
||||
- Review controller methods for query patterns
|
||||
- Examine test file for usage examples
|
||||
- Check JavaScript console for client-side errors
|
||||
196
ANALYTICS_QUICK_START.md
Normal file
196
ANALYTICS_QUICK_START.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Analytics System - Quick Start Guide
|
||||
|
||||
## ✅ What's Already Done
|
||||
|
||||
### 1. Global Tracking (Automatic)
|
||||
- Analytics tracker loaded on every page via `app-with-sidebar.blade.php`
|
||||
- Automatically tracks:
|
||||
- Page views
|
||||
- Time spent on page
|
||||
- Scroll depth
|
||||
- User sessions
|
||||
- All clicks with `data-track-click` attributes
|
||||
|
||||
### 2. Product Page Tracking (Implemented!)
|
||||
- **File**: `buyer/marketplace/product.blade.php`
|
||||
- **Tracks**:
|
||||
- Product views (automatic on page load)
|
||||
- Image zoom clicks (gallery images)
|
||||
- Lab report downloads
|
||||
- Add to cart button clicks
|
||||
- Related product clicks
|
||||
|
||||
### 3. Real-Time Notifications (For Sellers)
|
||||
- Reverb integration for high-intent buyer alerts
|
||||
- Automatically enabled for sellers
|
||||
- Toast notifications appear when buyers show buying signals
|
||||
|
||||
## 🚀 How to See It Working
|
||||
|
||||
### Step 1: Visit a Product Page
|
||||
```
|
||||
http://yoursite.com/b/{business}/brands/{brand}/products/{product}
|
||||
```
|
||||
|
||||
### Step 2: Open Browser DevTools
|
||||
- Press `F12`
|
||||
- Go to **Network** tab
|
||||
- Filter by: `track`
|
||||
|
||||
### Step 3: Interact with the Page
|
||||
- Scroll down
|
||||
- Click gallery images
|
||||
- Click "Add to Cart"
|
||||
- Click "View Report" on lab results
|
||||
- Click related products
|
||||
|
||||
### Step 4: Check Network Requests
|
||||
Look for POST requests to `/api/analytics/track` with payloads like:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "product_view",
|
||||
"product_id": 123,
|
||||
"session_id": "abc-123-def",
|
||||
"timestamp": 1699123456789
|
||||
}
|
||||
```
|
||||
|
||||
### Step 5: View Analytics Dashboard
|
||||
```
|
||||
http://yoursite.com/s/{business-slug}/analytics
|
||||
```
|
||||
|
||||
## 📊 What Data You'll See
|
||||
|
||||
### Product Analytics
|
||||
Navigate to: **Analytics > Products**
|
||||
|
||||
View:
|
||||
- Most viewed products
|
||||
- Product engagement rates
|
||||
- Image zoom rates
|
||||
- Lab report download counts
|
||||
- Add to cart conversion rates
|
||||
|
||||
### Buyer Intelligence
|
||||
Navigate to: **Analytics > Buyers**
|
||||
|
||||
View:
|
||||
- Active buyers this week
|
||||
- High-intent buyers (with real-time alerts)
|
||||
- Engagement scores
|
||||
- Buyer activity timelines
|
||||
|
||||
### Click Heatmap Data
|
||||
- All clicks tracked with element type, ID, and label
|
||||
- Position tracking for understanding UX
|
||||
- Referrer and UTM campaign tracking
|
||||
|
||||
## 🎯 Next Steps
|
||||
|
||||
### 1. Add Tracking to More Pages
|
||||
|
||||
**Marketplace/Catalog Pages:**
|
||||
```blade
|
||||
<div class="product-card">
|
||||
<a href="{{ route('products.show', $product) }}"
|
||||
data-track-click="product-card"
|
||||
data-track-id="{{ $product->id }}"
|
||||
data-track-label="{{ $product->name }}">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Navigation Links:**
|
||||
```blade
|
||||
<a href="{{ route('brands.index') }}"
|
||||
data-track-click="navigation"
|
||||
data-track-label="Browse Brands">
|
||||
Browse All Brands
|
||||
</a>
|
||||
```
|
||||
|
||||
**Filter/Sort Controls:**
|
||||
```blade
|
||||
<select data-track-click="filter"
|
||||
data-track-id="category-filter"
|
||||
data-track-label="Product Category">
|
||||
<option>All Categories</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
### 2. Grant Analytics Permissions
|
||||
|
||||
1. Go to: `Settings > Users`
|
||||
2. Click "Permissions" on a user
|
||||
3. Check analytics permissions:
|
||||
- ✅ `analytics.overview` - Dashboard
|
||||
- ✅ `analytics.products` - Product stats
|
||||
- ✅ `analytics.buyers` - Buyer intelligence
|
||||
- ✅ `analytics.marketing` - Email campaigns
|
||||
- ✅ `analytics.sales` - Sales pipeline
|
||||
- ✅ `analytics.export` - Export data
|
||||
|
||||
### 3. Test Real-Time Notifications (Sellers)
|
||||
|
||||
**Trigger Conditions:**
|
||||
- View same product 3+ times
|
||||
- View 5+ products from same brand
|
||||
- Download lab reports
|
||||
- Add items to cart
|
||||
- Watch product videos (when implemented)
|
||||
|
||||
**Where Alerts Appear:**
|
||||
- Toast notification (top-right)
|
||||
- Bell icon badge (topbar)
|
||||
- Buyer Intelligence page
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Not Seeing Tracking Requests?
|
||||
1. Check browser console for errors
|
||||
2. Verify `/js/analytics-tracker.js` loads correctly
|
||||
3. Check CSRF token is present in page meta tags
|
||||
|
||||
### Analytics Dashboard Empty?
|
||||
1. Ensure you've visited product pages as a buyer
|
||||
2. Check database: `select * from analytics_events limit 10;`
|
||||
3. Verify background jobs are running: `php artisan queue:work`
|
||||
|
||||
### No Real-Time Notifications?
|
||||
1. Ensure Reverb server is running: `php artisan reverb:start`
|
||||
2. Check business ID meta tag in page source
|
||||
3. Verify Laravel Echo is initialized
|
||||
|
||||
## 📁 Key Files
|
||||
|
||||
### JavaScript
|
||||
- `/public/js/analytics-tracker.js` - Main tracker
|
||||
- `/public/js/reverb-analytics-listener.js` - Real-time listener
|
||||
|
||||
### Blade Templates
|
||||
- `layouts/app-with-sidebar.blade.php` - Global setup
|
||||
- `buyer/marketplace/product.blade.php` - Example implementation
|
||||
|
||||
### Controllers
|
||||
- `app/Http/Controllers/Analytics/AnalyticsDashboardController.php`
|
||||
- `app/Http/Controllers/Analytics/ProductAnalyticsController.php`
|
||||
- `app/Http/Controllers/Analytics/BuyerIntelligenceController.php`
|
||||
|
||||
### Models
|
||||
- `app/Models/Analytics/AnalyticsEvent.php`
|
||||
- `app/Models/Analytics/ProductView.php`
|
||||
- `app/Models/Analytics/ClickTracking.php`
|
||||
- `app/Models/Analytics/BuyerEngagementScore.php`
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **Full Implementation Guide**: `ANALYTICS_IMPLEMENTATION.md`
|
||||
- **Tracking Examples**: `ANALYTICS_TRACKING_EXAMPLES.md`
|
||||
- **This Quick Start**: `ANALYTICS_QUICK_START.md`
|
||||
|
||||
## 🎉 You're Ready!
|
||||
|
||||
The analytics system is now live and collecting data. Start browsing product pages and watch the data flow into your analytics dashboard!
|
||||
216
ANALYTICS_TRACKING_EXAMPLES.md
Normal file
216
ANALYTICS_TRACKING_EXAMPLES.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Analytics Tracking Implementation Examples
|
||||
|
||||
This guide shows you how to implement analytics tracking on your pages to start collecting data.
|
||||
|
||||
## 1. Auto-Tracking (Already Working!)
|
||||
|
||||
The analytics tracker is now automatically loaded on all pages via `app-with-sidebar.blade.php`. It already tracks:
|
||||
|
||||
- Page views
|
||||
- Time on page
|
||||
- Scroll depth
|
||||
- Session management
|
||||
- Any element with `data-track-click` attribute
|
||||
|
||||
## 2. Product Detail Page Tracking
|
||||
|
||||
Add this script to the **bottom** of your product blade file (`buyer/marketplace/product.blade.php`):
|
||||
|
||||
```blade
|
||||
@push('scripts')
|
||||
<script>
|
||||
// Initialize product page tracker
|
||||
const productTracker = new ProductPageTracker({{ $product->id }});
|
||||
|
||||
// The tracker automatically tracks:
|
||||
// - Product views (done on initialization)
|
||||
// - Image zoom clicks
|
||||
// - Video plays
|
||||
// - Spec downloads
|
||||
// - Add to cart
|
||||
// - Add to wishlist
|
||||
</script>
|
||||
@endpush
|
||||
```
|
||||
|
||||
### Add Tracking Attributes to Product Page Elements
|
||||
|
||||
Update your product page HTML to include tracking attributes:
|
||||
|
||||
```blade
|
||||
<!-- Image Gallery (for zoom tracking) -->
|
||||
<div class="aspect-square bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:opacity-75"
|
||||
data-product-image-zoom>
|
||||
<img src="{{ asset('storage/' . $image->path) }}" alt="{{ $product->name }}">
|
||||
</div>
|
||||
|
||||
<!-- Product Videos (for video tracking) -->
|
||||
<video controls data-product-video>
|
||||
<source src="{{ asset('storage/' . $video->path) }}" type="video/mp4">
|
||||
</video>
|
||||
|
||||
<!-- Download Spec Sheet Button -->
|
||||
<a href="{{ route('buyer.products.spec-download', $product) }}"
|
||||
data-product-spec-download
|
||||
class="btn btn-outline">
|
||||
<span class="icon-[lucide--download] size-4"></span>
|
||||
Download Spec Sheet
|
||||
</a>
|
||||
|
||||
<!-- Add to Cart Button -->
|
||||
<button type="submit"
|
||||
data-product-add-cart
|
||||
class="btn btn-primary btn-block">
|
||||
<span class="icon-[lucide--shopping-cart] size-5"></span>
|
||||
Add to Cart
|
||||
</button>
|
||||
|
||||
<!-- Add to Wishlist Button -->
|
||||
<button type="button"
|
||||
data-product-add-wishlist
|
||||
class="btn btn-ghost">
|
||||
<span class="icon-[lucide--heart] size-5"></span>
|
||||
Save for Later
|
||||
</button>
|
||||
```
|
||||
|
||||
## 3. Generic Click Tracking
|
||||
|
||||
Track any button or link by adding the `data-track-click` attribute:
|
||||
|
||||
```blade
|
||||
<!-- Track navigation clicks -->
|
||||
<a href="{{ route('buyer.brands.index') }}"
|
||||
data-track-click="navigation"
|
||||
data-track-id="brands-link"
|
||||
data-track-label="View All Brands">
|
||||
Browse Brands
|
||||
</a>
|
||||
|
||||
<!-- Track CTA buttons -->
|
||||
<button data-track-click="cta"
|
||||
data-track-id="contact-seller"
|
||||
data-track-label="Contact Seller Button">
|
||||
Contact Seller
|
||||
</button>
|
||||
|
||||
<!-- Track filters -->
|
||||
<select data-track-click="filter"
|
||||
data-track-id="category-filter"
|
||||
data-track-label="Category Filter">
|
||||
<option>All Categories</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
### Click Tracking Attributes
|
||||
|
||||
- `data-track-click="type"` - Element type (required): `navigation`, `cta`, `filter`, `button`, etc.
|
||||
- `data-track-id="unique-id"` - Unique identifier for this element (optional)
|
||||
- `data-track-label="Label"` - Human-readable label (optional, defaults to element text)
|
||||
- `data-track-url="url"` - Destination URL (optional, auto-detected for links)
|
||||
|
||||
## 4. Dashboard/Catalog Page Tracking
|
||||
|
||||
For product listings and catalogs, add click tracking to product cards:
|
||||
|
||||
```blade
|
||||
@foreach($products as $product)
|
||||
<div class="card">
|
||||
<!-- Track product card clicks -->
|
||||
<a href="{{ route('buyer.products.show', $product) }}"
|
||||
data-track-click="product-card"
|
||||
data-track-id="{{ $product->id }}"
|
||||
data-track-label="{{ $product->name }}"
|
||||
class="card-body">
|
||||
|
||||
<img src="{{ $product->image_url }}" alt="{{ $product->name }}">
|
||||
<h3>{{ $product->name }}</h3>
|
||||
<p>${{ $product->wholesale_price }}</p>
|
||||
</a>
|
||||
|
||||
<!-- Track quick actions -->
|
||||
<button data-track-click="quick-add"
|
||||
data-track-id="{{ $product->id }}"
|
||||
data-track-label="Quick Add - {{ $product->name }}"
|
||||
class="btn btn-sm">
|
||||
Quick Add
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
```
|
||||
|
||||
## 5. Viewing Analytics Data
|
||||
|
||||
Once tracking is implemented, data will flow to:
|
||||
|
||||
1. **Analytics Dashboard**: `https://yoursite.com/s/{business-slug}/analytics`
|
||||
2. **Product Analytics**: `https://yoursite.com/s/{business-slug}/analytics/products`
|
||||
3. **Buyer Intelligence**: `https://yoursite.com/s/{business-slug}/analytics/buyers`
|
||||
|
||||
## 6. Backend API Endpoint
|
||||
|
||||
The tracker sends events to: `/api/analytics/track`
|
||||
|
||||
This endpoint is already set up and accepts:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "product_view|click|product_signal",
|
||||
"product_id": 123,
|
||||
"element_type": "button",
|
||||
"element_id": "add-to-cart",
|
||||
"element_label": "Add to Cart",
|
||||
"session_id": "uuid",
|
||||
"timestamp": 1234567890
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Real-Time Notifications (Sellers Only)
|
||||
|
||||
For sellers with Reverb enabled, high-intent buyer alerts will automatically appear when:
|
||||
|
||||
- A buyer views multiple products from your brand
|
||||
- A buyer repeatedly views the same product
|
||||
- A buyer downloads spec sheets
|
||||
- A buyer adds items to cart
|
||||
- A buyer watches product videos
|
||||
|
||||
Notifications appear as toast messages and in the notification dropdown.
|
||||
|
||||
## 8. Permission Setup
|
||||
|
||||
To grant users access to analytics:
|
||||
|
||||
1. Go to **Users** page in your business dashboard
|
||||
2. Click "Permissions" on a user card
|
||||
3. Check the analytics permissions you want to grant:
|
||||
- `analytics.overview` - Dashboard access
|
||||
- `analytics.products` - Product performance
|
||||
- `analytics.marketing` - Email campaigns
|
||||
- `analytics.sales` - Sales intelligence
|
||||
- `analytics.buyers` - Buyer insights
|
||||
- `analytics.export` - Export data
|
||||
|
||||
## Quick Start Checklist
|
||||
|
||||
- [x] Analytics scripts loaded in layout ✅ (Done automatically)
|
||||
- [ ] Add product page tracker to product detail pages
|
||||
- [ ] Add tracking attributes to product images/videos/buttons
|
||||
- [ ] Add click tracking to navigation and CTAs
|
||||
- [ ] Add tracking to product cards in listings
|
||||
- [ ] Grant analytics permissions to team members
|
||||
- [ ] Visit analytics dashboard to see data
|
||||
|
||||
## Testing
|
||||
|
||||
To test if tracking is working:
|
||||
|
||||
1. Open browser DevTools (F12)
|
||||
2. Go to Network tab
|
||||
3. Navigate to a product page
|
||||
4. Look for POST requests to `/api/analytics/track`
|
||||
5. Check the payload to see what data is being sent
|
||||
|
||||
## Need Help?
|
||||
|
||||
Check `ANALYTICS_IMPLEMENTATION.md` for full technical documentation.
|
||||
120
CLAUDE.local.md
Normal file
120
CLAUDE.local.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Local Workflow Notes (NOT COMMITTED TO GIT)
|
||||
|
||||
## 🚨 MANDATORY WORKTREES WORKFLOW
|
||||
|
||||
**CRITICAL:** Claude MUST use worktrees for ALL feature work. NO exceptions.
|
||||
|
||||
### Worktrees Location
|
||||
- **Main repo:** `C:\Users\Boss Man\Documents\GitHub\hub`
|
||||
- **Worktrees folder:** `C:\Users\Boss Man\Documents\GitHub\Work Trees\`
|
||||
|
||||
---
|
||||
|
||||
## Claude's Proactive Workflow Guide
|
||||
|
||||
### When User Requests a Feature
|
||||
|
||||
**Claude MUST immediately say:**
|
||||
> "Let me create a worktree for this feature"
|
||||
|
||||
**Then Claude MUST:**
|
||||
|
||||
1. **Create worktree with descriptive name:**
|
||||
```bash
|
||||
cd "C:\Users\Boss Man\Documents\GitHub\hub"
|
||||
git worktree add "../Work Trees/feature-descriptive-name" -b feature/descriptive-name
|
||||
```
|
||||
|
||||
2. **Switch to worktree:**
|
||||
```bash
|
||||
cd "C:\Users\Boss Man\Documents\GitHub\Work Trees/feature-descriptive-name"
|
||||
```
|
||||
|
||||
3. **Work on that ONE feature only:**
|
||||
- Keep focused on single feature
|
||||
- Commit regularly with clear messages
|
||||
- Run tests frequently
|
||||
|
||||
### When Feature is Complete
|
||||
|
||||
**Claude MUST prompt user:**
|
||||
> "Feature complete! Ready to create a PR?"
|
||||
|
||||
**Then Claude MUST:**
|
||||
|
||||
1. **Run tests first:**
|
||||
```bash
|
||||
./vendor/bin/pint
|
||||
php artisan test --parallel
|
||||
```
|
||||
|
||||
2. **Push branch:**
|
||||
```bash
|
||||
git push -u origin feature/descriptive-name
|
||||
```
|
||||
|
||||
3. **Create PR with good description:**
|
||||
```bash
|
||||
gh pr create --title "Feature: description" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
- Bullet point summary
|
||||
|
||||
## Changes
|
||||
- What was added/modified
|
||||
|
||||
## Test Plan
|
||||
- How to test
|
||||
|
||||
🤖 Generated with Claude Code
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### After PR is Merged
|
||||
|
||||
**Claude MUST help cleanup:**
|
||||
|
||||
1. **Return to main repo:**
|
||||
```bash
|
||||
cd "C:\Users\Boss Man\Documents\GitHub\hub"
|
||||
```
|
||||
|
||||
2. **Remove worktree:**
|
||||
```bash
|
||||
git worktree remove "../Work Trees/feature-descriptive-name"
|
||||
```
|
||||
|
||||
3. **Delete local branch:**
|
||||
```bash
|
||||
git branch -d feature/descriptive-name
|
||||
```
|
||||
|
||||
4. **Pull latest develop:**
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Why Worktrees are Mandatory
|
||||
|
||||
- ✅ **Isolation:** Each feature has its own directory
|
||||
- ✅ **No conflicts:** Work on multiple features safely
|
||||
- ✅ **Clean commits:** No mixing of changes
|
||||
- ✅ **Safety:** Main repo stays clean on develop
|
||||
- ✅ **Easy PR workflow:** One worktree = one PR
|
||||
|
||||
---
|
||||
|
||||
## Emergency: Uncommitted Work in Main Repo
|
||||
|
||||
If there are uncommitted changes in main repo:
|
||||
|
||||
1. **Best:** Commit to feature branch first
|
||||
2. **Alternative:** Stash them: `git stash`
|
||||
3. **Last resort:** Ask user what to do
|
||||
|
||||
---
|
||||
|
||||
**Note:** This file is in `.gitignore` and will never be committed or pushed to remote.
|
||||
20
CLAUDE.md
20
CLAUDE.md
@@ -1,5 +1,11 @@
|
||||
# Claude Code Context
|
||||
|
||||
## 📌 IMPORTANT: Check Personal Context Files
|
||||
|
||||
**ALWAYS read `claude.kelly.md` first** - Contains personal preferences and session tracking workflow
|
||||
|
||||
---
|
||||
|
||||
## 🚨 Critical Mistakes You Make
|
||||
|
||||
### 1. Business Isolation (MOST COMMON!)
|
||||
@@ -78,6 +84,11 @@ php artisan test --parallel # REQUIRED
|
||||
./vendor/bin/pint # REQUIRED
|
||||
```
|
||||
|
||||
**Commit Messages:**
|
||||
- ❌ **DO NOT** include Claude Code signature/attribution in commit messages
|
||||
- ❌ **DO NOT** add "🤖 Generated with Claude Code" or "Co-Authored-By: Claude"
|
||||
- ✅ Write clean, professional commit messages without AI attribution
|
||||
|
||||
**Credentials:** `{buyer,seller,admin}@example.com` / `password`
|
||||
|
||||
**Branches:** Never commit to `master`/`develop` directly - use feature branches
|
||||
@@ -104,8 +115,15 @@ Product::where('is_active', true)->get(); // No business_id filter!
|
||||
|
||||
---
|
||||
|
||||
## External Docs (Read When Needed)
|
||||
## Architecture Docs (Read When Needed)
|
||||
|
||||
**Custom Architecture:**
|
||||
- `.claude/DEPARTMENTS.md` - Department system, permissions, access control
|
||||
- `.claude/ROUTING.md` - Business slug routing, subdivision architecture
|
||||
- `.claude/PROCESSING.md` - Processing operations (Solventless vs BHO, conversions, wash batches)
|
||||
- `.claude/MODELS.md` - Key models, relationships, query patterns
|
||||
|
||||
**Standard Docs:**
|
||||
- `docs/URL_STRUCTURE.md` - **READ BEFORE** routing changes
|
||||
- `docs/DATABASE.md` - **READ BEFORE** migrations
|
||||
- `docs/DEVELOPMENT.md` - Local setup
|
||||
|
||||
157
CONTRIBUTING.md
157
CONTRIBUTING.md
@@ -239,6 +239,163 @@ git push origin feature/my-feature
|
||||
git push --no-verify
|
||||
```
|
||||
|
||||
### Keeping Your Feature Branch Up-to-Date
|
||||
|
||||
**Best practice for teams:** Sync your feature branch with `develop` regularly to avoid large merge conflicts.
|
||||
|
||||
#### Daily Start-of-Work Routine
|
||||
|
||||
```bash
|
||||
# 1. Get latest changes from develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
|
||||
# 2. Update your feature branch
|
||||
git checkout feature/my-feature
|
||||
git merge develop
|
||||
|
||||
# 3. If there are conflicts (see below), resolve them
|
||||
# 4. Continue working
|
||||
```
|
||||
|
||||
**How often?**
|
||||
- Minimum: Once per day (start of work)
|
||||
- Better: Multiple times per day if develop is active
|
||||
- Always: Before creating your Pull Request
|
||||
|
||||
#### Merge vs Rebase: Which to Use?
|
||||
|
||||
**For teams of 5+ developers, use `merge` (not `rebase`):**
|
||||
|
||||
```bash
|
||||
git checkout feature/my-feature
|
||||
git merge develop
|
||||
```
|
||||
|
||||
**Why merge over rebase?**
|
||||
- ✅ Safer: Preserves your commit history
|
||||
- ✅ Collaborative: Works when multiple people work on the same feature branch
|
||||
- ✅ Transparent: Shows when you integrated upstream changes
|
||||
- ✅ No force-push: Once you've pushed to origin, merge won't require `--force`
|
||||
|
||||
**When to use rebase:**
|
||||
- ⚠️ Only if you haven't pushed yet
|
||||
- ⚠️ Only if you're the sole developer on the branch
|
||||
- ⚠️ You want a cleaner, linear history
|
||||
|
||||
```bash
|
||||
# Only do this if you haven't pushed yet!
|
||||
git checkout feature/my-feature
|
||||
git rebase develop
|
||||
```
|
||||
|
||||
**Never rebase after pushing** - it rewrites history and breaks collaboration.
|
||||
|
||||
#### Handling Merge Conflicts
|
||||
|
||||
When you run `git merge develop` and see conflicts:
|
||||
|
||||
```bash
|
||||
$ git merge develop
|
||||
Auto-merging app/Http/Controllers/OrderController.php
|
||||
CONFLICT (content): Merge conflict in app/Http/Controllers/OrderController.php
|
||||
Automatic merge failed; fix conflicts and then commit the result.
|
||||
```
|
||||
|
||||
**Step-by-step resolution:**
|
||||
|
||||
1. **See which files have conflicts:**
|
||||
```bash
|
||||
git status
|
||||
# Look for "both modified:" files
|
||||
```
|
||||
|
||||
2. **Open conflicted files** - look for conflict markers:
|
||||
```php
|
||||
<<<<<<< HEAD
|
||||
// Your code
|
||||
=======
|
||||
// Code from develop
|
||||
>>>>>>> develop
|
||||
```
|
||||
|
||||
3. **Resolve conflicts** - edit the file to keep what you need:
|
||||
```php
|
||||
// Choose your code, their code, or combine both
|
||||
// Remove the <<<, ===, >>> markers
|
||||
```
|
||||
|
||||
4. **Mark as resolved:**
|
||||
```bash
|
||||
git add app/Http/Controllers/OrderController.php
|
||||
```
|
||||
|
||||
5. **Complete the merge:**
|
||||
```bash
|
||||
git commit -m "merge: resolve conflicts with develop"
|
||||
```
|
||||
|
||||
6. **Run tests to ensure nothing broke:**
|
||||
```bash
|
||||
./vendor/bin/sail artisan test
|
||||
```
|
||||
|
||||
7. **Push the merge commit:**
|
||||
```bash
|
||||
git push origin feature/my-feature
|
||||
```
|
||||
|
||||
#### When Conflicts Are Too Complex
|
||||
|
||||
If conflicts are extensive or you're unsure:
|
||||
|
||||
1. **Abort the merge:**
|
||||
```bash
|
||||
git merge --abort
|
||||
```
|
||||
|
||||
2. **Ask for help** in #engineering Slack:
|
||||
- "I'm merging develop into feature/X and have conflicts in OrderController"
|
||||
- Someone might have context on the upstream changes
|
||||
|
||||
3. **Pair program the resolution** - screen share with the person who made the conflicting changes
|
||||
|
||||
4. **Alternative: Start fresh** (last resort):
|
||||
```bash
|
||||
# Create new branch from latest develop
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/my-feature-v2
|
||||
|
||||
# Cherry-pick your commits
|
||||
git cherry-pick <commit-hash>
|
||||
```
|
||||
|
||||
#### Example: Multi-Day Feature Work
|
||||
|
||||
```bash
|
||||
# Monday morning
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Get latest changes
|
||||
# Work all day, make commits
|
||||
|
||||
# Tuesday morning
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Sync again (someone added auth changes)
|
||||
# Continue working
|
||||
|
||||
# Wednesday
|
||||
git checkout develop && git pull origin develop
|
||||
git checkout feature/payment-integration
|
||||
git merge develop # Final sync before PR
|
||||
git push origin feature/payment-integration
|
||||
# Create Pull Request
|
||||
```
|
||||
|
||||
**Result:** Small, manageable syncs instead of one huge conflict on PR day.
|
||||
|
||||
### When to Test Locally
|
||||
|
||||
**Always run tests before pushing if you:**
|
||||
|
||||
@@ -38,9 +38,10 @@ FROM composer:2 AS composer-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install required PHP extensions for Filament
|
||||
RUN apk add --no-cache icu-dev \
|
||||
&& docker-php-ext-install intl
|
||||
# Install required PHP extensions for Filament and Horizon
|
||||
RUN apk add --no-cache icu-dev libpng-dev libjpeg-turbo-dev freetype-dev \
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||
&& docker-php-ext-install intl gd pcntl
|
||||
|
||||
# Copy composer files
|
||||
COPY composer.json composer.lock ./
|
||||
|
||||
587
EXECUTIVE_ACCESS_GUIDE.md
Normal file
587
EXECUTIVE_ACCESS_GUIDE.md
Normal file
@@ -0,0 +1,587 @@
|
||||
# Executive Access Guide: Subdivisions & Department-Based Permissions
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how Cannabrands handles multi-division organizations with department-based access control. It covers:
|
||||
|
||||
- **Parent Companies** with multiple **Subdivisions** (Divisions)
|
||||
- **Department-Based Access Control** - users see only their department's data
|
||||
- **Executive Override** - executives can view all divisions and departments
|
||||
- **Cross-Department Visibility** for shared resources
|
||||
|
||||
## How Companies Work with Subdivisions
|
||||
|
||||
### Organizational Structure
|
||||
|
||||
**Parent Company** → Multiple **Divisions** → Multiple **Departments** → Multiple **Users**
|
||||
|
||||
**Example: Canopy AZ Group**
|
||||
|
||||
```
|
||||
Canopy AZ Group (Parent Company)
|
||||
├── Hash Factory AZ (Division)
|
||||
│ ├── Executive (Department)
|
||||
│ ├── Manufacturing (Department)
|
||||
│ ├── Sales (Department)
|
||||
│ └── Compliance (Department)
|
||||
│
|
||||
├── Leopard AZ (Division)
|
||||
│ ├── Executive (Department)
|
||||
│ ├── Manufacturing (Department)
|
||||
│ ├── Sales (Department)
|
||||
│ └── Fleet Management (Department)
|
||||
│
|
||||
└── Canopy Retail (Division)
|
||||
├── Executive (Department)
|
||||
├── Sales (Department)
|
||||
└── Compliance (Department)
|
||||
```
|
||||
|
||||
### Business Relationships
|
||||
|
||||
**Database Schema:**
|
||||
- Each subdivision has `parent_id` pointing to the parent company
|
||||
- Each subdivision has a `division_name` (e.g., "Hash Factory AZ")
|
||||
- Parent company has `is_parent_company = true`
|
||||
|
||||
**Code Reference:** `app/Models/Business.php`
|
||||
|
||||
```php
|
||||
// Check if this is a parent company
|
||||
$business->isParentCompany()
|
||||
|
||||
// Get all divisions under this parent
|
||||
$business->divisions()
|
||||
|
||||
// Get parent company (from a division)
|
||||
$business->parent
|
||||
```
|
||||
|
||||
## Department-Based Access Control
|
||||
|
||||
### Core Principle
|
||||
|
||||
**Users only see data related to their assigned department(s).**
|
||||
|
||||
This means:
|
||||
- Manufacturing users see only manufacturing batches, wash reports, and conversions
|
||||
- Sales users see only sales orders, customers, and invoices
|
||||
- Compliance users see only compliance tracking and lab results
|
||||
- Fleet users see only drivers, vehicles, and delivery routes
|
||||
|
||||
### User-Department Assignments
|
||||
|
||||
**Database:** `department_user` pivot table
|
||||
|
||||
```
|
||||
department_user
|
||||
├── department_id (Foreign Key → departments)
|
||||
├── user_id (Foreign Key → users)
|
||||
├── is_admin (Boolean) - Department administrator flag
|
||||
└── timestamps
|
||||
```
|
||||
|
||||
**A user can be assigned to multiple departments:**
|
||||
- John works in both Manufacturing AND Compliance
|
||||
- Sarah is Sales admin for Division A and Division B
|
||||
- Mike is Executive (sees all departments)
|
||||
|
||||
### How Data Filtering Works
|
||||
|
||||
**File:** `app/Http/Controllers/DashboardController.php:416-424`
|
||||
|
||||
```php
|
||||
// Step 1: Get user's assigned departments
|
||||
$userDepartments = auth()->user()->departments()
|
||||
->where('business_id', $business->id)
|
||||
->pluck('departments.id');
|
||||
|
||||
// Step 2: Find operators in those departments
|
||||
$allowedOperatorIds = User::whereHas('departments', function($q) use ($userDepartments) {
|
||||
$q->whereIn('departments.id', $userDepartments);
|
||||
})->pluck('id');
|
||||
|
||||
// Step 3: Filter data by those operators
|
||||
$activeWashes = Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'in_progress')
|
||||
->whereIn('operator_user_id', $allowedOperatorIds) // ← Department filtering
|
||||
->get();
|
||||
```
|
||||
|
||||
**Result:** Manufacturing users only see wash reports from other manufacturing users.
|
||||
|
||||
### Department Isolation Examples
|
||||
|
||||
#### Scenario 1: Manufacturing User
|
||||
**User:** Mike (Department: Manufacturing)
|
||||
**Can See:**
|
||||
- ✅ Batches created by manufacturing team
|
||||
- ✅ Wash reports from manufacturing operators
|
||||
- ✅ Work orders assigned to manufacturing
|
||||
- ✅ Purchase orders for manufacturing materials
|
||||
- ❌ Sales orders (Sales department)
|
||||
- ❌ Fleet deliveries (Fleet department)
|
||||
|
||||
#### Scenario 2: Sales User
|
||||
**User:** Sarah (Department: Sales)
|
||||
**Can See:**
|
||||
- ✅ Customer orders and invoices
|
||||
- ✅ Product catalog
|
||||
- ✅ Sales reports and analytics
|
||||
- ❌ Manufacturing batches (Manufacturing department)
|
||||
- ❌ Compliance tracking (Compliance department)
|
||||
|
||||
#### Scenario 3: Multi-Department User
|
||||
**User:** John (Departments: Manufacturing + Compliance)
|
||||
**Can See:**
|
||||
- ✅ Manufacturing batches and wash reports
|
||||
- ✅ Compliance tracking and lab results
|
||||
- ✅ COA (Certificate of Analysis) for batches
|
||||
- ✅ Quarantine holds and releases
|
||||
- ❌ Sales orders (not in Sales department)
|
||||
- ❌ Fleet operations (not in Fleet department)
|
||||
|
||||
## Executive Access Override
|
||||
|
||||
### Who Are Executives?
|
||||
|
||||
**Executives** are users with special permissions to view ALL data across ALL departments and divisions.
|
||||
|
||||
**Common Executive Roles:**
|
||||
- CEO / Owner
|
||||
- CFO / Finance Director
|
||||
- COO / Operations Director
|
||||
- Corporate Administrator
|
||||
|
||||
### Executive Permissions
|
||||
|
||||
**File:** `app/Http/Controllers/DashboardController.php:408-411`
|
||||
|
||||
```php
|
||||
// Executives bypass department filtering
|
||||
if (auth()->user()->hasRole('executive')) {
|
||||
// Get ALL departments in the business
|
||||
$userDepartments = $business->departments->pluck('id');
|
||||
}
|
||||
```
|
||||
|
||||
**What This Means:**
|
||||
- Executives see data from Manufacturing, Sales, Compliance, and ALL other departments
|
||||
- Executives can access Executive Dashboard with consolidated metrics
|
||||
- Executives can view corporate settings for all divisions
|
||||
|
||||
### Executive-Only Features
|
||||
|
||||
**1. Executive Dashboard**
|
||||
- **Route:** `/s/{business}/executive/dashboard`
|
||||
- **Controller:** `app/Http/Controllers/Seller/ExecutiveDashboardController.php`
|
||||
- **View:** `resources/views/seller/executive/dashboard.blade.php`
|
||||
|
||||
**Shows:**
|
||||
- Consolidated metrics across ALL divisions
|
||||
- Division-by-division performance comparison
|
||||
- Corporate-wide production analytics
|
||||
- Cross-division resource utilization
|
||||
|
||||
**2. Corporate Settings**
|
||||
- **Controller:** `app/Http/Controllers/Seller/CorporateSettingsController.php`
|
||||
- **View:** `resources/views/seller/corporate/divisions.blade.php`
|
||||
|
||||
**Manage:**
|
||||
- Division list and configuration
|
||||
- Corporate resource allocation
|
||||
- Cross-division policies
|
||||
|
||||
**3. Consolidated Analytics**
|
||||
- **Controller:** `app/Http/Controllers/Seller/ConsolidatedAnalyticsController.php`
|
||||
|
||||
**Reports:**
|
||||
- Total production across all manufacturing divisions
|
||||
- Combined sales performance
|
||||
- Corporate inventory levels
|
||||
- Cross-division compliance status
|
||||
|
||||
## Permission Levels
|
||||
|
||||
### 1. Regular User (Department-Scoped)
|
||||
**Access:** Only data from assigned department(s)
|
||||
**Example:** Manufacturing operator sees only manufacturing batches
|
||||
|
||||
### 2. Department Administrator
|
||||
**Access:** All data in department + department management
|
||||
**Example:** Manufacturing Manager can assign users to Manufacturing department
|
||||
|
||||
**Flag:** `department_user.is_admin = true`
|
||||
|
||||
```php
|
||||
// Check if user is department admin
|
||||
$user->departments()
|
||||
->where('department_id', $departmentId)
|
||||
->wherePivot('is_admin', true)
|
||||
->exists();
|
||||
```
|
||||
|
||||
### 3. Division Owner
|
||||
**Access:** All departments within their division
|
||||
**Example:** "Hash Factory AZ" owner sees Manufacturing, Sales, Compliance for Hash Factory AZ only
|
||||
|
||||
**Implementation:**
|
||||
```php
|
||||
// Division owners have 'owner' role scoped to their business
|
||||
if ($user->hasRole('owner') && $user->business_id === $business->id) {
|
||||
// See all departments in this division
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Corporate Executive
|
||||
**Access:** All divisions + all departments + corporate features
|
||||
**Example:** Canopy AZ Group CEO sees everything across Hash Factory AZ, Leopard AZ, and Canopy Retail
|
||||
|
||||
**Implementation:**
|
||||
```php
|
||||
// Corporate executives have 'executive' role at parent company level
|
||||
if ($user->hasRole('executive') && $business->isParentCompany()) {
|
||||
// Access to all divisions and departments
|
||||
}
|
||||
```
|
||||
|
||||
## Shared Departments Across Divisions
|
||||
|
||||
### Use Case: Shared Manufacturing Facility
|
||||
|
||||
**Scenario:** Hash Factory AZ and Leopard AZ share the same physical manufacturing facility with the same equipment and operators.
|
||||
|
||||
**Solution:** Users can be assigned to departments in MULTIPLE divisions.
|
||||
|
||||
**Example:**
|
||||
|
||||
**User:** Carlos (Manufacturing Operator)
|
||||
**Department Assignments:**
|
||||
- Hash Factory AZ → Manufacturing Department
|
||||
- Leopard AZ → Manufacturing Department
|
||||
|
||||
**Result:**
|
||||
- Carlos sees batches from both Hash Factory AZ and Leopard AZ
|
||||
- Carlos can create wash reports for either division
|
||||
- Dashboard shows combined data from both divisions
|
||||
|
||||
**Database:**
|
||||
```
|
||||
department_user table
|
||||
├── id: 1, department_id: 5 (Hash Factory Manufacturing), user_id: 10 (Carlos)
|
||||
└── id: 2, department_id: 12 (Leopard Manufacturing), user_id: 10 (Carlos)
|
||||
```
|
||||
|
||||
**Code Implementation:**
|
||||
```php
|
||||
// Get all departments across all divisions for this user
|
||||
$userDepartments = auth()->user()->departments()->pluck('departments.id');
|
||||
|
||||
// This returns: [5, 12] - departments from BOTH divisions
|
||||
```
|
||||
|
||||
### Use Case: Corporate Fleet Shared Across Divisions
|
||||
|
||||
**Scenario:** All divisions share the same delivery fleet.
|
||||
|
||||
**Solution:** Create a Fleet department at parent company level, assign drivers to it.
|
||||
|
||||
**Users in Fleet Department:**
|
||||
- See deliveries for all divisions
|
||||
- Manage vehicles shared across divisions
|
||||
- Track routes spanning multiple division locations
|
||||
|
||||
## Data Visibility Rules
|
||||
|
||||
### Rule 1: Business Isolation (Always Enforced)
|
||||
|
||||
**Users can ONLY see data from businesses they have access to.**
|
||||
|
||||
```php
|
||||
// ALWAYS scope by business_id first
|
||||
$query->where('business_id', $business->id);
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- Hash Factory AZ users cannot see Competitor Company's data
|
||||
- Each business is completely isolated
|
||||
|
||||
### Rule 2: Department Filtering (Enforced for Non-Executives)
|
||||
|
||||
**Regular users see only data from their assigned departments.**
|
||||
|
||||
```php
|
||||
if (!auth()->user()->hasRole('executive')) {
|
||||
$query->whereHas('operator.departments', function($q) use ($userDepts) {
|
||||
$q->whereIn('departments.id', $userDepts);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- Manufacturing user sees batches from manufacturing operators only
|
||||
- Sales user sees orders assigned to sales team only
|
||||
|
||||
### Rule 3: Executive Override (Top Priority)
|
||||
|
||||
**Executives bypass department filtering and see ALL data.**
|
||||
|
||||
```php
|
||||
if (auth()->user()->hasRole('executive')) {
|
||||
// No department filtering - see everything
|
||||
$userDepartments = $business->departments->pluck('id');
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- CEO sees manufacturing, sales, compliance, and all other data
|
||||
- CFO sees financial data across all departments
|
||||
|
||||
### Rule 4: Multi-Division Access
|
||||
|
||||
**Users can be assigned to departments in MULTIPLE divisions.**
|
||||
|
||||
```php
|
||||
// User's departments span multiple businesses
|
||||
$userDepartments = auth()->user()->departments()
|
||||
->pluck('departments.id'); // Includes departments from all divisions
|
||||
```
|
||||
|
||||
**Example:**
|
||||
- Shared resource (operator, driver) sees data from all assigned divisions
|
||||
- Corporate admin manages users across multiple divisions
|
||||
|
||||
## Permission System Integration
|
||||
|
||||
### Spatie Permissions
|
||||
|
||||
Cannabrands uses **Spatie Laravel Permission** for role-based access control.
|
||||
|
||||
**Permissions Structure:**
|
||||
- `view-dashboard` - See basic dashboard
|
||||
- `view-batches` - See batch tracking (Manufacturing)
|
||||
- `create-batches` - Create batches (Manufacturing Admin)
|
||||
- `view-orders` - See sales orders (Sales)
|
||||
- `manage-fleet` - Manage vehicles/drivers (Fleet)
|
||||
- `view-executive-dashboard` - Access executive features (Executive)
|
||||
|
||||
### Combining Permissions + Departments
|
||||
|
||||
**Two-Layer Security:**
|
||||
1. **Permission Check:** Does user have permission to access this feature?
|
||||
2. **Department Check:** Is the data from user's department?
|
||||
|
||||
**Example:**
|
||||
```php
|
||||
// Layer 1: Permission check
|
||||
if (!auth()->user()->can('view-batches')) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Layer 2: Department filtering
|
||||
$batches = Batch::where('business_id', $business->id)
|
||||
->whereHas('operator.departments', function($q) use ($userDepartments) {
|
||||
$q->whereIn('departments.id', $userDepartments);
|
||||
})
|
||||
->get();
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- User must have `view-batches` permission AND be in Manufacturing department
|
||||
- Even with permission, they only see batches from their department
|
||||
|
||||
## Navigation & Menu Filtering
|
||||
|
||||
### Department-Based Menu Items
|
||||
|
||||
**File:** `resources/views/components/seller-sidebar.blade.php`
|
||||
|
||||
```blade
|
||||
{{-- Manufacturing Section - Only for Manufacturing department users --}}
|
||||
@if(auth()->user()->departments()->where('code', 'MFG')->exists())
|
||||
<li class="menu-title">Manufacturing</li>
|
||||
<li><a href="/batches">Batches</a></li>
|
||||
<li><a href="/wash-reports">Wash Reports</a></li>
|
||||
<li><a href="/work-orders">Work Orders</a></li>
|
||||
@endif
|
||||
|
||||
{{-- Sales Section - Only for Sales department users --}}
|
||||
@if(auth()->user()->departments()->where('code', 'SALES')->exists())
|
||||
<li class="menu-title">Sales</li>
|
||||
<li><a href="/orders">Orders</a></li>
|
||||
<li><a href="/customers">Customers</a></li>
|
||||
@endif
|
||||
|
||||
{{-- Executive Section - Only for executives --}}
|
||||
@if(auth()->user()->hasRole('executive'))
|
||||
<li class="menu-title">Executive</li>
|
||||
<li><a href="/executive/dashboard">Executive Dashboard</a></li>
|
||||
<li><a href="/corporate/divisions">Manage Divisions</a></li>
|
||||
@endif
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Users only see menu items relevant to their departments
|
||||
- Executives see all sections + executive-only items
|
||||
|
||||
## Common Access Scenarios
|
||||
|
||||
### Scenario 1: New Manufacturing Operator
|
||||
|
||||
**Setup:**
|
||||
1. Create user account
|
||||
2. Assign to "Hash Factory AZ" business
|
||||
3. Assign to "Manufacturing" department
|
||||
4. Give permissions: `view-batches`, `create-wash-reports`
|
||||
|
||||
**Access:**
|
||||
- ✅ Dashboard shows manufacturing metrics
|
||||
- ✅ Can view batches from manufacturing team
|
||||
- ✅ Can create wash reports
|
||||
- ❌ Cannot see sales orders
|
||||
- ❌ Cannot see executive dashboard
|
||||
|
||||
### Scenario 2: Sales Manager for Multiple Divisions
|
||||
|
||||
**Setup:**
|
||||
1. Create user account
|
||||
2. Assign to "Hash Factory AZ" → Sales Department (is_admin = true)
|
||||
3. Assign to "Leopard AZ" → Sales Department (is_admin = true)
|
||||
4. Give permissions: `view-orders`, `manage-customers`, `view-sales-reports`
|
||||
|
||||
**Access:**
|
||||
- ✅ See orders from both Hash Factory AZ and Leopard AZ
|
||||
- ✅ Manage customers across both divisions
|
||||
- ✅ View combined sales reports
|
||||
- ✅ Assign users to Sales department (dept admin)
|
||||
- ❌ Cannot see manufacturing data
|
||||
- ❌ Cannot see executive dashboard
|
||||
|
||||
### Scenario 3: Corporate CFO
|
||||
|
||||
**Setup:**
|
||||
1. Create user account
|
||||
2. Assign to "Canopy AZ Group" (parent company)
|
||||
3. Assign role: `executive`
|
||||
4. Give permissions: `view-executive-dashboard`, `view-financial-reports`, `manage-billing`
|
||||
|
||||
**Access:**
|
||||
- ✅ Executive dashboard with all divisions
|
||||
- ✅ Financial reports across all divisions
|
||||
- ✅ Manufacturing data from all divisions
|
||||
- ✅ Sales data from all divisions
|
||||
- ✅ Compliance data from all divisions
|
||||
- ✅ Corporate settings and division management
|
||||
|
||||
### Scenario 4: Shared Equipment Operator
|
||||
|
||||
**Setup:**
|
||||
1. Create user account
|
||||
2. Assign to "Hash Factory AZ" → Manufacturing Department
|
||||
3. Assign to "Leopard AZ" → Manufacturing Department
|
||||
4. Give permissions: `operate-equipment`, `create-wash-reports`
|
||||
|
||||
**Access:**
|
||||
- ✅ See batches from both divisions
|
||||
- ✅ Create wash reports for either division
|
||||
- ✅ View shared equipment schedule
|
||||
- ❌ Cannot see sales or compliance data
|
||||
- ❌ Cannot manage departments (not admin)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Always Check Business ID First
|
||||
|
||||
```php
|
||||
// CORRECT
|
||||
$batch = Batch::where('business_id', $business->id)
|
||||
->findOrFail($id);
|
||||
|
||||
// WRONG - Allows cross-business access!
|
||||
$batch = Batch::findOrFail($id);
|
||||
if ($batch->business_id !== $business->id) abort(403);
|
||||
```
|
||||
|
||||
### Always Apply Department Filtering
|
||||
|
||||
```php
|
||||
// CORRECT
|
||||
$batches = Batch::where('business_id', $business->id)
|
||||
->whereHas('operator.departments', function($q) use ($depts) {
|
||||
$q->whereIn('departments.id', $depts);
|
||||
})
|
||||
->get();
|
||||
|
||||
// WRONG - Shows all batches in business!
|
||||
$batches = Batch::where('business_id', $business->id)->get();
|
||||
```
|
||||
|
||||
### Check Permissions Before Department Filtering
|
||||
|
||||
```php
|
||||
// CORRECT ORDER
|
||||
if (!$user->can('view-batches')) abort(403);
|
||||
$batches = Batch::forUserDepartments($user)->get();
|
||||
|
||||
// WRONG - Filtered data still requires permission!
|
||||
$batches = Batch::forUserDepartments($user)->get();
|
||||
if (!$user->can('view-batches')) abort(403);
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
When adding new features with department access:
|
||||
|
||||
- [ ] Check business_id isolation first
|
||||
- [ ] Check user permissions (Spatie)
|
||||
- [ ] Apply department filtering for non-executives
|
||||
- [ ] Allow executive override
|
||||
- [ ] Test with multi-department users
|
||||
- [ ] Test with shared department across divisions
|
||||
- [ ] Update navigation to show/hide menu items
|
||||
- [ ] Add department filtering to all queries
|
||||
- [ ] Document which departments can access the feature
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `docs/features/PARENT_COMPANY_SUBDIVISIONS.md` - Technical implementation details
|
||||
- `docs/ROUTE_ISOLATION.md` - Module routing and isolation
|
||||
- `CLAUDE.md` - Common security mistakes to avoid
|
||||
- `app/Models/Business.php` - Parent/division relationships
|
||||
- `app/Models/Department.php` - Department structure
|
||||
- `database/migrations/2025_11_13_020000_add_hierarchy_to_businesses_table.php` - Business hierarchy schema
|
||||
- `database/migrations/2025_11_13_010000_create_departments_table.php` - Department schema
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
**Controllers:**
|
||||
- `app/Http/Controllers/DashboardController.php:408-424` - Department filtering logic
|
||||
- `app/Http/Controllers/Seller/ExecutiveDashboardController.php` - Executive dashboard
|
||||
- `app/Http/Controllers/Seller/CorporateSettingsController.php` - Corporate settings
|
||||
|
||||
**Models:**
|
||||
- `app/Models/User.php` - Department relationships
|
||||
- `app/Models/Department.php` - Department model
|
||||
- `app/Models/Business.php` - Parent/division methods
|
||||
|
||||
**Views:**
|
||||
- `resources/views/components/seller-sidebar.blade.php` - Department-based navigation
|
||||
- `resources/views/layouts/app-with-sidebar.blade.php` - Division name display
|
||||
|
||||
## Questions & Troubleshooting
|
||||
|
||||
**Q: User can't see data they should have access to?**
|
||||
A: Check department assignments in `department_user` table. User must be assigned to the correct department.
|
||||
|
||||
**Q: Executive sees only one department's data?**
|
||||
A: Check role assignment. User needs `executive` role, not just department assignment.
|
||||
|
||||
**Q: Shared operator sees data from only one division?**
|
||||
A: Check `department_user` table. User needs assignments to departments in BOTH divisions.
|
||||
|
||||
**Q: How to give user access to all departments without making them executive?**
|
||||
A: Assign user to ALL departments individually. Executive role bypasses this need.
|
||||
|
||||
**Q: Department admin can't manage department users?**
|
||||
A: Check `department_user.is_admin` flag is set to `true` for that user's assignment.
|
||||
195
MISSING_FILES_REPORT.md
Normal file
195
MISSING_FILES_REPORT.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Missing Files Report - Manufacturing Features Worktree
|
||||
|
||||
**Date:** 2025-11-13
|
||||
**Comparison:** Main repo vs `/home/kelly/git/hub-worktrees/manufacturing-features`
|
||||
|
||||
## Summary
|
||||
|
||||
The main repository contains **significantly more files** than the manufacturing-features worktree. These files represent work from commits:
|
||||
- `2831def` (9:00 PM Nov 13) - Manufacturing module with departments and executive features
|
||||
- `812fb20` (9:39 PM Nov 13) - UI improvements and enhanced dashboard
|
||||
|
||||
---
|
||||
|
||||
## Controllers Missing from Worktree (22 files)
|
||||
|
||||
### Admin Controllers (1):
|
||||
- `app/Http/Controllers/Admin/QuickSwitchController.php`
|
||||
|
||||
### Analytics Controllers (6):
|
||||
- `app/Http/Controllers/Analytics/AnalyticsDashboardController.php`
|
||||
- `app/Http/Controllers/Analytics/BuyerIntelligenceController.php`
|
||||
- `app/Http/Controllers/Analytics/MarketingAnalyticsController.php`
|
||||
- `app/Http/Controllers/Analytics/ProductAnalyticsController.php`
|
||||
- `app/Http/Controllers/Analytics/SalesAnalyticsController.php`
|
||||
- `app/Http/Controllers/Analytics/TrackingController.php`
|
||||
|
||||
### Seller Controllers (13):
|
||||
- `app/Http/Controllers/Seller/BatchController.php`
|
||||
- `app/Http/Controllers/Seller/BrandController.php`
|
||||
- `app/Http/Controllers/Seller/BrandPreviewController.php`
|
||||
- `app/Http/Controllers/Seller/ConsolidatedAnalyticsController.php`
|
||||
- `app/Http/Controllers/Seller/CorporateSettingsController.php`
|
||||
- `app/Http/Controllers/Seller/DashboardController.php`
|
||||
- `app/Http/Controllers/Seller/ExecutiveDashboardController.php`
|
||||
- `app/Http/Controllers/Seller/OrderController.php`
|
||||
- `app/Http/Controllers/Seller/PurchaseOrderController.php`
|
||||
- `app/Http/Controllers/Seller/WashReportController.php`
|
||||
- `app/Http/Controllers/Seller/WorkOrderController.php`
|
||||
|
||||
### Fleet Controllers (2):
|
||||
- `app/Http/Controllers/Seller/Fleet/DriverController.php`
|
||||
- `app/Http/Controllers/Seller/Fleet/VehicleController.php`
|
||||
|
||||
### Marketing Controllers (2):
|
||||
- `app/Http/Controllers/Seller/Marketing/BroadcastController.php`
|
||||
- `app/Http/Controllers/Seller/Marketing/TemplateController.php`
|
||||
|
||||
---
|
||||
|
||||
## Models Missing from Worktree (5 files)
|
||||
|
||||
- `app/Models/ComponentCategory.php`
|
||||
- `app/Models/Conversion.php`
|
||||
- `app/Models/Department.php`
|
||||
- `app/Models/PurchaseOrder.php`
|
||||
- `app/Models/WorkOrder.php`
|
||||
|
||||
---
|
||||
|
||||
## Views Missing from Worktree (30+ files)
|
||||
|
||||
### Admin Views (1):
|
||||
- `resources/views/admin/quick-switch.blade.php`
|
||||
|
||||
### Component Views (3):
|
||||
- `resources/views/components/back-to-admin-button.blade.php`
|
||||
- `resources/views/components/dashboard/strain-performance.blade.php`
|
||||
- `resources/views/components/user-session-info.blade.php`
|
||||
|
||||
### Analytics Views (8):
|
||||
- `resources/views/seller/analytics/buyer-detail.blade.php`
|
||||
- `resources/views/seller/analytics/buyers.blade.php`
|
||||
- `resources/views/seller/analytics/campaign-detail.blade.php`
|
||||
- `resources/views/seller/analytics/dashboard.blade.php`
|
||||
- `resources/views/seller/analytics/marketing.blade.php`
|
||||
- `resources/views/seller/analytics/product-detail.blade.php`
|
||||
- `resources/views/seller/analytics/products.blade.php`
|
||||
- `resources/views/seller/analytics/sales.blade.php`
|
||||
|
||||
### Batch Views (3):
|
||||
- `resources/views/seller/batches/create.blade.php`
|
||||
- `resources/views/seller/batches/edit.blade.php`
|
||||
- `resources/views/seller/batches/index.blade.php`
|
||||
|
||||
### Brand Views (5):
|
||||
- `resources/views/seller/brands/create.blade.php`
|
||||
- `resources/views/seller/brands/edit.blade.php`
|
||||
- `resources/views/seller/brands/index.blade.php`
|
||||
- `resources/views/seller/brands/preview.blade.php`
|
||||
- `resources/views/seller/brands/show.blade.php`
|
||||
|
||||
### Corporate/Executive Views (2):
|
||||
- `resources/views/seller/corporate/divisions.blade.php`
|
||||
- `resources/views/seller/executive/dashboard.blade.php`
|
||||
|
||||
### Marketing Views (8):
|
||||
- `resources/views/seller/marketing/broadcasts/analytics.blade.php`
|
||||
- `resources/views/seller/marketing/broadcasts/create.blade.php`
|
||||
- `resources/views/seller/marketing/broadcasts/index.blade.php`
|
||||
- `resources/views/seller/marketing/broadcasts/show.blade.php`
|
||||
- `resources/views/seller/marketing/templates/create.blade.php`
|
||||
- `resources/views/seller/marketing/templates/edit.blade.php`
|
||||
- `resources/views/seller/marketing/templates/index.blade.php`
|
||||
- `resources/views/seller/marketing/templates/show.blade.php`
|
||||
|
||||
### Purchase Order Views (4):
|
||||
- `resources/views/seller/purchase-orders/create.blade.php`
|
||||
- `resources/views/seller/purchase-orders/edit.blade.php`
|
||||
- `resources/views/seller/purchase-orders/index.blade.php`
|
||||
- `resources/views/seller/purchase-orders/show.blade.php`
|
||||
|
||||
### Wash Report Views (4):
|
||||
- `resources/views/seller/wash-reports/active-dashboard.blade.php`
|
||||
- `resources/views/seller/wash-reports/daily-performance.blade.php`
|
||||
- `resources/views/seller/wash-reports/print.blade.php`
|
||||
- `resources/views/seller/wash-reports/search.blade.php`
|
||||
|
||||
### Work Order Views (5):
|
||||
- `resources/views/seller/work-orders/create.blade.php`
|
||||
- `resources/views/seller/work-orders/edit.blade.php`
|
||||
- `resources/views/seller/work-orders/index.blade.php`
|
||||
- `resources/views/seller/work-orders/my-work-orders.blade.php`
|
||||
- `resources/views/seller/work-orders/show.blade.php`
|
||||
|
||||
---
|
||||
|
||||
## Migrations Missing from Worktree (10 files)
|
||||
|
||||
- `database/migrations/2025_10_29_135618_create_component_categories_table.php`
|
||||
- `database/migrations/2025_11_12_033522_add_role_template_to_business_user_table.php`
|
||||
- `database/migrations/2025_11_12_035044_add_module_flags_to_businesses_table.php`
|
||||
- `database/migrations/2025_11_12_201000_create_conversions_table.php`
|
||||
- `database/migrations/2025_11_12_201100_create_conversion_inputs_table.php`
|
||||
- `database/migrations/2025_11_12_202000_create_purchase_orders_table.php`
|
||||
- `database/migrations/2025_11_13_010000_create_departments_table.php`
|
||||
- `database/migrations/2025_11_13_010100_create_department_user_table.php`
|
||||
- `database/migrations/2025_11_13_010200_create_work_orders_table.php`
|
||||
- `database/migrations/2025_11_13_020000_add_hierarchy_to_businesses_table.php`
|
||||
|
||||
---
|
||||
|
||||
## Seeders Missing from Worktree
|
||||
|
||||
- `database/seeders/CanopyAzBusinessRestructureSeeder.php`
|
||||
- `database/seeders/CanopyAzDepartmentsSeeder.php`
|
||||
- `database/seeders/CompleteManufacturingSeeder.php`
|
||||
- `database/seeders/ManufacturingSampleDataSeeder.php`
|
||||
- `database/seeders/ManufacturingStructureSeeder.php`
|
||||
|
||||
---
|
||||
|
||||
## Documentation Missing from Worktree
|
||||
|
||||
- `EXECUTIVE_ACCESS_GUIDE.md` (root)
|
||||
- `docs/features/PARENT_COMPANY_SUBDIVISIONS.md`
|
||||
|
||||
---
|
||||
|
||||
## Action Items
|
||||
|
||||
To sync the manufacturing-features worktree with main repo:
|
||||
|
||||
```bash
|
||||
cd /home/kelly/git/hub-worktrees/manufacturing-features
|
||||
git fetch origin
|
||||
git merge origin/feature/manufacturing-module
|
||||
```
|
||||
|
||||
Or copy specific files:
|
||||
|
||||
```bash
|
||||
# Copy controllers
|
||||
cp -r /home/kelly/git/hub/app/Http/Controllers/Analytics /home/kelly/git/hub-worktrees/manufacturing-features/app/Http/Controllers/
|
||||
cp /home/kelly/git/hub/app/Http/Controllers/Admin/QuickSwitchController.php /home/kelly/git/hub-worktrees/manufacturing-features/app/Http/Controllers/Admin/
|
||||
|
||||
# Copy models
|
||||
cp /home/kelly/git/hub/app/Models/{ComponentCategory,Conversion,Department,PurchaseOrder,WorkOrder}.php /home/kelly/git/hub-worktrees/manufacturing-features/app/Models/
|
||||
|
||||
# Copy views
|
||||
cp -r /home/kelly/git/hub/resources/views/seller/{analytics,batches,brands,marketing,corporate,executive,purchase-orders,wash-reports,work-orders} /home/kelly/git/hub-worktrees/manufacturing-features/resources/views/seller/
|
||||
|
||||
# Copy migrations
|
||||
cp /home/kelly/git/hub/database/migrations/2025_*.php /home/kelly/git/hub-worktrees/manufacturing-features/database/migrations/
|
||||
|
||||
# Copy seeders
|
||||
cp /home/kelly/git/hub/database/seeders/{CanopyAz*,Complete*,Manufacturing*}.php /home/kelly/git/hub-worktrees/manufacturing-features/database/seeders/
|
||||
|
||||
# Copy docs
|
||||
cp /home/kelly/git/hub/EXECUTIVE_ACCESS_GUIDE.md /home/kelly/git/hub-worktrees/manufacturing-features/
|
||||
cp /home/kelly/git/hub/docs/features/PARENT_COMPANY_SUBDIVISIONS.md /home/kelly/git/hub-worktrees/manufacturing-features/docs/features/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Total Missing Files:** ~80+ files across controllers, models, views, migrations, seeders, and documentation
|
||||
50
Makefile
50
Makefile
@@ -1,31 +1,26 @@
|
||||
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
|
||||
.PHONY: help dev dev-down dev-build dev-shell dev-logs dev-vite k-setup k-dev k-down k-logs k-shell k-artisan k-composer k-vite k-status prod-build prod-up prod-down prod-logs prod-shell prod-vite prod-test prod-test-build prod-test-up prod-test-down prod-test-logs prod-test-shell prod-test-status prod-test-clean migrate test clean install
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# ==================== K8s Variables ====================
|
||||
# K3d cluster must be created with dual volume mounts:
|
||||
# k3d cluster create dev \
|
||||
# --api-port 6443 \
|
||||
# --port "80:80@loadbalancer" \
|
||||
# --port "443:443@loadbalancer" \
|
||||
# --volume /Users/jon/projects/cannabrands/cannabrands_new/.worktrees:/worktrees \
|
||||
# --volume /Users/jon/projects/cannabrands/cannabrands_new:/project-root \
|
||||
# --volume k3d-dev-images:/k3d/images
|
||||
|
||||
# Detect if we're in a worktree or project root
|
||||
GIT_DIR := $(shell git rev-parse --git-dir 2>/dev/null)
|
||||
IS_WORKTREE := $(shell echo "$(GIT_DIR)" | grep -q ".worktrees" && echo "true" || echo "false")
|
||||
|
||||
# Set paths based on location
|
||||
# Find project root (handles both worktree and main repo)
|
||||
ifeq ($(IS_WORKTREE),true)
|
||||
# In a worktree - use worktree-specific path
|
||||
# In a worktree - project root is two levels up
|
||||
PROJECT_ROOT := $(shell cd ../.. && pwd)
|
||||
WORKTREE_NAME := $(shell basename $(CURDIR))
|
||||
K8S_VOLUME_PATH := /worktrees/$(WORKTREE_NAME)
|
||||
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
|
||||
else
|
||||
# In project root - use root path
|
||||
# In project root
|
||||
PROJECT_ROOT := $(shell pwd)
|
||||
WORKTREE_NAME := root
|
||||
K8S_VOLUME_PATH := /project-root
|
||||
HOST_WORKTREE_PATH := $(PROJECT_ROOT)/.worktrees
|
||||
endif
|
||||
|
||||
# Generate namespace from branch name (feat-branch-name)
|
||||
@@ -69,6 +64,28 @@ dev-vite: ## Start Vite dev server (run after 'make dev')
|
||||
./vendor/bin/sail npm run dev
|
||||
|
||||
# ==================== K8s Local Development ====================
|
||||
k-setup: ## One-time setup: Create K3d cluster with auto-detected volume mounts
|
||||
@echo "🔧 Setting up K3d cluster 'dev' with auto-detected paths"
|
||||
@echo " Project Root: $(PROJECT_ROOT)"
|
||||
@echo " Worktrees Path: $(HOST_WORKTREE_PATH)"
|
||||
@echo ""
|
||||
@# Check if cluster already exists
|
||||
@if k3d cluster list | grep -q "^dev "; then \
|
||||
echo "⚠️ Cluster 'dev' already exists!"; \
|
||||
echo " To recreate, run: k3d cluster delete dev && make k-setup"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@# Create cluster with dynamic volume mounts
|
||||
k3d cluster create dev \
|
||||
--api-port 6443 \
|
||||
--port "80:80@loadbalancer" \
|
||||
--port "443:443@loadbalancer" \
|
||||
--volume $(HOST_WORKTREE_PATH):/worktrees \
|
||||
--volume $(PROJECT_ROOT):/project-root
|
||||
@echo ""
|
||||
@echo "✅ K3d cluster created successfully!"
|
||||
@echo " Next step: Run 'make k-dev' to start your environment"
|
||||
|
||||
k-dev: ## Start k8s local environment (like Sail, but with namespace isolation)
|
||||
@echo "🚀 Starting k8s environment"
|
||||
@echo " Location: $(if $(filter true,$(IS_WORKTREE)),Worktree ($(WORKTREE_NAME)),Project Root)"
|
||||
@@ -254,6 +271,13 @@ install: ## Initial project setup
|
||||
@echo " 2. Run 'make dev' to start development environment"
|
||||
@echo " 3. Run 'make migrate' to set up database"
|
||||
|
||||
setup-hooks: ## Configure git hooks for code quality
|
||||
@git config core.hooksPath .githooks
|
||||
@chmod +x .githooks/*
|
||||
@echo "✅ Git hooks configured!"
|
||||
@echo " - pre-commit: Auto-formats code with Laravel Pint"
|
||||
@echo " - pre-push: Optionally runs tests before pushing"
|
||||
|
||||
mailpit: ## Open Mailpit web UI
|
||||
@open http://localhost:8025 || xdg-open http://localhost:8025 || echo "Open http://localhost:8025 in your browser"
|
||||
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
# PRODUCT2 MIGRATION INSTRUCTIONS
|
||||
|
||||
## Context
|
||||
We are migrating the OLD seller product page from `../cannabrands-hub-old` to create a new "Product2" page in the current project at `/hub`. This page will be a comprehensive, modernized version of the old seller product edit page.
|
||||
|
||||
## Critical Rules
|
||||
1. **SELLER SIDE ONLY** - Work only with `/s/` routes (seller area)
|
||||
2. **STAY IN BRANCH** - `feature/product-page-migrate` (verify before making changes)
|
||||
3. **ROLLBACK READY** - All database migrations must be fully reversible
|
||||
4. **DO NOT TOUCH BOM** - Leave existing BOM functionality completely as-is (we'll discuss later)
|
||||
5. **SINGLE PAGE LAYOUT** - No tabs, use card-based layout with Nexus components
|
||||
6. **FOLLOW OLD LAYOUT** - Modernize the old product page structure, don't reinvent
|
||||
|
||||
## Old Project Analysis Complete
|
||||
- Old project location: `../cannabrands-hub-old`
|
||||
- Old used Laravel CRM for product management
|
||||
- Comprehensive field analysis done (see below)
|
||||
- Old layout analyzed from vendor views
|
||||
|
||||
## Complete Missing Fields (from migrations analysis)
|
||||
|
||||
### From `products` table:
|
||||
```sql
|
||||
-- Metadata
|
||||
product_line (text, nullable)
|
||||
product_link (text, nullable) -- External URL
|
||||
creatives (text, nullable) -- Marketing assets
|
||||
barcode (string, nullable)
|
||||
brand_display_order (integer, nullable)
|
||||
|
||||
-- Configuration
|
||||
has_varieties (boolean, default: false)
|
||||
license_id (unsignedBigInteger, nullable)
|
||||
sell_multiples (boolean, default: false)
|
||||
fractional_quantities (boolean, default: false)
|
||||
allow_sample (boolean, default: false)
|
||||
isFPR (boolean, default: false)
|
||||
isSellable (boolean, default: false)
|
||||
|
||||
-- Case/Box Packaging
|
||||
isCase (boolean, default: false)
|
||||
cased_qty (integer, default: 0)
|
||||
isBox (boolean, default: false)
|
||||
boxed_qty (integer, default: 0)
|
||||
|
||||
-- Dates
|
||||
launch_date (date, nullable)
|
||||
|
||||
-- Inventory Management
|
||||
inventory_manage_pct (integer, nullable) -- 0-100%
|
||||
min_order_qty (integer, nullable)
|
||||
max_order_qty (integer, nullable)
|
||||
low_stock_threshold (integer, nullable)
|
||||
low_stock_alert_enabled (boolean, default: false)
|
||||
|
||||
-- Strain
|
||||
strain_value (decimal 8,2, nullable)
|
||||
|
||||
-- Arizona Compliance
|
||||
arz_total_weight (decimal 10,3, nullable)
|
||||
arz_usable_mmj (decimal 10,3, nullable)
|
||||
|
||||
-- Descriptions
|
||||
long_description (text, nullable)
|
||||
ingredients (text, nullable)
|
||||
effects (text, nullable)
|
||||
dosage_guidelines (text, nullable)
|
||||
|
||||
-- Visibility
|
||||
show_inventory_to_buyers (boolean, default: false)
|
||||
|
||||
-- Threshold Automation
|
||||
decreasing_qty_threshold (integer, nullable)
|
||||
decreasing_qty_action (string, nullable)
|
||||
increasing_qty_threshold (integer, nullable)
|
||||
increasing_qty_action (string, nullable)
|
||||
|
||||
-- Packaging Reference
|
||||
packaging_id (foreignId, nullable)
|
||||
|
||||
-- Enhanced Status
|
||||
status (enum: available, archived, sample, backorder, internal, unavailable)
|
||||
```
|
||||
|
||||
### Need to create:
|
||||
- `product_packaging` table (id, name, description, is_active, timestamps)
|
||||
|
||||
## Product2 Page Layout (Single Page, No Tabs)
|
||||
|
||||
### Structure:
|
||||
```
|
||||
HEADER (Product name, SKU, status badges, action buttons)
|
||||
|
||||
LEFT SIDEBAR (1/3 width):
|
||||
- Product Images (main + gallery + upload)
|
||||
- Quick Stats Card (cost, wholesale, MSRP, margin)
|
||||
- Audit Info Card (created, modified, by user)
|
||||
|
||||
MAIN CONTENT (2/3 width):
|
||||
Card 1: Basic Information
|
||||
Card 2: Pricing & Units
|
||||
Card 3: Inventory Management
|
||||
Card 4: Cannabis Information
|
||||
Card 5: Product Details & Content
|
||||
Card 6: Advanced Settings
|
||||
Card 7: Compliance & Tracking
|
||||
|
||||
FULL WIDTH (bottom):
|
||||
Card 8: Product Varieties (if has_varieties = true)
|
||||
Card 9: Lab Test Results (link to separate management)
|
||||
Collapsible: Audit History
|
||||
```
|
||||
|
||||
### Cards Detail:
|
||||
|
||||
**Card 1: Basic Information**
|
||||
- Brand (dropdown) *
|
||||
- Product Line (text)
|
||||
- SKU (text) *
|
||||
- Barcode (text)
|
||||
- Product Name (text) *
|
||||
- Type (dropdown) *
|
||||
- Category (text)
|
||||
- Description (textarea)
|
||||
- Active toggle
|
||||
- Featured toggle
|
||||
|
||||
**Card 2: Pricing & Units**
|
||||
- Cost Price, Wholesale, MSRP, Margin (auto-calc)
|
||||
- Price Unit dropdown
|
||||
- Net Weight + Weight Unit
|
||||
- Units Per Case
|
||||
- Checkboxes: Sell in Multiples, Fractional Quantities, Sell as Case, Sell as Box
|
||||
|
||||
**Card 3: Inventory Management**
|
||||
- On Hand, Allocated, Available, Reorder Point (display)
|
||||
- Min/Max Order Qty
|
||||
- Low Stock Threshold + Alert checkbox
|
||||
- Show Inventory to Buyers checkbox
|
||||
- Inventory Management slider (0-100%)
|
||||
- Threshold Automation (decrease/increase triggers)
|
||||
|
||||
**Card 4: Cannabis Information**
|
||||
- THC%, CBD%, THC mg, CBD mg
|
||||
- Strain dropdown (with classification)
|
||||
- Strain Value
|
||||
- Product Packaging dropdown
|
||||
- Ingredients, Effects, Dosing Guidelines (text areas)
|
||||
- Arizona Compliance (Total Weight, Usable MMJ)
|
||||
|
||||
**Card 5: Product Details & Content**
|
||||
- Short Description
|
||||
- Long Description (rich text editor)
|
||||
- Product Link (external URL)
|
||||
- Creatives/Assets
|
||||
|
||||
**Card 6: Advanced Settings**
|
||||
- Enable Sample Requests checkbox
|
||||
- Sellable Product checkbox
|
||||
- Finished Product Ready checkbox
|
||||
- Status dropdown
|
||||
- Display Order (within brand)
|
||||
|
||||
**Card 7: Compliance & Tracking**
|
||||
- Metrc ID
|
||||
- License dropdown
|
||||
- Launch Date, Harvest Date, Package Date, Test Date
|
||||
|
||||
**Card 8: Product Varieties** (conditional)
|
||||
- Table showing child products with name, SKU, prices, stock
|
||||
- Add Variety button
|
||||
|
||||
**Card 9: Lab Test Results**
|
||||
- Summary of latest lab test
|
||||
- Link to full lab management (don't build lab CRUD yet)
|
||||
|
||||
## Tasks to Complete
|
||||
|
||||
### 1. Database Migration (with rollback)
|
||||
- Create migration: `add_product2_fields_to_products_table.php`
|
||||
- Add ALL missing fields listed above
|
||||
- Proper indexes
|
||||
- Full `down()` method for rollback
|
||||
- Create `product_packaging` table migration
|
||||
|
||||
### 2. Routes
|
||||
- File: `routes/seller.php`
|
||||
- Add under existing products routes:
|
||||
- `/{product}/edit2` → Product2 edit page
|
||||
- Keep existing routes intact
|
||||
|
||||
### 3. Controller
|
||||
- Create: `app/Http/Controllers/Seller/Product2Controller.php`
|
||||
- Methods: edit(), update()
|
||||
- Full validation for all new fields
|
||||
- Business isolation checks (CRITICAL - see CLAUDE.md)
|
||||
- Image upload handling
|
||||
|
||||
### 4. Model Updates
|
||||
- Update `app/Models/Product.php` fillable array
|
||||
- Add new relationships if needed (packaging)
|
||||
- Add accessors/mutators as needed
|
||||
|
||||
### 5. Views
|
||||
- Create: `resources/views/seller/products/edit2.blade.php`
|
||||
- Use Nexus card components
|
||||
- Single page layout (no tabs)
|
||||
- Alpine.js for interactivity
|
||||
- Follow structure outlined above
|
||||
- Use existing DaisyUI + Nexus patterns
|
||||
|
||||
### 6. Nexus Components Available
|
||||
From `nexus-html@3.1.0/resources/views/`:
|
||||
- Cards: `card`, `card-body`, `card-title`
|
||||
- Forms: `input`, `select`, `textarea`, `checkbox`, `toggle`, `label`, `fieldset`
|
||||
- Layouts: Grid system with responsive columns
|
||||
- File upload: FilePond integration
|
||||
- Date picker: Flatpickr
|
||||
- Icons: Iconify (lucide set)
|
||||
|
||||
## Key Files from Old Project
|
||||
- Controller: `vendor/venturedrake/laravel-crm/src/Http/Controllers/ProductController.php`
|
||||
- Edit View: `vendor/venturedrake/laravel-crm/resources/views/products/edit.blade.php`
|
||||
- Fields Form: `vendor/venturedrake/laravel-crm/resources/views/products/partials/fields.blade.php` (1400+ lines!)
|
||||
|
||||
## Current Project Files
|
||||
- Routes: `routes/seller.php`
|
||||
- Controller: `app/Http/Controllers/Seller/ProductController.php`
|
||||
- Model: `app/Models/Product.php`
|
||||
- Current Edit: `resources/views/seller/products/edit.blade.php`
|
||||
- Migration: `database/migrations/2025_10_07_172951_create_products_table.php`
|
||||
|
||||
## Important Notes from CLAUDE.md
|
||||
1. **Business Isolation**: ALWAYS scope by business_id BEFORE finding by ID
|
||||
- `Product::whereHas('brand', fn($q) => $q->where('business_id', $business->id))->findOrFail($id)`
|
||||
2. **Route Protection**: Use middleware `['auth', 'verified', 'seller', 'approved']`
|
||||
3. **No Filament**: Use DaisyUI + Blade for seller area
|
||||
4. **Run tests before commit**: `php artisan test --parallel && ./vendor/bin/pint`
|
||||
|
||||
## Git Branch
|
||||
- Current: `feature/product-page-migrate`
|
||||
- DO NOT commit to develop directly
|
||||
|
||||
## Next Steps
|
||||
1. Verify branch: `git branch` (should show feature/product-page-migrate)
|
||||
2. Create migrations with full rollback capability
|
||||
3. Update Product model
|
||||
4. Create Product2Controller
|
||||
5. Create edit2.blade.php view
|
||||
6. Test thoroughly
|
||||
7. Run Pint + tests
|
||||
8. Commit with clear message
|
||||
|
||||
## Questions to Clarify Before Building
|
||||
- Collapsible cards to reduce clutter? (yes/no)
|
||||
- Should quantity_on_hand be editable in UI? (currently hidden)
|
||||
- Which fields are absolutely required vs nice-to-have?
|
||||
- SQL dump ready for real data analysis?
|
||||
336
PUSH_NOTIFICATIONS_SETUP.md
Normal file
336
PUSH_NOTIFICATIONS_SETUP.md
Normal file
@@ -0,0 +1,336 @@
|
||||
# Push Notifications & Laravel Horizon Setup
|
||||
|
||||
## Overview
|
||||
This feature adds browser push notifications for high-intent buyer signals as part of the Premium Buyer Analytics module.
|
||||
|
||||
## Prerequisites
|
||||
- HTTPS (production/staging) or localhost (development)
|
||||
- Browser that supports Web Push API (Chrome, Firefox, Edge, Safari 16+)
|
||||
|
||||
---
|
||||
|
||||
## Installation Steps
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
composer update
|
||||
```
|
||||
|
||||
This will install:
|
||||
- `laravel-notification-channels/webpush: ^10.2` - Web push notifications
|
||||
- `laravel/horizon: ^5.39` - Queue management dashboard
|
||||
|
||||
### 2. Install Horizon Assets
|
||||
|
||||
```bash
|
||||
php artisan horizon:install
|
||||
```
|
||||
|
||||
This publishes Horizon's dashboard assets to `public/vendor/horizon`.
|
||||
|
||||
### 3. Run Database Migrations
|
||||
|
||||
```bash
|
||||
php artisan migrate
|
||||
```
|
||||
|
||||
This creates:
|
||||
- `push_subscriptions` table - Stores browser push subscriptions
|
||||
|
||||
### 4. Generate VAPID Keys
|
||||
|
||||
```bash
|
||||
php artisan webpush:vapid
|
||||
```
|
||||
|
||||
This generates VAPID keys for web push authentication and adds them to your `.env`:
|
||||
```
|
||||
VAPID_PUBLIC_KEY=...
|
||||
VAPID_PRIVATE_KEY=...
|
||||
```
|
||||
|
||||
**⚠️ IMPORTANT**:
|
||||
- Never commit VAPID keys to git
|
||||
- Generate different keys for each environment (local, staging, production)
|
||||
- Keys are environment-specific and can't be shared between environments
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### 1. Register HorizonServiceProvider
|
||||
|
||||
Add to `bootstrap/providers.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class, // Add this line
|
||||
];
|
||||
```
|
||||
|
||||
### 2. Register Event Listener
|
||||
|
||||
In `app/Providers/AppServiceProvider.php` boot method:
|
||||
|
||||
```php
|
||||
use App\Events\HighIntentBuyerDetected;
|
||||
use App\Listeners\Analytics\SendHighIntentSignalPushNotification;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(
|
||||
HighIntentBuyerDetected::class,
|
||||
SendHighIntentSignalPushNotification::class
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Environment Variables
|
||||
|
||||
Ensure these are in your `.env`:
|
||||
|
||||
```env
|
||||
# Queue Configuration
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Horizon Configuration
|
||||
HORIZON_DOMAIN=your-domain.com
|
||||
HORIZON_PATH=horizon
|
||||
|
||||
# Web Push (generated by webpush:vapid)
|
||||
VAPID_PUBLIC_KEY=your-public-key
|
||||
VAPID_PRIVATE_KEY=your-private-key
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### 1. Start Required Services
|
||||
|
||||
```bash
|
||||
# Start Laravel Sail (includes Redis)
|
||||
./vendor/bin/sail up -d
|
||||
|
||||
# OR if using local Redis
|
||||
redis-server
|
||||
```
|
||||
|
||||
### 2. Start Horizon Queue Worker
|
||||
|
||||
```bash
|
||||
php artisan horizon
|
||||
|
||||
# OR with Sail
|
||||
./vendor/bin/sail artisan horizon
|
||||
```
|
||||
|
||||
**⚠️ Horizon must be running** for push notifications to be sent!
|
||||
|
||||
### 3. Seed Test Data (Local Only)
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=PushNotificationTestDataSeeder
|
||||
```
|
||||
|
||||
This creates:
|
||||
- ✅ 5 repeated product views
|
||||
- ✅ High engagement buyer score (95%)
|
||||
- ✅ 4 intent signals (various types)
|
||||
- ✅ Test notification event
|
||||
|
||||
### 4. Access Dashboards
|
||||
|
||||
- **Horizon Dashboard**: http://localhost/horizon
|
||||
- Monitor queued jobs
|
||||
- View failed jobs
|
||||
- See job metrics
|
||||
|
||||
- **Analytics Dashboard**: http://localhost/s/cannabrands/buyer-intelligence/buyers
|
||||
- View buyer engagement scores
|
||||
- See intent signals
|
||||
- Test push notifications
|
||||
|
||||
---
|
||||
|
||||
## Testing Push Notifications
|
||||
|
||||
### Browser Setup
|
||||
|
||||
1. **Navigate to your site** (must be HTTPS or localhost)
|
||||
2. **Grant notification permission** when prompted
|
||||
3. Browser will create a push subscription automatically
|
||||
|
||||
### Trigger Test Notification
|
||||
|
||||
Option 1: Use the seeder (creates test event):
|
||||
```bash
|
||||
php artisan db:seed --class=PushNotificationTestDataSeeder
|
||||
```
|
||||
|
||||
Option 2: Manually trigger via Tinker:
|
||||
```bash
|
||||
php artisan tinker
|
||||
```
|
||||
|
||||
```php
|
||||
use App\Events\HighIntentBuyerDetected;
|
||||
|
||||
event(new HighIntentBuyerDetected(
|
||||
sellerBusinessId: 1,
|
||||
buyerBusinessId: 2,
|
||||
signalType: 'high_engagement',
|
||||
signalStrength: 'very_high',
|
||||
metadata: ['engagement_score' => 95]
|
||||
));
|
||||
```
|
||||
|
||||
### Verify Notification Delivery
|
||||
|
||||
1. Check Horizon dashboard: `/horizon` - Job should show as processed
|
||||
2. Check browser - Should receive push notification
|
||||
3. Check Laravel logs: `storage/logs/laravel.log`
|
||||
|
||||
---
|
||||
|
||||
## Production/Staging Deployment
|
||||
|
||||
### Deployment Checklist
|
||||
|
||||
1. ✅ Run `composer install --no-dev --optimize-autoloader`
|
||||
2. ✅ Run `php artisan horizon:install`
|
||||
3. ✅ Run `php artisan migrate --force`
|
||||
4. ✅ Run `php artisan webpush:vapid` (generates environment-specific keys)
|
||||
5. ✅ Configure supervisor to keep Horizon running
|
||||
6. ✅ Set `HORIZON_DOMAIN` in `.env`
|
||||
7. ✅ **DO NOT** run test data seeder
|
||||
|
||||
### Supervisor Configuration
|
||||
|
||||
Create `/etc/supervisor/conf.d/horizon.conf`:
|
||||
|
||||
```ini
|
||||
[program:horizon]
|
||||
process_name=%(program_name)s
|
||||
command=php /path/to/artisan horizon
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www-data
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/path/to/storage/logs/horizon.log
|
||||
stopwaitsecs=3600
|
||||
```
|
||||
|
||||
Then:
|
||||
```bash
|
||||
sudo supervisorctl reread
|
||||
sudo supervisorctl update
|
||||
sudo supervisorctl start horizon
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Notifications Not Sending
|
||||
|
||||
1. **Check Horizon is running**: Visit `/horizon` dashboard
|
||||
2. **Check queue connection**: `php artisan queue:monitor`
|
||||
3. **Check Redis**: `redis-cli ping` (should return PONG)
|
||||
4. **Check logs**: `tail -f storage/logs/laravel.log`
|
||||
|
||||
### VAPID Key Issues
|
||||
|
||||
```bash
|
||||
# Regenerate keys
|
||||
php artisan webpush:vapid --force
|
||||
|
||||
# Then restart Horizon
|
||||
php artisan horizon:terminate
|
||||
php artisan horizon
|
||||
```
|
||||
|
||||
### Browser Not Receiving Notifications
|
||||
|
||||
1. Check browser permissions: Allow notifications for your site
|
||||
2. Check HTTPS: Must be HTTPS or localhost
|
||||
3. Check subscription exists: `SELECT * FROM push_subscriptions;`
|
||||
4. Check browser console for errors
|
||||
|
||||
---
|
||||
|
||||
## What Triggers Push Notifications
|
||||
|
||||
Notifications are automatically sent when:
|
||||
|
||||
| Trigger | Threshold | Signal Type |
|
||||
|---------|-----------|-------------|
|
||||
| Repeated product views | 3+ views | `repeated_view` |
|
||||
| High engagement score | ≥ 60% | `high_engagement` |
|
||||
| Spec download | Any | `spec_download` |
|
||||
| Contact button click | Any | `contact_click` |
|
||||
|
||||
All triggers require `has_analytics = true` on the business.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User Action (e.g., views product 3x)
|
||||
↓
|
||||
Analytics Tracking System
|
||||
↓
|
||||
CalculateEngagementScore Job
|
||||
↓
|
||||
HighIntentBuyerDetected Event fired
|
||||
↓
|
||||
SendHighIntentSignalPushNotification Listener (queued)
|
||||
↓
|
||||
Horizon Queue Processing
|
||||
↓
|
||||
Push Notification Sent to Browser
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Added
|
||||
|
||||
- `app/Notifications/Analytics/HighIntentSignalNotification.php`
|
||||
- `app/Models/Analytics/PushSubscription.php`
|
||||
- `app/Listeners/Analytics/SendHighIntentSignalPushNotification.php`
|
||||
- `app/Providers/HorizonServiceProvider.php`
|
||||
- `database/migrations/2025_11_09_003106_create_push_subscriptions_table.php`
|
||||
- `database/seeders/PushNotificationTestDataSeeder.php` (test data only)
|
||||
- `config/webpush.php`
|
||||
- `config/horizon.php`
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- ✅ Push notifications only sent to users with permission
|
||||
- ✅ VAPID keys are environment-specific
|
||||
- ✅ Subscriptions tied to user accounts
|
||||
- ✅ All triggers respect `has_analytics` module flag
|
||||
- ⚠️ Never commit VAPID keys to version control
|
||||
- ⚠️ Never run test seeders in production
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
- **Laravel Horizon Docs**: https://laravel.com/docs/horizon
|
||||
- **Web Push Package**: https://github.com/laravel-notification-channels/webpush
|
||||
- **Web Push Protocol**: https://web.dev/push-notifications/
|
||||
501
QUICK-HANDOFF-CLAUDE-CODE.md
Normal file
501
QUICK-HANDOFF-CLAUDE-CODE.md
Normal file
@@ -0,0 +1,501 @@
|
||||
# Analytics Implementation - Quick Handoff for Claude Code
|
||||
|
||||
## 🎯 Implementation Guide Location
|
||||
|
||||
**Main Technical Guide:** `/mnt/user-data/outputs/analytics-implementation-guide-REVISED.md`
|
||||
|
||||
This is a **REVISED** implementation that matches your ACTUAL Cannabrands architecture.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ CRITICAL ARCHITECTURAL DIFFERENCES
|
||||
|
||||
Your setup is different from typical Laravel multi-tenant apps:
|
||||
|
||||
### 1. **business_id is bigInteger (not UUID)**
|
||||
```php
|
||||
// Migration
|
||||
$table->unsignedBigInteger('business_id')->index();
|
||||
$table->foreign('business_id')->references('id')->on('businesses');
|
||||
|
||||
// NOT UUID like:
|
||||
$table->uuid('tenant_id');
|
||||
```
|
||||
|
||||
### 2. **NO Global Scopes - Explicit Scoping Pattern**
|
||||
```php
|
||||
// ❌ WRONG - Security vulnerability!
|
||||
ProductView::findOrFail($id)
|
||||
|
||||
// ✅ RIGHT - Your pattern
|
||||
ProductView::where('business_id', $business->id)->findOrFail($id)
|
||||
|
||||
// All queries MUST explicitly scope by business_id
|
||||
```
|
||||
|
||||
### 3. **Permissions in business_user.permissions JSON Column**
|
||||
```php
|
||||
// NOT using Spatie permission routes yet
|
||||
// Permissions stored in: business_user pivot table
|
||||
// Column: 'permissions' => 'array' (JSON)
|
||||
|
||||
// Check permissions via helper:
|
||||
hasBusinessPermission('analytics.overview')
|
||||
|
||||
// NOT via:
|
||||
auth()->user()->can('analytics.overview') // ❌ Don't use this yet
|
||||
```
|
||||
|
||||
### 4. **Multi-Business Users**
|
||||
```php
|
||||
// Users can belong to MULTIPLE businesses
|
||||
auth()->user()->businesses // BelongsToMany
|
||||
|
||||
// Get current business:
|
||||
auth()->user()->primaryBusiness()
|
||||
|
||||
// Or use helper:
|
||||
currentBusiness()
|
||||
currentBusinessId()
|
||||
```
|
||||
|
||||
### 5. **Products → Brand → Business Hierarchy**
|
||||
```php
|
||||
// Products DON'T have direct business_id
|
||||
// They go through Brand:
|
||||
$product->brand->business_id
|
||||
|
||||
// For tracking product views, get seller's business:
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
```
|
||||
|
||||
### 6. **User Types via Middleware**
|
||||
```php
|
||||
// Routes use user_type middleware:
|
||||
Route::middleware(['auth', 'verified', 'buyer']) // Buyers
|
||||
Route::middleware(['auth', 'verified', 'seller']) // Sellers
|
||||
Route::middleware(['auth', 'admin']) // Admins
|
||||
|
||||
// user_type values:
|
||||
'buyer' => 'Buyer/Retailer'
|
||||
'seller' => 'Seller/Brand'
|
||||
'admin' => 'Super Admin'
|
||||
```
|
||||
|
||||
### 7. **Reverb IS Configured (Horizon is NOT)**
|
||||
```php
|
||||
// ✅ Use Reverb for real-time updates
|
||||
use App\Events\Analytics\HighIntentBuyerDetected;
|
||||
event(new HighIntentBuyerDetected(...));
|
||||
|
||||
// ✅ Use Redis queues (already available)
|
||||
CalculateEngagementScore::dispatch()->onQueue('analytics');
|
||||
|
||||
// ❌ Don't install Horizon (not needed yet)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 WHAT YOU'RE BUILDING
|
||||
|
||||
### Database Tables (7 migrations):
|
||||
1. `analytics_events` - Raw event stream
|
||||
2. `product_views` - Product engagement tracking
|
||||
3. `email_campaigns` + `email_interactions` + `email_clicks` - Email tracking
|
||||
4. `click_tracking` - General click events
|
||||
5. `user_sessions` + `intent_signals` - Session & intent tracking
|
||||
6. `buyer_engagement_scores` - Calculated buyer scores
|
||||
7. `jobs` table for Redis queues
|
||||
|
||||
**Key Field:** Every table has `business_id` (bigInteger) with proper indexing
|
||||
|
||||
### Backend Components:
|
||||
- **Helper Functions:** `currentBusiness()`, `hasBusinessPermission()`
|
||||
- **AnalyticsTracker Service:** Main tracking service
|
||||
- **Queue Jobs:** Async engagement score calculations
|
||||
- **Events:** Reverb broadcasting for real-time updates
|
||||
- **Controllers:** Dashboard, Products, Marketing, Sales, Buyers
|
||||
- **Models:** 10 analytics models with explicit business scoping
|
||||
|
||||
### Frontend:
|
||||
- Permission management UI in existing business/users section
|
||||
- Analytics navigation (new top-level section)
|
||||
- Dashboard views with KPIs and charts
|
||||
- Real-time notifications via Reverb
|
||||
|
||||
---
|
||||
|
||||
## 🔐 SECURITY PATTERN (CRITICAL!)
|
||||
|
||||
**EVERY query MUST scope by business_id:**
|
||||
|
||||
```php
|
||||
// ❌ NEVER do this - data leakage!
|
||||
AnalyticsEvent::find($id)
|
||||
ProductView::where('product_id', $productId)->get()
|
||||
|
||||
// ✅ ALWAYS do this - business isolated
|
||||
AnalyticsEvent::where('business_id', $business->id)->find($id)
|
||||
ProductView::where('business_id', $business->id)
|
||||
->where('product_id', $productId)
|
||||
->get()
|
||||
|
||||
// ✅ Or use scope helper in models
|
||||
ProductView::forBusiness($business->id)->get()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 IMPLEMENTATION STEPS
|
||||
|
||||
### 1. Create Helper Files First
|
||||
```bash
|
||||
# Create helpers
|
||||
mkdir -p app/Helpers
|
||||
# Copy BusinessHelper.php
|
||||
# Copy helpers.php
|
||||
# Update composer.json autoload.files
|
||||
composer dump-autoload
|
||||
```
|
||||
|
||||
### 2. Run Migrations
|
||||
```bash
|
||||
# Copy all 7 migration files
|
||||
php artisan migrate
|
||||
|
||||
# Verify tables created
|
||||
php artisan tinker
|
||||
>>> DB::table('analytics_events')->count()
|
||||
>>> DB::table('product_views')->count()
|
||||
```
|
||||
|
||||
### 3. Create Models
|
||||
```bash
|
||||
mkdir -p app/Models/Analytics
|
||||
# Copy all model files (10 models)
|
||||
# Each model has explicit business scoping
|
||||
```
|
||||
|
||||
### 4. Create Services
|
||||
```bash
|
||||
mkdir -p app/Services/Analytics
|
||||
# Copy AnalyticsTracker service
|
||||
```
|
||||
|
||||
### 5. Create Jobs
|
||||
```bash
|
||||
mkdir -p app/Jobs/Analytics
|
||||
# Copy CalculateEngagementScore job
|
||||
```
|
||||
|
||||
### 6. Create Events
|
||||
```bash
|
||||
mkdir -p app/Events/Analytics
|
||||
# Copy HighIntentBuyerDetected event
|
||||
# Update routes/channels.php for broadcasting
|
||||
```
|
||||
|
||||
### 7. Create Controllers
|
||||
```bash
|
||||
mkdir -p app/Http/Controllers/Analytics
|
||||
# Copy all controller files
|
||||
```
|
||||
|
||||
### 8. Add Routes
|
||||
```bash
|
||||
# Update routes/web.php with analytics routes
|
||||
# Use existing middleware patterns (auth, verified)
|
||||
```
|
||||
|
||||
### 9. Update UI
|
||||
```bash
|
||||
# Add analytics navigation section
|
||||
# Add permission management tile to business/users
|
||||
# Create analytics dashboard views
|
||||
```
|
||||
|
||||
### 10. Configure Queues
|
||||
```bash
|
||||
# Start queue worker
|
||||
php artisan queue:work --queue=analytics
|
||||
|
||||
# (Reverb should already be running)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 TRACKING EXAMPLES
|
||||
|
||||
### Track Product View
|
||||
```php
|
||||
use App\Services\Analytics\AnalyticsTracker;
|
||||
|
||||
public function show(Product $product, Request $request)
|
||||
{
|
||||
$tracker = new AnalyticsTracker($request);
|
||||
$view = $tracker->trackProductView($product);
|
||||
|
||||
// Queue engagement score calculation if buyer
|
||||
if ($view && $view->buyer_business_id) {
|
||||
\App\Jobs\Analytics\CalculateEngagementScore::dispatch(
|
||||
$view->business_id,
|
||||
$view->buyer_business_id
|
||||
);
|
||||
}
|
||||
|
||||
return view('products.show', compact('product'));
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript Click Tracking
|
||||
```javascript
|
||||
// Add to your main JS
|
||||
document.addEventListener('click', function(e) {
|
||||
const trackable = e.target.closest('[data-track-click]');
|
||||
if (trackable) {
|
||||
fetch('/api/analytics/track-click', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
|
||||
},
|
||||
body: JSON.stringify({
|
||||
element_type: trackable.dataset.trackClick,
|
||||
element_id: trackable.dataset.trackId
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### HTML Usage
|
||||
```blade
|
||||
<a href="{{ route('products.show', $product) }}"
|
||||
data-track-click="product_link"
|
||||
data-track-id="{{ $product->id }}">
|
||||
{{ $product->name }}
|
||||
</a>
|
||||
```
|
||||
|
||||
### Real-Time Notifications
|
||||
```javascript
|
||||
// In analytics dashboard
|
||||
const businessId = {{ $business->id }};
|
||||
|
||||
Echo.channel('analytics.business.' + businessId)
|
||||
.listen('.high-intent-buyer', (e) => {
|
||||
showNotification('🔥 Hot Lead!', `${e.buyer_name} showing high intent`);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 TESTING BUSINESS ISOLATION
|
||||
|
||||
```php
|
||||
// In php artisan tinker
|
||||
|
||||
// 1. Login as user
|
||||
auth()->loginUsingId(1);
|
||||
$business = currentBusiness();
|
||||
|
||||
// 2. Test helper
|
||||
echo "Business ID: " . currentBusinessId();
|
||||
|
||||
// 3. Test permission
|
||||
echo hasBusinessPermission('analytics.overview') ? "✅ HAS" : "❌ NO";
|
||||
|
||||
// 4. Test scoping - should only return current business data
|
||||
$count = App\Models\Analytics\ProductView::where('business_id', $business->id)->count();
|
||||
echo "My views: $count";
|
||||
|
||||
// 5. Test auto-set business_id
|
||||
$event = App\Models\Analytics\AnalyticsEvent::create([
|
||||
'event_type' => 'test'
|
||||
]);
|
||||
echo $event->business_id === $business->id ? "✅ PASS" : "❌ FAIL";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 PERMISSION SETUP
|
||||
|
||||
Add permissions to a user:
|
||||
|
||||
```php
|
||||
// In tinker or seeder
|
||||
$user = User::find(1);
|
||||
$business = $user->businesses->first();
|
||||
|
||||
// Grant analytics permissions
|
||||
$user->businesses()->updateExistingPivot($business->id, [
|
||||
'permissions' => [
|
||||
'analytics.overview',
|
||||
'analytics.products',
|
||||
'analytics.marketing',
|
||||
'analytics.sales',
|
||||
'analytics.buyers',
|
||||
'analytics.export'
|
||||
]
|
||||
]);
|
||||
|
||||
// Verify
|
||||
$pivot = $user->businesses()->find($business->id)->pivot;
|
||||
print_r($pivot->permissions);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ QUEUE CONFIGURATION
|
||||
|
||||
Make sure Redis is running and queue worker is started:
|
||||
|
||||
```bash
|
||||
# Check Redis
|
||||
redis-cli ping
|
||||
|
||||
# Start queue worker
|
||||
php artisan queue:work --queue=analytics --tries=3
|
||||
|
||||
# Or with supervisor (production):
|
||||
[program:cannabrands-analytics-queue]
|
||||
command=php /path/to/artisan queue:work --queue=analytics --tries=3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 NAVIGATION UPDATE
|
||||
|
||||
Add to your sidebar navigation:
|
||||
|
||||
```blade
|
||||
<!-- Analytics Section (New Top-Level) -->
|
||||
<div class="nav-section">
|
||||
<div class="nav-header">
|
||||
<svg>...</svg>
|
||||
Analytics
|
||||
</div>
|
||||
|
||||
@if(hasBusinessPermission('analytics.overview'))
|
||||
<a href="{{ route('analytics.dashboard') }}" class="nav-item">
|
||||
Overview
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if(hasBusinessPermission('analytics.products'))
|
||||
<a href="{{ route('analytics.products.index') }}" class="nav-item">
|
||||
Products
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<!-- Marketing, Sales, Buyers... -->
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 COMMON ISSUES
|
||||
|
||||
### Issue: "business_id cannot be null"
|
||||
**Solution:** Make sure `currentBusinessId()` returns a value. User must be logged in and have a business.
|
||||
|
||||
### Issue: "Seeing other businesses' data"
|
||||
**Solution:** You forgot to scope by business_id! Check your query has `where('business_id', ...)`.
|
||||
|
||||
### Issue: "Permission check not working"
|
||||
**Solution:** Check the permissions array in business_user pivot table. Make sure it's a JSON array.
|
||||
|
||||
### Issue: "Product has no business_id"
|
||||
**Solution:** Products don't have direct business_id. Use `BusinessHelper::fromProduct($product)` to get seller's business.
|
||||
|
||||
---
|
||||
|
||||
## 📚 FILE STRUCTURE
|
||||
|
||||
```
|
||||
app/
|
||||
├── Events/Analytics/
|
||||
│ └── HighIntentBuyerDetected.php
|
||||
├── Helpers/
|
||||
│ ├── BusinessHelper.php
|
||||
│ └── helpers.php
|
||||
├── Http/Controllers/Analytics/
|
||||
│ ├── AnalyticsDashboardController.php
|
||||
│ ├── ProductAnalyticsController.php
|
||||
│ ├── MarketingAnalyticsController.php
|
||||
│ ├── SalesAnalyticsController.php
|
||||
│ └── BuyerIntelligenceController.php
|
||||
├── Jobs/Analytics/
|
||||
│ └── CalculateEngagementScore.php
|
||||
├── Models/Analytics/
|
||||
│ ├── AnalyticsEvent.php
|
||||
│ ├── ProductView.php
|
||||
│ ├── EmailCampaign.php
|
||||
│ ├── EmailInteraction.php
|
||||
│ ├── EmailClick.php
|
||||
│ ├── ClickTracking.php
|
||||
│ ├── UserSession.php
|
||||
│ ├── IntentSignal.php
|
||||
│ └── BuyerEngagementScore.php
|
||||
└── Services/Analytics/
|
||||
└── AnalyticsTracker.php
|
||||
|
||||
database/migrations/
|
||||
├── 2024_01_01_000001_create_analytics_events_table.php
|
||||
├── 2024_01_01_000002_create_product_views_table.php
|
||||
├── 2024_01_01_000003_create_email_tracking_tables.php
|
||||
├── 2024_01_01_000004_create_click_tracking_table.php
|
||||
├── 2024_01_01_000005_create_user_sessions_and_intent_tables.php
|
||||
├── 2024_01_01_000006_add_analytics_permissions_to_business_user.php
|
||||
└── 2024_01_01_000007_create_analytics_jobs_table.php
|
||||
|
||||
resources/views/analytics/
|
||||
├── dashboard.blade.php
|
||||
├── products/
|
||||
│ ├── index.blade.php
|
||||
│ └── show.blade.php
|
||||
├── marketing/
|
||||
├── sales/
|
||||
└── buyers/
|
||||
|
||||
routes/
|
||||
├── channels.php (add broadcasting channel)
|
||||
└── web.php (add analytics routes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ DEFINITION OF DONE
|
||||
|
||||
- [ ] All 7 migrations run successfully
|
||||
- [ ] BusinessHelper and helpers.php created and autoloaded
|
||||
- [ ] All 10 analytics models created with business scoping
|
||||
- [ ] AnalyticsTracker service working
|
||||
- [ ] Queue jobs configured and tested
|
||||
- [ ] Reverb events broadcasting
|
||||
- [ ] All 5 controllers created
|
||||
- [ ] Routes added with permission checks
|
||||
- [ ] Navigation updated with Analytics section
|
||||
- [ ] Permission UI tile added
|
||||
- [ ] At least one dashboard view working
|
||||
- [ ] Business isolation verified (no cross-business data)
|
||||
- [ ] Permission checking works via business_user pivot
|
||||
- [ ] Queue worker running for analytics jobs
|
||||
- [ ] Test data can be created and viewed
|
||||
|
||||
---
|
||||
|
||||
## 🎉 READY TO IMPLEMENT!
|
||||
|
||||
Everything in the main guide is tailored to YOUR actual architecture:
|
||||
- ✅ business_id (bigInteger) not UUID
|
||||
- ✅ Explicit scoping, no global scopes
|
||||
- ✅ business_user.permissions JSON
|
||||
- ✅ Multi-business user support
|
||||
- ✅ Product → Brand → Business hierarchy
|
||||
- ✅ Reverb for real-time
|
||||
- ✅ Redis queues (no Horizon needed)
|
||||
|
||||
**Estimated implementation time: 5-6 hours**
|
||||
|
||||
Start with helpers and migrations, then build up from there! 🚀
|
||||
1
SESSION_ACTIVE
Symbolic link
1
SESSION_ACTIVE
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/kelly/Nextcloud/Claude Sessions/hub-session/SESSION_ACTIVE
|
||||
392
SESSION_SUMMARY_2025-11-14.md
Normal file
392
SESSION_SUMMARY_2025-11-14.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Session Summary - Dashboard Fixes & Security Improvements
|
||||
**Date:** November 14, 2025
|
||||
**Branch:** `feature/manufacturing-module`
|
||||
**Location:** `/home/kelly/git/hub` (main repo)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This session completed fixes from the previous session (Nov 13) and addressed critical errors in the dashboard and security vulnerabilities. All work was done in the main repository on `feature/manufacturing-module` branch.
|
||||
|
||||
---
|
||||
|
||||
## Completed Fixes
|
||||
|
||||
### 1. Dashboard TypeError Fix - Quality Calculation ✅
|
||||
|
||||
**Problem:** TypeError "Cannot access offset of type array on array" at line 526 in DashboardController
|
||||
**Root Cause:** Code assumed quality data existed in Stage 2 wash reports, but WashReportController doesn't collect quality grades yet
|
||||
|
||||
**Files Changed:**
|
||||
- `app/Http/Controllers/DashboardController.php` (lines 513-545)
|
||||
|
||||
**Solution:**
|
||||
- Made quality grade extraction defensive
|
||||
- Iterates through all yield types (works with both hash and rosin structures)
|
||||
- Returns `null` for `avg_hash_quality` when no quality data exists
|
||||
- Only calls `calculateAverageQuality()` when grades are available
|
||||
|
||||
**Code:**
|
||||
```php
|
||||
// Check all yield types for quality data (handles both hash and rosin structures)
|
||||
foreach ($stage2['yields'] as $yieldType => $yieldData) {
|
||||
if (isset($yieldData['quality']) && $yieldData['quality']) {
|
||||
$qualityGrades[] = $yieldData['quality'];
|
||||
}
|
||||
}
|
||||
|
||||
// Only include quality if we have the data
|
||||
if (empty($qualityGrades)) {
|
||||
$component->past_performance = [
|
||||
'has_data' => true,
|
||||
'wash_count' => $pastWashes->count(),
|
||||
'avg_yield' => round($avgYield, 1),
|
||||
'avg_hash_quality' => null, // No quality data tracked
|
||||
];
|
||||
} else {
|
||||
$avgQuality = $this->calculateAverageQuality($qualityGrades);
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Department-Based Dashboard Visibility ✅
|
||||
|
||||
**Problem:** Owners and super admins saw sales metrics even when only in processing departments
|
||||
**Architecture Violation:** Dashboard blocks should be determined by department groups, not role overrides
|
||||
|
||||
**Files Changed:**
|
||||
- `app/Http/Controllers/DashboardController.php` (lines 56-60)
|
||||
|
||||
**Solution:**
|
||||
- Removed owner and super admin overrides: `|| $isOwner || $isSuperAdmin`
|
||||
- Dashboard blocks now determined ONLY by department assignments
|
||||
- Added clear documentation explaining this architectural decision
|
||||
|
||||
**Before:**
|
||||
```php
|
||||
$showSalesMetrics = $hasSales || $isOwner || $isSuperAdmin;
|
||||
```
|
||||
|
||||
**After:**
|
||||
```php
|
||||
// Dashboard blocks determined ONLY by department groups (not by ownership or admin role)
|
||||
// Users see data for their assigned departments - add user to department for access
|
||||
$showSalesMetrics = $hasSales;
|
||||
$showProcessingMetrics = $hasSolventless;
|
||||
$showFleetMetrics = $hasDelivery;
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Vinny (LAZ-SOLV) → sees ONLY processing blocks
|
||||
- Sales team (CBD-SALES, CBD-MKTG) → sees ONLY sales blocks
|
||||
- Multi-department users → see blocks for ALL their departments
|
||||
- Ownership = business management, NOT data access
|
||||
|
||||
---
|
||||
|
||||
### 3. Dashboard View - Null Quality Handling ✅
|
||||
|
||||
**Problem:** View tried to display `null` quality in badge when quality data missing
|
||||
|
||||
**Files Changed:**
|
||||
- `resources/views/seller/dashboard.blade.php` (lines 538-553)
|
||||
|
||||
**Solution:**
|
||||
- Added check for both `has_data` AND `avg_hash_quality` before showing badge
|
||||
- Shows "Not tracked" when wash history exists but no quality data
|
||||
- Shows "—" when no wash history exists at all
|
||||
|
||||
**Code:**
|
||||
```blade
|
||||
@if($component->past_performance['has_data'] && $component->past_performance['avg_hash_quality'])
|
||||
<div class="badge badge-sm ...">
|
||||
{{ $component->past_performance['avg_hash_quality'] }}
|
||||
</div>
|
||||
@elseif($component->past_performance['has_data'])
|
||||
<span class="text-xs text-base-content/40">Not tracked</span>
|
||||
@else
|
||||
<span class="text-xs text-base-content/40">—</span>
|
||||
@endif
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Quality badges display correctly when data exists
|
||||
- Graceful fallback when quality not tracked
|
||||
- Clear distinction between "no history" vs "no quality data"
|
||||
|
||||
---
|
||||
|
||||
### 4. Filament Admin Middleware Registration ✅
|
||||
|
||||
**Problem:** Users with wrong user type getting 403 Forbidden when accessing `/admin`, requiring manual cookie deletion
|
||||
|
||||
**Files Changed:**
|
||||
- `app/Providers/Filament/AdminPanelProvider.php` (lines 8, 72)
|
||||
|
||||
**Solution:**
|
||||
- Imported custom middleware: `use App\Http\Middleware\FilamentAdminAuthenticate;`
|
||||
- Registered in authMiddleware: `FilamentAdminAuthenticate::class`
|
||||
- Middleware auto-logs out users without access and redirects to login
|
||||
|
||||
**Code:**
|
||||
```php
|
||||
// Added import
|
||||
use App\Http\Middleware\FilamentAdminAuthenticate;
|
||||
|
||||
// Changed auth middleware
|
||||
->authMiddleware([
|
||||
FilamentAdminAuthenticate::class, // Instead of Authenticate::class
|
||||
])
|
||||
```
|
||||
|
||||
**How It Works:**
|
||||
1. Detects when authenticated user lacks panel access
|
||||
2. Logs them out completely (clears session)
|
||||
3. Redirects to login with message: "Please login with an account that has access to this panel."
|
||||
4. No more manual cookie deletion needed!
|
||||
|
||||
---
|
||||
|
||||
### 5. Parent Company Cross-Division Security ✅
|
||||
|
||||
**Problem:** Users could manually change URL slug to access divisions they're not assigned to
|
||||
|
||||
**Files Changed:**
|
||||
- `routes/seller.php` (lines 11-19)
|
||||
|
||||
**Solution:**
|
||||
- Enhanced route binding documentation
|
||||
- Clarified that existing check already prevents cross-division access
|
||||
- Check validates against `business_user` pivot table
|
||||
|
||||
**Security Checks:**
|
||||
1. Unauthorized access to any business → 403
|
||||
2. Parent company users accessing division URLs by changing slug → 403
|
||||
3. Division users accessing other divisions' URLs by changing slug → 403
|
||||
|
||||
**Code:**
|
||||
```php
|
||||
// Security: Verify user is explicitly assigned to this business
|
||||
// This prevents:
|
||||
// 1. Unauthorized access to any business
|
||||
// 2. Parent company users accessing division URLs by changing slug
|
||||
// 3. Division users accessing other divisions' URLs by changing slug
|
||||
// Users must be explicitly assigned via business_user pivot table
|
||||
if (! auth()->check() || ! auth()->user()->businesses->contains($business->id)) {
|
||||
abort(403, 'You do not have access to this business or division.');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `app/Http/Controllers/DashboardController.php`
|
||||
- Line 56-60: Removed owner override from dashboard visibility
|
||||
- Lines 513-545: Fixed quality grade extraction to be defensive
|
||||
|
||||
2. `resources/views/seller/dashboard.blade.php`
|
||||
- Lines 538-553: Added null quality handling in Idle Fresh Frozen table
|
||||
|
||||
3. `app/Providers/Filament/AdminPanelProvider.php`
|
||||
- Line 8: Added FilamentAdminAuthenticate import
|
||||
- Line 72: Registered custom middleware
|
||||
|
||||
4. `routes/seller.php`
|
||||
- Lines 11-19: Enhanced security documentation for route binding
|
||||
|
||||
---
|
||||
|
||||
## Context from Previous Session (Nov 13)
|
||||
|
||||
This session addressed incomplete tasks from `SESSION_SUMMARY_2025-11-13.md`:
|
||||
|
||||
### Completed from Nov 13 Backlog:
|
||||
1. ✅ Custom Middleware Registration (was created but not registered)
|
||||
2. ✅ Parent Company Security Fix (documentation clarified)
|
||||
|
||||
### Already Complete from Nov 13:
|
||||
- ✅ Manufacturing module implementation
|
||||
- ✅ Seeder architecture with production protection
|
||||
- ✅ Quick Switch impersonation feature
|
||||
- ✅ Idle Fresh Frozen dashboard with past performance metrics
|
||||
- ✅ Historical wash cycle data in Stage 1 form
|
||||
|
||||
### Low Priority (Not Blocking):
|
||||
- Missing demo user "Kelly" - other demo users (Vinny, Maria) work fine
|
||||
|
||||
---
|
||||
|
||||
## Dashboard Block Visibility by Department
|
||||
|
||||
### Processing Department (LAZ-SOLV, CRG-SOLV):
|
||||
**Shows:**
|
||||
- ✅ Wash Reports, Average Yield, Active/Completed Work Orders stats
|
||||
- ✅ Idle Fresh Frozen with past performance metrics
|
||||
- ✅ Quick Actions: Start a New Wash, Review Wash Reports
|
||||
- ✅ Recent Washes table
|
||||
- ✅ Strain Performance section
|
||||
|
||||
**Hidden:**
|
||||
- ❌ Revenue Statistics chart
|
||||
- ❌ Low Stock Alerts (sales products)
|
||||
- ❌ Recent Orders
|
||||
- ❌ Top Performing Products
|
||||
|
||||
### Sales Department (CBD-SALES, CBD-MKTG):
|
||||
**Shows:**
|
||||
- ✅ Revenue Statistics chart
|
||||
- ✅ Quick Actions: Add New Product, View All Orders
|
||||
- ✅ Low Stock Alerts
|
||||
- ✅ Recent Orders table
|
||||
- ✅ Top Performing Products
|
||||
|
||||
**Hidden:**
|
||||
- ❌ Processing metrics
|
||||
- ❌ Idle Fresh Frozen
|
||||
- ❌ Strain Performance
|
||||
|
||||
### Fleet Department (CRG-DELV):
|
||||
**Shows:**
|
||||
- ✅ Drivers, Active Vehicles, Fleet Size, Deliveries Today stats
|
||||
- ✅ Quick Actions: Manage Drivers
|
||||
|
||||
**Hidden:**
|
||||
- ❌ Sales and processing content
|
||||
|
||||
---
|
||||
|
||||
## Idle Fresh Frozen Display
|
||||
|
||||
### Dashboard Table (Processing Department)
|
||||
| Material | Quantity | Past Avg Yield | Past Hash Quality | Action |
|
||||
|----------|----------|----------------|-------------------|---------|
|
||||
| Blue Dream - Fresh Frozen | 500g | **4.2%** (3 washes) | **Not tracked** | [Start Wash] |
|
||||
| Cherry Pie - Fresh Frozen | 750g | **5.8%** (5 washes) | **Not tracked** | [Start Wash] |
|
||||
|
||||
**Notes:**
|
||||
- "Past Avg Yield" calculates from historical wash data
|
||||
- "Past Hash Quality" shows "Not tracked" because WashReportController doesn't collect quality grades yet
|
||||
- "Start Wash" button links to Stage 1 form with strain pre-populated
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Admin Panel 403 Fix
|
||||
- [ ] Login as `seller@example.com` (non-admin)
|
||||
- [ ] Navigate to `/admin`
|
||||
- [ ] Expected: Auto-logout + redirect to login with message (no 403 error page)
|
||||
|
||||
### Cross-Division URL Protection
|
||||
- [ ] Login as Vinny (Leopard AZ user)
|
||||
- [ ] Go to `/s/leopard-az/dashboard` (should work)
|
||||
- [ ] Change URL to `/s/cannabrands-az/dashboard`
|
||||
- [ ] Expected: 403 error "You do not have access to this business or division."
|
||||
|
||||
### Dashboard Department Blocks
|
||||
- [ ] Login as Vinny (LAZ-SOLV department)
|
||||
- [ ] View dashboard
|
||||
- [ ] Verify processing metrics show, sales metrics hidden
|
||||
- [ ] Verify revenue chart is hidden
|
||||
|
||||
### Idle Fresh Frozen Performance Data
|
||||
- [ ] View processing dashboard
|
||||
- [ ] Check Idle Fresh Frozen section
|
||||
- [ ] Verify Past Avg Yield shows percentages
|
||||
- [ ] Verify Past Hash Quality shows "Not tracked"
|
||||
|
||||
### Dashboard TypeError Fix
|
||||
- [ ] Access dashboard as any processing user
|
||||
- [ ] Verify no TypeError when viewing Idle Fresh Frozen
|
||||
- [ ] Verify quality column displays gracefully
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### 1. Department-Based Access Control
|
||||
**Decision:** Dashboard blocks determined ONLY by department assignments, not by roles or ownership.
|
||||
|
||||
**Rationale:**
|
||||
- Clearer separation of concerns
|
||||
- Easier to audit ("what does this user see?")
|
||||
- Scales better for multi-department users
|
||||
- Ownership = business management, not data access
|
||||
|
||||
**Implementation:**
|
||||
- User assigned to LAZ-SOLV → sees processing data only
|
||||
- User assigned to CBD-SALES → sees sales data only
|
||||
- User assigned to both → sees both
|
||||
|
||||
### 2. Working in Main Repo (Not Worktree)
|
||||
**Decision:** All work done in `/home/kelly/git/hub` on `feature/manufacturing-module` branch.
|
||||
|
||||
**Rationale:**
|
||||
- More traditional workflow
|
||||
- Simpler to understand and maintain
|
||||
- Worktree added complexity without clear benefit
|
||||
- Can merge/cherry-pick from worktree if needed later
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Future Enhancements
|
||||
|
||||
### 1. Quality Grade Collection Not Implemented
|
||||
**Status:** Deferred - not blocking
|
||||
**Issue:** WashReportController Stage 2 doesn't collect quality grades yet
|
||||
**Impact:** Dashboard shows "Not tracked" for all quality data
|
||||
**Future Work:** Update `WashReportController::storeStage2()` to:
|
||||
- Accept quality inputs: `quality_fresh_press_120u`, `quality_cold_cure_90u`, etc.
|
||||
- Store in `$metadata['stage_2']['yields'][...]['quality']`
|
||||
- Then dashboard will automatically show quality badges
|
||||
|
||||
### 2. Worktree Branch Status
|
||||
**Status:** Inactive but preserved
|
||||
**Location:** `/home/kelly/git/hub-worktrees/manufacturing-features`
|
||||
**Branch:** `feature/manufacturing-features`
|
||||
**Decision:** Keep as reference, all new work in main repo
|
||||
|
||||
---
|
||||
|
||||
## Cache Commands Run
|
||||
|
||||
```bash
|
||||
./vendor/bin/sail artisan view:clear
|
||||
./vendor/bin/sail artisan cache:clear
|
||||
./vendor/bin/sail artisan config:clear
|
||||
./vendor/bin/sail artisan route:clear
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (When Resuming)
|
||||
|
||||
1. **Test all fixes** using checklist above
|
||||
2. **Run test suite:** `php artisan test --parallel`
|
||||
3. **Run Pint:** `./vendor/bin/pint`
|
||||
4. **Decide on worktree:** Keep as backup or merge/delete
|
||||
5. **Future:** Implement quality grade collection in WashReportController
|
||||
|
||||
---
|
||||
|
||||
## Git Information
|
||||
|
||||
**Branch:** `feature/manufacturing-module`
|
||||
**Location:** `/home/kelly/git/hub`
|
||||
**Uncommitted Changes:** 4 files modified (ready to commit)
|
||||
|
||||
**Modified Files:**
|
||||
- `app/Http/Controllers/DashboardController.php`
|
||||
- `app/Providers/Filament/AdminPanelProvider.php`
|
||||
- `resources/views/seller/dashboard.blade.php`
|
||||
- `routes/seller.php`
|
||||
|
||||
---
|
||||
|
||||
**Session completed:** 2025-11-14
|
||||
**All fixes tested:** Pending user testing
|
||||
**Ready for commit:** Yes
|
||||
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
155
app/Console/Commands/CleanupPermissionAuditLogs.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\PermissionAuditLog;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CleanupPermissionAuditLogs extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'permissions:cleanup-audit
|
||||
{--dry-run : Show what would be deleted without actually deleting}
|
||||
{--force : Skip confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete expired permission audit logs (non-critical logs past their expiration date)';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$isDryRun = $this->option('dry-run');
|
||||
$isForced = $this->option('force');
|
||||
|
||||
$this->info('🔍 Scanning for expired permission audit logs...');
|
||||
$this->newLine();
|
||||
|
||||
// Find expired logs
|
||||
$expiredLogs = PermissionAuditLog::expired()->get();
|
||||
|
||||
if ($expiredLogs->isEmpty()) {
|
||||
$this->info('✅ No expired audit logs found. Everything is up to date!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Statistics
|
||||
$totalCount = $expiredLogs->count();
|
||||
$oldestLog = $expiredLogs->sortBy('created_at')->first();
|
||||
$newestLog = $expiredLogs->sortByDesc('created_at')->first();
|
||||
|
||||
// Display summary
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['Expired logs found', $totalCount],
|
||||
['Oldest expired log', $oldestLog->created_at->format('Y-m-d H:i:s')],
|
||||
['Newest expired log', $newestLog->created_at->format('Y-m-d H:i:s')],
|
||||
['Date range', $oldestLog->created_at->diffForHumans($newestLog->created_at, true)],
|
||||
]
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Show sample of logs to be deleted
|
||||
$this->info('📋 Sample of logs to be deleted:');
|
||||
$sampleLogs = $expiredLogs->take(5);
|
||||
|
||||
foreach ($sampleLogs as $log) {
|
||||
$this->line(sprintf(
|
||||
' • [%s] %s - %s (expired %s)',
|
||||
$log->created_at->format('Y-m-d'),
|
||||
$log->action_name,
|
||||
$log->targetUser?->name ?? 'Unknown User',
|
||||
$log->expires_at->diffForHumans()
|
||||
));
|
||||
}
|
||||
|
||||
if ($totalCount > 5) {
|
||||
$this->line(" ... and {$totalCount} more");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Dry run mode
|
||||
if ($isDryRun) {
|
||||
$this->warn('🧪 DRY RUN MODE - No logs will be deleted');
|
||||
$this->info("Would delete {$totalCount} expired audit logs");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Confirmation prompt (unless forced)
|
||||
if (! $isForced) {
|
||||
$confirmed = $this->confirm(
|
||||
"Are you sure you want to delete {$totalCount} expired audit logs?",
|
||||
false
|
||||
);
|
||||
|
||||
if (! $confirmed) {
|
||||
$this->info('❌ Cleanup cancelled');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform deletion
|
||||
$this->info('🗑️ Deleting expired audit logs...');
|
||||
|
||||
$progressBar = $this->output->createProgressBar($totalCount);
|
||||
$progressBar->start();
|
||||
|
||||
$deletedCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($expiredLogs as $log) {
|
||||
try {
|
||||
$log->delete();
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
$errorCount++;
|
||||
$this->error("Failed to delete log ID {$log->id}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
// Final summary
|
||||
if ($errorCount === 0) {
|
||||
$this->info("✅ Successfully deleted {$deletedCount} expired audit logs");
|
||||
} else {
|
||||
$this->warn("⚠️ Deleted {$deletedCount} logs with {$errorCount} errors");
|
||||
}
|
||||
|
||||
// Show remaining stats
|
||||
$remainingTotal = PermissionAuditLog::count();
|
||||
$remainingCritical = PermissionAuditLog::critical()->count();
|
||||
$remainingNonExpired = $remainingTotal - $remainingCritical;
|
||||
|
||||
$this->newLine();
|
||||
$this->info('📊 Database statistics after cleanup:');
|
||||
$this->table(
|
||||
['Category', 'Count'],
|
||||
[
|
||||
['Critical logs (kept forever)', $remainingCritical],
|
||||
['Non-critical logs (not yet expired)', $remainingNonExpired],
|
||||
['Total remaining logs', $remainingTotal],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,10 @@ class CreateTestInvoiceForApproval extends Command
|
||||
|
||||
$this->info("✓ Company: {$company->name}");
|
||||
|
||||
// Get some products
|
||||
$products = Product::where('quantity_on_hand', '>', 10)->where('is_active', true)->take(5)->get();
|
||||
// Get some products that have inventory
|
||||
$products = Product::whereHas('inventoryItems', function ($q) {
|
||||
$q->where('quantity_on_hand', '>', 10);
|
||||
})->where('is_active', true)->take(5)->get();
|
||||
if ($products->isEmpty()) {
|
||||
$this->error('No products found. Please seed products first.');
|
||||
|
||||
|
||||
289
app/Console/Commands/ImportBrandFromMySQL.php
Normal file
289
app/Console/Commands/ImportBrandFromMySQL.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class ImportBrandFromMySQL extends Command
|
||||
{
|
||||
protected $signature = 'brand:import-from-mysql {remoteName? : Remote brand name} {localName? : Local brand name (if different)}';
|
||||
|
||||
protected $description = 'Import brand data and images from remote MySQL database';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
$remoteBrandName = $this->argument('remoteName') ?? 'Canna';
|
||||
$localBrandName = $this->argument('localName') ?? $remoteBrandName;
|
||||
|
||||
$this->info('Connecting to remote MySQL database...');
|
||||
|
||||
try {
|
||||
// Connect to remote MySQL with latin1 charset (Windows-1252)
|
||||
$pdo = new \PDO(
|
||||
'mysql:host=sql1.creationshop.net;dbname=hub_cannabrands;charset=latin1',
|
||||
'claude',
|
||||
'claude'
|
||||
);
|
||||
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$this->info('Connected successfully!');
|
||||
|
||||
// Fetch brand data from MySQL
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT brand_id, name, tagline, short_desc, `desc`, url,
|
||||
image, banner, address, unit_number, city, state, zip, phone,
|
||||
public, fb, insta, twitter, youtube
|
||||
FROM brands
|
||||
WHERE name = :name
|
||||
');
|
||||
$stmt->execute(['name' => $remoteBrandName]);
|
||||
$remoteBrand = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||
|
||||
if (! $remoteBrand) {
|
||||
$this->error("Brand '{$remoteBrandName}' not found in remote database");
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Found remote brand: {$remoteBrand['name']}");
|
||||
|
||||
// Find local brand by name
|
||||
$localBrand = Brand::where('name', $localBrandName)->first();
|
||||
|
||||
if (! $localBrand) {
|
||||
$this->error("Brand '{$localBrandName}' not found in local database");
|
||||
$this->info('Available brands: '.Brand::pluck('name')->implode(', '));
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Found local brand: {$localBrand->name} (ID: {$localBrand->id})");
|
||||
|
||||
// Create brands directory if it doesn't exist
|
||||
if (! Storage::disk('public')->exists('brands')) {
|
||||
Storage::disk('public')->makeDirectory('brands');
|
||||
$this->info('Created brands directory');
|
||||
}
|
||||
|
||||
// Initialize Intervention Image
|
||||
$manager = new ImageManager(new Driver);
|
||||
|
||||
// Process logo image with thumbnails (save as PNG for transparency support)
|
||||
if ($remoteBrand['image']) {
|
||||
$logoPath = "brands/{$localBrand->slug}-logo.png";
|
||||
|
||||
// Read and process the original image
|
||||
$originalImage = $manager->read($remoteBrand['image']);
|
||||
|
||||
// Try to remove white background by making white pixels transparent
|
||||
// Sample corners to detect if background is white
|
||||
$width = $originalImage->width();
|
||||
$height = $originalImage->height();
|
||||
|
||||
// Use GD to manipulate pixels
|
||||
$gdImage = imagecreatefromstring($remoteBrand['image']);
|
||||
if ($gdImage !== false) {
|
||||
// Enable alpha blending
|
||||
imagealphablending($gdImage, false);
|
||||
imagesavealpha($gdImage, true);
|
||||
|
||||
// Make white and near-white pixels transparent
|
||||
for ($x = 0; $x < imagesx($gdImage); $x++) {
|
||||
for ($y = 0; $y < imagesy($gdImage); $y++) {
|
||||
$rgb = imagecolorat($gdImage, $x, $y);
|
||||
$colors = imagecolorsforindex($gdImage, $rgb);
|
||||
|
||||
// If pixel is white or very close to white (RGB > 245)
|
||||
if ($colors['red'] > 245 && $colors['green'] > 245 && $colors['blue'] > 245) {
|
||||
$transparent = imagecolorallocatealpha($gdImage, 255, 255, 255, 127);
|
||||
imagesetpixel($gdImage, $x, $y, $transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save as PNG
|
||||
ob_start();
|
||||
imagepng($gdImage);
|
||||
$processedData = ob_get_clean();
|
||||
imagedestroy($gdImage);
|
||||
|
||||
Storage::disk('public')->put($logoPath, $processedData);
|
||||
$originalImage = $manager->read($processedData);
|
||||
} else {
|
||||
// Fallback: save original as PNG
|
||||
Storage::disk('public')->put($logoPath, $originalImage->toPng());
|
||||
}
|
||||
|
||||
// Generate thumbnails optimized for retina displays (PNG for transparency)
|
||||
// Thumbnail (160x160) for list views (2x retina at 80px)
|
||||
$thumbRetina = clone $originalImage;
|
||||
$thumbRetina->scale(width: 160);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-thumb.png", $thumbRetina->toPng());
|
||||
|
||||
// Medium (600x600) for product cards (2x retina at 300px)
|
||||
$mediumRetina = clone $originalImage;
|
||||
$mediumRetina->scale(width: 600);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-medium.png", $mediumRetina->toPng());
|
||||
|
||||
// Large (1600x1600) for detail views
|
||||
$largeRetina = clone $originalImage;
|
||||
$largeRetina->scale(width: 1600);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-logo-large.png", $largeRetina->toPng());
|
||||
|
||||
$localBrand->logo_path = $logoPath;
|
||||
$this->info("✓ Saved logo + thumbnails: {$logoPath} (".strlen($remoteBrand['image']).' bytes)');
|
||||
}
|
||||
|
||||
// Process banner image with thumbnails
|
||||
if ($remoteBrand['banner']) {
|
||||
$bannerPath = "brands/{$localBrand->slug}-banner.jpg";
|
||||
|
||||
// Save original
|
||||
Storage::disk('public')->put($bannerPath, $remoteBrand['banner']);
|
||||
|
||||
// Generate banner thumbnails if banner is large enough
|
||||
if (strlen($remoteBrand['banner']) > 1000) {
|
||||
$image = $manager->read($remoteBrand['banner']);
|
||||
|
||||
// Medium banner (1344px wide) for retina displays at 672px
|
||||
$mediumBanner = clone $image;
|
||||
$mediumBanner->scale(width: 1344);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-medium.jpg", $mediumBanner->toJpeg(quality: 92));
|
||||
|
||||
// Large banner (2560px wide) for full-width hero sections
|
||||
$largeBanner = clone $image;
|
||||
$largeBanner->scale(width: 2560);
|
||||
Storage::disk('public')->put("brands/{$localBrand->slug}-banner-large.jpg", $largeBanner->toJpeg(quality: 92));
|
||||
}
|
||||
|
||||
$localBrand->banner_path = $bannerPath;
|
||||
$this->info("✓ Saved banner + thumbnails: {$bannerPath} (".strlen($remoteBrand['banner']).' bytes)');
|
||||
}
|
||||
|
||||
// Helper function to sanitize text (convert Windows-1252 to UTF-8)
|
||||
$sanitize = function ($text) {
|
||||
if (! $text) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
// First, convert from Windows-1252/ISO-8859-1 to UTF-8
|
||||
$text = mb_convert_encoding($text, 'UTF-8', 'Windows-1252');
|
||||
|
||||
// Replace common Windows-1252 special characters with standard equivalents
|
||||
$replacements = [
|
||||
"\xE2\x80\x98" => "'", // Left single quote
|
||||
"\xE2\x80\x99" => "'", // Right single quote (apostrophe)
|
||||
"\xE2\x80\x9C" => '"', // Left double quote
|
||||
"\xE2\x80\x9D" => '"', // Right double quote
|
||||
"\xE2\x80\x93" => '-', // En dash
|
||||
"\xE2\x80\x94" => '-', // Em dash
|
||||
"\xE2\x80\x26" => '...', // Ellipsis
|
||||
];
|
||||
|
||||
$text = str_replace(array_keys($replacements), array_values($replacements), $text);
|
||||
|
||||
return trim($text);
|
||||
};
|
||||
|
||||
// Update other brand fields
|
||||
$updates = [];
|
||||
|
||||
if ($remoteBrand['tagline']) {
|
||||
$localBrand->tagline = $sanitize($remoteBrand['tagline']);
|
||||
$updates[] = 'tagline';
|
||||
}
|
||||
|
||||
if ($remoteBrand['short_desc']) {
|
||||
$localBrand->description = $sanitize($remoteBrand['short_desc']);
|
||||
$updates[] = 'description';
|
||||
}
|
||||
|
||||
if ($remoteBrand['desc']) {
|
||||
$localBrand->long_description = $sanitize($remoteBrand['desc']);
|
||||
$updates[] = 'long_description';
|
||||
}
|
||||
|
||||
if ($remoteBrand['url']) {
|
||||
$localBrand->website_url = $remoteBrand['url'];
|
||||
$updates[] = 'website_url';
|
||||
}
|
||||
|
||||
// Address fields
|
||||
if ($remoteBrand['address']) {
|
||||
$localBrand->address = $remoteBrand['address'];
|
||||
$updates[] = 'address';
|
||||
}
|
||||
|
||||
if ($remoteBrand['unit_number']) {
|
||||
$localBrand->unit_number = $remoteBrand['unit_number'];
|
||||
$updates[] = 'unit_number';
|
||||
}
|
||||
|
||||
if ($remoteBrand['city']) {
|
||||
$localBrand->city = $remoteBrand['city'];
|
||||
$updates[] = 'city';
|
||||
}
|
||||
|
||||
if ($remoteBrand['state']) {
|
||||
$localBrand->state = $remoteBrand['state'];
|
||||
$updates[] = 'state';
|
||||
}
|
||||
|
||||
if ($remoteBrand['zip']) {
|
||||
$localBrand->zip_code = $remoteBrand['zip'];
|
||||
$updates[] = 'zip_code';
|
||||
}
|
||||
|
||||
if ($remoteBrand['phone']) {
|
||||
$localBrand->phone = $remoteBrand['phone'];
|
||||
$updates[] = 'phone';
|
||||
}
|
||||
|
||||
// Social media
|
||||
if ($remoteBrand['fb']) {
|
||||
$localBrand->facebook_url = 'https://facebook.com/'.$remoteBrand['fb'];
|
||||
$updates[] = 'facebook_url';
|
||||
}
|
||||
|
||||
if ($remoteBrand['insta']) {
|
||||
$localBrand->instagram_handle = $remoteBrand['insta'];
|
||||
$updates[] = 'instagram_handle';
|
||||
}
|
||||
|
||||
if ($remoteBrand['twitter']) {
|
||||
$localBrand->twitter_handle = $remoteBrand['twitter'];
|
||||
$updates[] = 'twitter_handle';
|
||||
}
|
||||
|
||||
if ($remoteBrand['youtube']) {
|
||||
$localBrand->youtube_url = $remoteBrand['youtube'];
|
||||
$updates[] = 'youtube_url';
|
||||
}
|
||||
|
||||
// Visibility
|
||||
$localBrand->is_public = (bool) $remoteBrand['public'];
|
||||
$updates[] = 'is_public';
|
||||
|
||||
// Save the brand
|
||||
$localBrand->save();
|
||||
|
||||
$this->info("\n✓ Successfully imported brand data!");
|
||||
$this->info('Updated fields: '.implode(', ', $updates));
|
||||
|
||||
$this->newLine();
|
||||
$this->info('View the brand at:');
|
||||
$this->line("http://localhost/s/cannabrands/brands/{$localBrand->hashid}/edit");
|
||||
|
||||
} catch (\Exception $e) {
|
||||
$this->error('Error: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
158
app/Console/Commands/SeedCoaData.php
Normal file
158
app/Console/Commands/SeedCoaData.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Batch;
|
||||
use App\Models\BatchCoaFile;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class SeedCoaData extends Command
|
||||
{
|
||||
protected $signature = 'seed:coa-data';
|
||||
|
||||
protected $description = 'Add COA files to existing batches for testing';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Seeding COA data for testing...');
|
||||
|
||||
// Get all active products with batches
|
||||
$products = Product::with('batches')
|
||||
->where('is_active', true)
|
||||
->whereHas('batches')
|
||||
->get();
|
||||
|
||||
if ($products->isEmpty()) {
|
||||
$this->warn('No products with batches found. Run the main seeder first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Found {$products->count()} products with batches");
|
||||
|
||||
$coaCount = 0;
|
||||
|
||||
foreach ($products as $product) {
|
||||
foreach ($product->batches as $batch) {
|
||||
// Skip if batch already has COAs
|
||||
if ($batch->coaFiles()->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create 1-2 COA files per batch
|
||||
$numCoas = rand(1, 2);
|
||||
|
||||
for ($i = 1; $i <= $numCoas; $i++) {
|
||||
$isPrimary = ($i === 1);
|
||||
|
||||
// Create a dummy PDF file
|
||||
$fileName = "COA-{$batch->batch_number}-{$i}.pdf";
|
||||
$filePath = "businesses/{$product->brand->business->uuid}/batches/{$batch->id}/coas/{$fileName}";
|
||||
|
||||
// Create dummy PDF content (just for testing)
|
||||
$pdfContent = $this->generateDummyPdf($batch, $product);
|
||||
Storage::disk('local')->put($filePath, $pdfContent);
|
||||
|
||||
// Create COA file record
|
||||
BatchCoaFile::create([
|
||||
'batch_id' => $batch->id,
|
||||
'file_name' => $fileName,
|
||||
'file_path' => $filePath,
|
||||
'file_size' => strlen($pdfContent),
|
||||
'mime_type' => 'application/pdf',
|
||||
'is_primary' => $isPrimary,
|
||||
'display_order' => $i,
|
||||
]);
|
||||
|
||||
$coaCount++;
|
||||
}
|
||||
|
||||
$this->line(" Added {$numCoas} COA(s) for batch {$batch->batch_number}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("✓ Created {$coaCount} COA files");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function generateDummyPdf(Batch $batch, Product $product): string
|
||||
{
|
||||
// Generate a simple text-based "PDF" for testing
|
||||
// In a real system, you'd use a PDF library
|
||||
return "%PDF-1.4
|
||||
1 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 2 0 R
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [3 0 R]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/Parent 2 0 R
|
||||
/Resources <<
|
||||
/Font <<
|
||||
/F1 <<
|
||||
/Type /Font
|
||||
/Subtype /Type1
|
||||
/BaseFont /Helvetica
|
||||
>>
|
||||
>>
|
||||
>>
|
||||
/MediaBox [0 0 612 792]
|
||||
/Contents 4 0 R
|
||||
>>
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Length 250
|
||||
>>
|
||||
stream
|
||||
BT
|
||||
/F1 12 Tf
|
||||
50 700 Td
|
||||
(CERTIFICATE OF ANALYSIS) Tj
|
||||
0 -30 Td
|
||||
(Batch Number: {$batch->batch_number}) Tj
|
||||
0 -20 Td
|
||||
(Product: {$product->name}) Tj
|
||||
0 -20 Td
|
||||
(Test Date: ".now()->format('Y-m-d').') Tj
|
||||
0 -30 Td
|
||||
(THC: 25.5%) Tj
|
||||
0 -20 Td
|
||||
(CBD: 0.8%) Tj
|
||||
0 -20 Td
|
||||
(Status: PASSED) Tj
|
||||
ET
|
||||
endstream
|
||||
endobj
|
||||
xref
|
||||
0 5
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000000058 00000 n
|
||||
0000000115 00000 n
|
||||
0000000317 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 5
|
||||
/Root 1 0 R
|
||||
>>
|
||||
startxref
|
||||
619
|
||||
%%EOF';
|
||||
}
|
||||
}
|
||||
225
app/Console/Commands/SeedTestOrders.php
Normal file
225
app/Console/Commands/SeedTestOrders.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Location;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SeedTestOrders extends Command
|
||||
{
|
||||
protected $signature = 'seed:test-orders {--clean : Delete existing test orders first}';
|
||||
|
||||
protected $description = 'Create test orders at various statuses for testing the order flow';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->option('clean')) {
|
||||
$this->info('Cleaning up existing test orders...');
|
||||
$testOrders = Order::where('order_number', 'like', 'TEST-%')->get();
|
||||
foreach ($testOrders as $order) {
|
||||
// Delete order items first, then the order
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Creating test orders at various statuses...');
|
||||
|
||||
// Get a buyer business (retailer) and location
|
||||
$buyerBusiness = Business::where('business_type', 'retailer')->first();
|
||||
if (! $buyerBusiness) {
|
||||
$this->error('No buyer business found. Run the main seeder first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$buyerLocation = Location::where('business_id', $buyerBusiness->id)->first();
|
||||
if (! $buyerLocation) {
|
||||
$this->error('No buyer location found. Run the main seeder first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get a buyer user
|
||||
$buyerUser = User::where('user_type', 'buyer')->first();
|
||||
if (! $buyerUser) {
|
||||
$this->error('No buyer user found. Run the main seeder first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get products with batches and COAs
|
||||
$products = Product::with(['brand.business', 'batches.coaFiles'])
|
||||
->where('is_active', true)
|
||||
->whereHas('batches.coaFiles')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
if ($products->isEmpty()) {
|
||||
$this->error('No products with COAs found. Run seed:coa-data first.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$orders = [];
|
||||
|
||||
// 1. Order ready for pre-delivery review (after picking, before delivery)
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(3),
|
||||
'ready_for_delivery',
|
||||
'TEST-PREDELIVERY-001',
|
||||
'Order ready for pre-delivery review (Review #1)'
|
||||
);
|
||||
|
||||
// 2. Order delivered and ready for post-delivery acceptance (Review #2)
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(3),
|
||||
'delivered',
|
||||
'TEST-DELIVERED-001',
|
||||
'Order delivered and ready for acceptance (Review #2)'
|
||||
);
|
||||
|
||||
// 3. Order in progress (picking)
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(2),
|
||||
'in_progress',
|
||||
'TEST-PICKING-001',
|
||||
'Order currently being picked'
|
||||
);
|
||||
|
||||
// 4. Order accepted and approved for delivery
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(2),
|
||||
'approved_for_delivery',
|
||||
'TEST-APPROVED-001',
|
||||
'Order approved for delivery (passed Review #1)'
|
||||
);
|
||||
|
||||
// 5. Order out for delivery
|
||||
$orders[] = $this->createTestOrder(
|
||||
$buyerBusiness,
|
||||
$buyerLocation,
|
||||
$products->random(2),
|
||||
'out_for_delivery',
|
||||
'TEST-OUTDELIVERY-001',
|
||||
'Order out for delivery'
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('✓ Created '.count($orders).' test orders');
|
||||
$this->newLine();
|
||||
|
||||
$this->table(
|
||||
['Order Number', 'Status', 'Items', 'Description'],
|
||||
collect($orders)->map(fn ($order) => [
|
||||
$order->order_number,
|
||||
$order->status,
|
||||
$order->items->count(),
|
||||
$this->getOrderDescription($order->order_number),
|
||||
])
|
||||
);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('You can now test the order flow in the UI:');
|
||||
$this->line(' • Pre-delivery review: /b/'.$buyerBusiness->slug.'/orders/TEST-PREDELIVERY-001/pre-delivery-review');
|
||||
$this->line(' • Post-delivery acceptance: /b/'.$buyerBusiness->slug.'/orders/TEST-DELIVERED-001/acceptance');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function createTestOrder(
|
||||
Business $buyerBusiness,
|
||||
Location $buyerLocation,
|
||||
$products,
|
||||
string $status,
|
||||
string $orderNumber,
|
||||
string $description
|
||||
): Order {
|
||||
return DB::transaction(function () use ($buyerBusiness, $buyerLocation, $products, $status, $orderNumber) {
|
||||
// Get first product's seller business
|
||||
$sellerBusiness = $products->first()->brand->business;
|
||||
|
||||
// Calculate totals
|
||||
$subtotal = $products->sum(function ($product) {
|
||||
return $product->wholesale_price * 5; // 5 units each
|
||||
});
|
||||
|
||||
$surchargePercent = Order::getSurchargePercentage('net_30');
|
||||
$surcharge = $subtotal * ($surchargePercent / 100);
|
||||
$taxRate = $buyerBusiness->getTaxRate();
|
||||
$tax = ($subtotal + $surcharge) * $taxRate;
|
||||
$total = $subtotal + $surcharge + $tax;
|
||||
|
||||
// Create order
|
||||
$order = Order::create([
|
||||
'order_number' => $orderNumber,
|
||||
'business_id' => $buyerBusiness->id,
|
||||
'seller_business_id' => $sellerBusiness->id,
|
||||
'location_id' => $buyerLocation->id,
|
||||
'status' => $status,
|
||||
'fulfillment_method' => 'delivery',
|
||||
'payment_terms' => 'net_30',
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'surcharge' => $surcharge,
|
||||
'total' => $total,
|
||||
'notes' => 'Test order for flow testing',
|
||||
]);
|
||||
|
||||
// Create order items with batch allocation
|
||||
foreach ($products as $product) {
|
||||
$batch = $product->batches->first();
|
||||
$quantity = 5;
|
||||
|
||||
// Allocate inventory
|
||||
if ($batch) {
|
||||
$batch->allocate($quantity);
|
||||
}
|
||||
|
||||
OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'batch_id' => $batch?->id,
|
||||
'product_name' => $product->name,
|
||||
'product_sku' => $product->sku,
|
||||
'brand_name' => $product->brand->name,
|
||||
'batch_number' => $batch?->batch_number,
|
||||
'quantity' => $quantity,
|
||||
'unit_price' => $product->wholesale_price,
|
||||
'line_total' => $product->wholesale_price * $quantity,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order->fresh(['items']);
|
||||
});
|
||||
}
|
||||
|
||||
private function getOrderDescription(string $orderNumber): string
|
||||
{
|
||||
return match (true) {
|
||||
str_contains($orderNumber, 'PREDELIVERY') => 'Order ready for pre-delivery review (Review #1)',
|
||||
str_contains($orderNumber, 'DELIVERED') => 'Order delivered and ready for acceptance (Review #2)',
|
||||
str_contains($orderNumber, 'PICKING') => 'Order currently being picked',
|
||||
str_contains($orderNumber, 'APPROVED') => 'Order approved for delivery (passed Review #1)',
|
||||
str_contains($orderNumber, 'OUTDELIVERY') => 'Order out for delivery',
|
||||
default => 'Test order',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,10 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule)
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
// Check for scheduled broadcasts every minute
|
||||
$schedule->job(new \App\Jobs\Marketing\ProcessScheduledBroadcastsJob)
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
56
app/Events/HighIntentBuyerDetected.php
Normal file
56
app/Events/HighIntentBuyerDetected.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class HighIntentBuyerDetected implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $sellerBusinessId,
|
||||
public int $buyerBusinessId,
|
||||
public IntentSignal $signal,
|
||||
public ?BuyerEngagementScore $engagementScore = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the channels the event should broadcast on.
|
||||
*/
|
||||
public function broadcastOn(): Channel
|
||||
{
|
||||
return new Channel("business.{$this->sellerBusinessId}.analytics");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data to broadcast.
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'buyer_business_id' => $this->buyerBusinessId,
|
||||
'buyer_business_name' => $this->signal->buyerBusiness?->name,
|
||||
'signal_type' => $this->signal->signal_type,
|
||||
'signal_strength' => $this->signal->signal_strength,
|
||||
'product_id' => $this->signal->subject_type === 'App\Models\Product' ? $this->signal->subject_id : null,
|
||||
'total_engagement_score' => $this->engagementScore?->total_score,
|
||||
'detected_at' => $this->signal->detected_at->toIso8601String(),
|
||||
'context' => $this->signal->context,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's broadcast name.
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'high-intent-buyer-detected';
|
||||
}
|
||||
}
|
||||
192
app/Filament/Pages/NotificationSettings.php
Normal file
192
app/Filament/Pages/NotificationSettings.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class NotificationSettings extends Page
|
||||
{
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-envelope';
|
||||
|
||||
protected string $view = 'filament.pages.notification-settings';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?string $navigationLabel = 'Notification Settings';
|
||||
|
||||
protected static ?int $navigationSort = 98;
|
||||
|
||||
public ?array $data = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->form->fill([
|
||||
// Mail settings
|
||||
'mail_driver' => config('mail.default'),
|
||||
'mail_host' => config('mail.mailers.smtp.host'),
|
||||
'mail_port' => config('mail.mailers.smtp.port'),
|
||||
'mail_username' => config('mail.mailers.smtp.username'),
|
||||
'mail_password' => config('mail.mailers.smtp.password'),
|
||||
'mail_encryption' => config('mail.mailers.smtp.encryption'),
|
||||
'mail_from_address' => config('mail.from.address'),
|
||||
'mail_from_name' => config('mail.from.name'),
|
||||
|
||||
// SMS settings (Twilio example)
|
||||
'sms_enabled' => env('SMS_ENABLED', false),
|
||||
'sms_provider' => env('SMS_PROVIDER', 'twilio'),
|
||||
'twilio_sid' => env('TWILIO_SID'),
|
||||
'twilio_auth_token' => env('TWILIO_AUTH_TOKEN'),
|
||||
'twilio_phone_number' => env('TWILIO_PHONE_NUMBER'),
|
||||
|
||||
// WhatsApp settings
|
||||
'whatsapp_enabled' => env('WHATSAPP_ENABLED', false),
|
||||
'whatsapp_provider' => env('WHATSAPP_PROVIDER', 'twilio'),
|
||||
'whatsapp_business_number' => env('WHATSAPP_BUSINESS_NUMBER'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\Tabs::make('Notification Providers')
|
||||
->tabs([
|
||||
Forms\Components\Tabs\Tab::make('Email')
|
||||
->icon('heroicon-o-envelope')
|
||||
->schema([
|
||||
Forms\Components\Section::make('Email Provider Configuration')
|
||||
->description('Configure your email provider for sending transactional emails')
|
||||
->schema([
|
||||
Forms\Components\Select::make('mail_driver')
|
||||
->label('Mail Driver')
|
||||
->options([
|
||||
'smtp' => 'SMTP',
|
||||
'sendmail' => 'Sendmail',
|
||||
'mailgun' => 'Mailgun',
|
||||
'ses' => 'Amazon SES',
|
||||
'postmark' => 'Postmark',
|
||||
])
|
||||
->required()
|
||||
->reactive(),
|
||||
Forms\Components\Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('mail_host')
|
||||
->label('SMTP Host')
|
||||
->required()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_port')
|
||||
->label('SMTP Port')
|
||||
->required()
|
||||
->numeric()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_username')
|
||||
->label('Username')
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_password')
|
||||
->label('Password')
|
||||
->password()
|
||||
->revealable()
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\Select::make('mail_encryption')
|
||||
->label('Encryption')
|
||||
->options([
|
||||
'tls' => 'TLS',
|
||||
'ssl' => 'SSL',
|
||||
'' => 'None',
|
||||
])
|
||||
->visible(fn ($get) => $get('mail_driver') === 'smtp'),
|
||||
Forms\Components\TextInput::make('mail_from_address')
|
||||
->label('From Address')
|
||||
->email()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('mail_from_name')
|
||||
->label('From Name')
|
||||
->required(),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('SMS')
|
||||
->icon('heroicon-o-device-phone-mobile')
|
||||
->schema([
|
||||
Forms\Components\Section::make('SMS Provider Configuration')
|
||||
->description('Configure your SMS provider for sending text messages')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('sms_enabled')
|
||||
->label('Enable SMS Notifications')
|
||||
->reactive(),
|
||||
Forms\Components\Select::make('sms_provider')
|
||||
->label('SMS Provider')
|
||||
->options([
|
||||
'twilio' => 'Twilio',
|
||||
'nexmo' => 'Vonage (Nexmo)',
|
||||
'aws_sns' => 'AWS SNS',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->visible(fn ($get) => $get('sms_enabled')),
|
||||
Forms\Components\Grid::make(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('twilio_sid')
|
||||
->label('Twilio Account SID')
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
Forms\Components\TextInput::make('twilio_auth_token')
|
||||
->label('Twilio Auth Token')
|
||||
->password()
|
||||
->revealable()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
Forms\Components\TextInput::make('twilio_phone_number')
|
||||
->label('Twilio Phone Number')
|
||||
->tel()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('sms_enabled') && $get('sms_provider') === 'twilio'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
Forms\Components\Tabs\Tab::make('WhatsApp')
|
||||
->icon('heroicon-o-chat-bubble-left-right')
|
||||
->schema([
|
||||
Forms\Components\Section::make('WhatsApp Configuration')
|
||||
->description('Configure WhatsApp Business API for sending messages')
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('whatsapp_enabled')
|
||||
->label('Enable WhatsApp Notifications')
|
||||
->reactive(),
|
||||
Forms\Components\Select::make('whatsapp_provider')
|
||||
->label('WhatsApp Provider')
|
||||
->options([
|
||||
'twilio' => 'Twilio WhatsApp',
|
||||
'whatsapp_cloud' => 'WhatsApp Cloud API',
|
||||
])
|
||||
->required()
|
||||
->reactive()
|
||||
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||
Forms\Components\TextInput::make('whatsapp_business_number')
|
||||
->label('WhatsApp Business Number')
|
||||
->tel()
|
||||
->required()
|
||||
->visible(fn ($get) => $get('whatsapp_enabled')),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->statePath('data');
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
// TODO: Save settings to environment file or database
|
||||
// For now, this would require implementing a settings storage system
|
||||
|
||||
Notification::make()
|
||||
->title('Settings saved')
|
||||
->success()
|
||||
->body('Note: These settings are read from .env file. To persist changes, update your .env file.')
|
||||
->send();
|
||||
}
|
||||
}
|
||||
160
app/Filament/Resources/BatchResource.php
Normal file
160
app/Filament/Resources/BatchResource.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\Batches\Schemas\BatchForm;
|
||||
use App\Filament\Resources\Batches\Tables\BatchesTable;
|
||||
use App\Filament\Resources\BatchResource\Pages;
|
||||
use App\Models\Batch;
|
||||
use App\Services\QrCodeService;
|
||||
use BackedEnum;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use UnitEnum;
|
||||
|
||||
class BatchResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Batch::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
|
||||
|
||||
protected static ?string $navigationLabel = 'Batches';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return BatchForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
$table = BatchesTable::configure($table);
|
||||
|
||||
// Add custom QR and COA actions
|
||||
return $table
|
||||
->recordActions(array_merge(
|
||||
$table->getRecordActions(),
|
||||
[
|
||||
Action::make('generate_qr')
|
||||
->label('Generate QR')
|
||||
->icon('heroicon-o-qr-code')
|
||||
->action(function (Batch $record) {
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->generateForBatch($record);
|
||||
|
||||
if ($result['success']) {
|
||||
Notification::make()
|
||||
->title('QR Code Generated')
|
||||
->body($result['message'])
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Failed to generate QR code')
|
||||
->body($result['message'])
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
->visible(fn (Batch $record) => ! $record->qr_code_path),
|
||||
|
||||
Action::make('download_qr')
|
||||
->label('Download QR')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->url(fn (Batch $record) => route('seller.business.manufacturing.batches.qr-code.download', [
|
||||
'business' => $record->business->slug,
|
||||
'batch' => $record->id,
|
||||
]))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (Batch $record) => $record->qr_code_path),
|
||||
|
||||
Action::make('regenerate_qr')
|
||||
->label('Regenerate QR')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->action(function (Batch $record) {
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->regenerate($record);
|
||||
|
||||
if ($result['success']) {
|
||||
Notification::make()
|
||||
->title('QR Code Regenerated')
|
||||
->success()
|
||||
->send();
|
||||
} else {
|
||||
Notification::make()
|
||||
->title('Failed to regenerate QR code')
|
||||
->body($result['message'])
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
})
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Batch $record) => $record->qr_code_path),
|
||||
|
||||
Action::make('view_coa')
|
||||
->label('View COA')
|
||||
->icon('heroicon-o-document-text')
|
||||
->url(fn (Batch $record) => route('public.coa.show', ['batchNumber' => $record->batch_number]))
|
||||
->openUrlInNewTab()
|
||||
->visible(fn (Batch $record) => $record->lab !== null),
|
||||
]
|
||||
))
|
||||
->bulkActions(array_merge(
|
||||
$table->getBulkActions(),
|
||||
[
|
||||
BulkAction::make('generate_qr_codes')
|
||||
->label('Generate QR Codes')
|
||||
->icon('heroicon-o-qr-code')
|
||||
->action(function (Collection $records) {
|
||||
$qrService = app(QrCodeService::class);
|
||||
$batchIds = $records->pluck('id')->toArray();
|
||||
$result = $qrService->bulkGenerate($batchIds);
|
||||
|
||||
Notification::make()
|
||||
->title("Generated {$result['successful']} QR codes")
|
||||
->body("Failed: {$result['failed']}")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
// Scope to user's business unless they're a super admin
|
||||
if (! auth()->user()->hasRole('super_admin')) {
|
||||
$query->where('business_id', auth()->user()->business_id);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBatches::route('/'),
|
||||
'create' => Pages\CreateBatch::route('/create'),
|
||||
'view' => Pages\ViewBatch::route('/{record}'),
|
||||
'edit' => Pages\EditBatch::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Filament/Resources/BatchResource/Pages/CreateBatch.php
Normal file
18
app/Filament/Resources/BatchResource/Pages/CreateBatch.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BatchResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BatchResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBatch extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BatchResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['business_id'] = auth()->user()->business_id;
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
20
app/Filament/Resources/BatchResource/Pages/EditBatch.php
Normal file
20
app/Filament/Resources/BatchResource/Pages/EditBatch.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BatchResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BatchResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBatch extends EditRecord
|
||||
{
|
||||
protected static string $resource = BatchResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/BatchResource/Pages/ListBatches.php
Normal file
19
app/Filament/Resources/BatchResource/Pages/ListBatches.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BatchResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BatchResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBatches extends ListRecords
|
||||
{
|
||||
protected static string $resource = BatchResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/BatchResource/Pages/ViewBatch.php
Normal file
19
app/Filament/Resources/BatchResource/Pages/ViewBatch.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BatchResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BatchResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBatch extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BatchResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\Batches\Schemas;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -18,84 +19,144 @@ class BatchForm
|
||||
->components([
|
||||
Section::make('Batch Information')
|
||||
->schema([
|
||||
TextInput::make('batch_number')
|
||||
->label('Batch Number')
|
||||
->placeholder('Auto-generated if left blank')
|
||||
->maxLength(255)
|
||||
->helperText('Unique identifier for this batch'),
|
||||
|
||||
Select::make('product_id')
|
||||
->label('Product')
|
||||
->relationship('product', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->columnSpan(2),
|
||||
TextInput::make('batch_number')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->helperText('Unique identifier for this batch (e.g., TB-AM-240315)'),
|
||||
TextInput::make('internal_code')
|
||||
->helperText('Internal production/tracking code (optional)'),
|
||||
])
|
||||
->columns(2),
|
||||
->required(),
|
||||
|
||||
Section::make('Production Dates')
|
||||
->schema([
|
||||
DatePicker::make('production_date')
|
||||
->helperText('Date the batch was produced/manufactured'),
|
||||
DatePicker::make('harvest_date')
|
||||
->helperText('Harvest date (for flower products)'),
|
||||
DatePicker::make('package_date')
|
||||
->helperText('Date the batch was packaged'),
|
||||
DatePicker::make('expiration_date')
|
||||
->helperText('Expiration/best-by date'),
|
||||
Select::make('batch_type')
|
||||
->label('Batch Type')
|
||||
->options([
|
||||
'intake' => 'Intake',
|
||||
'production' => 'Production',
|
||||
'finished' => 'Finished',
|
||||
])
|
||||
->default('finished')
|
||||
->required()
|
||||
->helperText('Type of batch in the production process'),
|
||||
|
||||
Select::make('lab_id')
|
||||
->label('Lab Test')
|
||||
->relationship('lab', 'lab_name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Associated lab test results'),
|
||||
|
||||
Select::make('parent_batch_id')
|
||||
->label('Parent Batch')
|
||||
->relationship('parentBatch', 'batch_number')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Parent batch if this was produced from another batch'),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Inventory Management')
|
||||
->schema([
|
||||
TextInput::make('quantity_produced')
|
||||
->label('Quantity Produced')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Total units produced in this batch'),
|
||||
|
||||
TextInput::make('quantity_available')
|
||||
->label('Quantity Available')
|
||||
->required()
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Units currently available for sale'),
|
||||
|
||||
TextInput::make('quantity_allocated')
|
||||
->label('Quantity Allocated')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Units reserved in pending orders (auto-calculated)'),
|
||||
|
||||
TextInput::make('quantity_sold')
|
||||
->label('Quantity Sold')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Units already sold (auto-calculated)'),
|
||||
])
|
||||
->columns(2)
|
||||
->columns(4)
|
||||
->description('Allocated and sold quantities are automatically managed by the system.'),
|
||||
|
||||
Section::make('Status & Compliance')
|
||||
Section::make('Dates')
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->default(true)
|
||||
->helperText('Is this batch available for sale?'),
|
||||
Toggle::make('is_tested')
|
||||
->default(false)
|
||||
->helperText('Has this batch passed lab testing?'),
|
||||
Toggle::make('is_quarantined')
|
||||
->default(false)
|
||||
->helperText('Is this batch quarantined pending results?'),
|
||||
])
|
||||
->columns(3),
|
||||
DatePicker::make('production_date')
|
||||
->label('Production Date')
|
||||
->helperText('Date the batch was produced/manufactured'),
|
||||
|
||||
Section::make('Additional Information')
|
||||
DatePicker::make('intake_date')
|
||||
->label('Intake Date')
|
||||
->helperText('Date the batch was received/intake'),
|
||||
|
||||
DatePicker::make('expiration_date')
|
||||
->label('Expiration Date')
|
||||
->helperText('Expiration/best-by date'),
|
||||
|
||||
DatePicker::make('test_date')
|
||||
->label('Test Date')
|
||||
->helperText('Date of lab testing'),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Warehouse & Location')
|
||||
->schema([
|
||||
TextInput::make('warehouse_location')
|
||||
->label('Warehouse Location')
|
||||
->placeholder('e.g., Shelf A-15')
|
||||
->maxLength(255)
|
||||
->helperText('Physical location in warehouse'),
|
||||
|
||||
TextInput::make('container_type')
|
||||
->label('Container Type')
|
||||
->placeholder('e.g., Turkey Bag, Box')
|
||||
->maxLength(255)
|
||||
->helperText('Type of container batch is stored in'),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Quality & Compliance')
|
||||
->schema([
|
||||
Toggle::make('is_quarantined')
|
||||
->label('Quarantined')
|
||||
->default(false)
|
||||
->helperText('Is this batch quarantined?')
|
||||
->reactive(),
|
||||
|
||||
Textarea::make('quarantine_reason')
|
||||
->label('Quarantine Reason')
|
||||
->rows(2)
|
||||
->helperText('Reason for quarantine')
|
||||
->visible(fn (Forms\Get $get) => $get('is_quarantined'))
|
||||
->columnSpanFull(),
|
||||
|
||||
Toggle::make('is_released_for_sale')
|
||||
->label('Released for Sale')
|
||||
->default(false)
|
||||
->helperText('Has this batch been released for sale?'),
|
||||
|
||||
Textarea::make('notes')
|
||||
->label('Notes')
|
||||
->rows(3)
|
||||
->helperText('Production notes, special handling instructions, etc.')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible(),
|
||||
->columns(2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,18 +23,35 @@ class BatchesTable
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('batch_number')
|
||||
->label('Batch #')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->copyable()
|
||||
->weight('bold'),
|
||||
TextColumn::make('product.name')
|
||||
->label('Product')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->description(fn ($record) => $record->product->sku ?? null),
|
||||
->description(fn ($record) => $record->product->sku ?? null)
|
||||
->limit(30),
|
||||
TextColumn::make('batch_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'intake' => 'info',
|
||||
'production' => 'warning',
|
||||
'finished' => 'success',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('warehouse_location')
|
||||
->label('Location')
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
TextColumn::make('production_date')
|
||||
->label('Produced')
|
||||
->date()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('expiration_date')
|
||||
->date()
|
||||
->sortable()
|
||||
@@ -60,14 +77,13 @@ class BatchesTable
|
||||
->label('Status')
|
||||
->badge()
|
||||
->getStateUsing(fn ($record) => $record->is_quarantined ? 'Quarantined' :
|
||||
(! $record->is_active ? 'Inactive' :
|
||||
(! $record->is_tested ? 'Pending Test' : 'Active'))
|
||||
(! $record->is_released_for_sale ? 'Not Released' : 'Released')
|
||||
)
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'Active' => Color::Green,
|
||||
'Pending Test' => Color::Yellow,
|
||||
'Released' => Color::Green,
|
||||
'Not Released' => Color::Yellow,
|
||||
'Quarantined' => Color::Red,
|
||||
'Inactive' => Color::Gray,
|
||||
default => Color::Gray,
|
||||
}),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
@@ -80,19 +96,23 @@ class BatchesTable
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('batch_type')
|
||||
->options([
|
||||
'intake' => 'Intake',
|
||||
'production' => 'Production',
|
||||
'finished' => 'Finished',
|
||||
]),
|
||||
SelectFilter::make('product')
|
||||
->relationship('product', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
Filter::make('active')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_active', true))
|
||||
Filter::make('released')
|
||||
->label('Released for Sale')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_released_for_sale', true))
|
||||
->toggle(),
|
||||
Filter::make('available')
|
||||
->query(fn (Builder $query): Builder => $query->where('quantity_available', '>', 0))
|
||||
->toggle(),
|
||||
Filter::make('tested')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_tested', true))
|
||||
->toggle(),
|
||||
Filter::make('quarantined')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_quarantined', true))
|
||||
->toggle(),
|
||||
|
||||
@@ -57,7 +57,7 @@ class BrandResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -454,6 +454,58 @@ class BusinessResource extends Resource
|
||||
->columns(2),
|
||||
]),
|
||||
|
||||
Tab::make('Modules')
|
||||
->schema([
|
||||
Section::make('Premium Feature Modules')
|
||||
->description('Enable optional premium features for this business. Modules are activated on a per-business basis.')
|
||||
->schema([
|
||||
Grid::make(1)
|
||||
->schema([
|
||||
Toggle::make('has_analytics')
|
||||
->label('Buyer Intelligence Module')
|
||||
->helperText('Premium analytics: Buyer engagement tracking, intent signals, RFDI scoring, email campaign analytics')
|
||||
->default(false)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('has_marketing')
|
||||
->label('Marketing Module')
|
||||
->helperText('Email campaigns, marketing automation, broadcast messages')
|
||||
->default(false)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('has_manufacturing')
|
||||
->label('Manufacturing Module')
|
||||
->helperText('Production tracking, batch management, quality control')
|
||||
->default(false)
|
||||
->inline(false),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Module Information')
|
||||
->description('Module activation status and billing information')
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('active_modules_count')
|
||||
->label('Active Modules')
|
||||
->content(function ($record) {
|
||||
if (! $record) {
|
||||
return '0';
|
||||
}
|
||||
$count = 0;
|
||||
if ($record->has_analytics) {
|
||||
$count++;
|
||||
}
|
||||
if ($record->has_marketing) {
|
||||
$count++;
|
||||
}
|
||||
if ($record->has_manufacturing) {
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count.' module'.($count !== 1 ? 's' : '').' enabled';
|
||||
}),
|
||||
])
|
||||
->columns(1),
|
||||
]),
|
||||
Tab::make('Status & Settings')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
@@ -547,6 +599,24 @@ class BusinessResource extends Resource
|
||||
})
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('modules')
|
||||
->label('Active Modules')
|
||||
->formatStateUsing(function ($record) {
|
||||
$modules = [];
|
||||
if ($record->has_analytics) {
|
||||
$modules[] = 'Analytics';
|
||||
}
|
||||
if ($record->has_marketing) {
|
||||
$modules[] = 'Marketing';
|
||||
}
|
||||
if ($record->has_manufacturing) {
|
||||
$modules[] = 'Manufacturing';
|
||||
}
|
||||
|
||||
return empty($modules) ? 'None' : implode(', ', $modules);
|
||||
})
|
||||
->badge()
|
||||
->color(fn ($record) => ($record->has_analytics || $record->has_marketing || $record->has_manufacturing) ? 'success' : 'gray'),
|
||||
BadgeColumn::make('status')
|
||||
->label('Status')
|
||||
->formatStateUsing(fn (string $state): string => ucfirst(str_replace('_', ' ', $state)))
|
||||
|
||||
@@ -52,7 +52,7 @@ class ComponentResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
63
app/Filament/Resources/EmailTemplateResource.php
Normal file
63
app/Filament/Resources/EmailTemplateResource.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource\Pages\CreateEmailTemplate;
|
||||
use App\Filament\Resources\EmailTemplateResource\Pages\EditEmailTemplate;
|
||||
use App\Filament\Resources\EmailTemplateResource\Pages\ListEmailTemplates;
|
||||
use App\Filament\Resources\EmailTemplateResource\Pages\ViewEmailTemplate;
|
||||
use App\Filament\Resources\EmailTemplateResource\Schemas\EmailTemplateForm;
|
||||
use App\Filament\Resources\EmailTemplateResource\Schemas\EmailTemplateInfolist;
|
||||
use App\Filament\Resources\EmailTemplateResource\Tables\EmailTemplatesTable;
|
||||
use App\Models\EmailTemplate;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class EmailTemplateResource extends Resource
|
||||
{
|
||||
protected static ?string $model = EmailTemplate::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-envelope';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?int $navigationSort = 10;
|
||||
|
||||
protected static ?string $navigationLabel = 'Email Templates';
|
||||
|
||||
protected static ?string $modelLabel = 'Email Template';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Email Templates';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Count inactive templates
|
||||
return static::getModel()::where('is_active', false)->count() ?: null;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return EmailTemplateForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return EmailTemplateInfolist::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return EmailTemplatesTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListEmailTemplates::route('/'),
|
||||
'create' => CreateEmailTemplate::route('/create'),
|
||||
'view' => ViewEmailTemplate::route('/{record}'),
|
||||
'edit' => EditEmailTemplate::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateEmailTemplate extends CreateRecord
|
||||
{
|
||||
protected static string $resource = EmailTemplateResource::class;
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditEmailTemplate extends EditRecord
|
||||
{
|
||||
protected static string $resource = EmailTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
ViewAction::make(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getRedirectUrl(): string
|
||||
{
|
||||
return $this->getResource()::getUrl('index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEmailTemplates extends ListRecords
|
||||
{
|
||||
protected static string $resource = EmailTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmailTemplateResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewEmailTemplate extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EmailTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Schemas;
|
||||
|
||||
use App\Models\EmailTemplate;
|
||||
use Filament\Forms\Components\Checkbox;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class EmailTemplateForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(1)
|
||||
->components([
|
||||
Section::make('Template Details')
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->label('Template Key')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->regex('/^[a-z0-9_-]+$/')
|
||||
->helperText('Lowercase alphanumeric characters, hyphens and underscores only')
|
||||
->disabled(fn ($context) => $context === 'edit')
|
||||
->dehydrated(fn ($context) => $context === 'create')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('name')
|
||||
->label('Template Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('subject')
|
||||
->label('Email Subject')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
|
||||
Textarea::make('description')
|
||||
->label('Description')
|
||||
->helperText('Describe when this template is used')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('available_variables')
|
||||
->label('Available Variables')
|
||||
->helperText('Comma-separated list (e.g., verification_url, email, logo_url)')
|
||||
->afterStateHydrated(function (TextInput $component, $state) {
|
||||
if (is_array($state)) {
|
||||
$component->state(implode(', ', $state));
|
||||
}
|
||||
})
|
||||
->dehydrateStateUsing(function ($state) {
|
||||
if (empty($state)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map('trim', explode(',', $state));
|
||||
})
|
||||
->columnSpanFull(),
|
||||
|
||||
Checkbox::make('is_active')
|
||||
->label('Template is Active')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
])
|
||||
->columns(2),
|
||||
|
||||
Section::make('Email Content')
|
||||
->schema([
|
||||
Textarea::make('body_html')
|
||||
->label('HTML Body')
|
||||
->required()
|
||||
->rows(25)
|
||||
->helperText('Use {{ $variable }} syntax for dynamic content')
|
||||
->columnSpanFull()
|
||||
->extraAttributes(['style' => 'font-family: monospace; font-size: 13px;']),
|
||||
|
||||
Textarea::make('body_text')
|
||||
->label('Plain Text Body (Optional)')
|
||||
->rows(15)
|
||||
->helperText('Plain text fallback for email clients that don\'t support HTML')
|
||||
->columnSpanFull()
|
||||
->extraAttributes(['style' => 'font-family: monospace; font-size: 13px;']),
|
||||
]),
|
||||
|
||||
Section::make('Metadata')
|
||||
->schema([
|
||||
Placeholder::make('created_at')
|
||||
->label('Created At')
|
||||
->content(fn (?EmailTemplate $record): string => $record?->created_at?->diffForHumans() ?? '-'),
|
||||
|
||||
Placeholder::make('updated_at')
|
||||
->label('Last Updated')
|
||||
->content(fn (?EmailTemplate $record): string => $record?->updated_at?->diffForHumans() ?? '-'),
|
||||
])
|
||||
->columns(2)
|
||||
->hidden(fn ($context) => $context === 'create'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Schemas;
|
||||
|
||||
use Filament\Infolists\Components\IconEntry;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\HtmlString;
|
||||
|
||||
class EmailTemplateInfolist
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextEntry::make('name')
|
||||
->label('Template Name')
|
||||
->columnSpan(1),
|
||||
|
||||
TextEntry::make('key')
|
||||
->label('Template Key')
|
||||
->badge()
|
||||
->copyable()
|
||||
->copyMessage('Key copied!')
|
||||
->copyMessageDuration(1500)
|
||||
->columnSpan(1),
|
||||
|
||||
TextEntry::make('subject')
|
||||
->label('Email Subject')
|
||||
->columnSpan(2),
|
||||
|
||||
TextEntry::make('description')
|
||||
->label('Description')
|
||||
->columnSpan(2)
|
||||
->placeholder('No description provided'),
|
||||
|
||||
TextEntry::make('available_variables')
|
||||
->label('Available Variables')
|
||||
->badge()
|
||||
->separator(',')
|
||||
->columnSpan(2)
|
||||
->placeholder('No variables defined'),
|
||||
|
||||
IconEntry::make('is_active')
|
||||
->label('Status')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('danger')
|
||||
->columnSpan(1),
|
||||
|
||||
TextEntry::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime()
|
||||
->since()
|
||||
->columnSpan(1),
|
||||
|
||||
TextEntry::make('updated_at')
|
||||
->label('Last Updated')
|
||||
->dateTime()
|
||||
->since()
|
||||
->columnSpan(1),
|
||||
|
||||
ViewEntry::make('preview')
|
||||
->label('HTML Preview')
|
||||
->viewData(fn ($record) => [
|
||||
'html' => $record->body_html,
|
||||
])
|
||||
->view('filament.email-template-preview')
|
||||
->columnSpan(2),
|
||||
|
||||
TextEntry::make('body_html')
|
||||
->label('HTML Source')
|
||||
->formatStateUsing(fn ($state) => new HtmlString('<pre class="text-xs font-mono bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto whitespace-pre-wrap">'.htmlspecialchars($state).'</pre>'))
|
||||
->columnSpan(2),
|
||||
|
||||
TextEntry::make('body_text')
|
||||
->label('Plain Text Version')
|
||||
->formatStateUsing(fn ($state) => new HtmlString('<pre class="text-xs font-mono bg-gray-100 dark:bg-gray-900 p-4 rounded overflow-x-auto whitespace-pre-wrap">'.htmlspecialchars($state ?: 'No plain text version').'</pre>'))
|
||||
->columnSpan(2)
|
||||
->hidden(fn ($record) => empty($record->body_text)),
|
||||
])
|
||||
->columns(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmailTemplateResource\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class EmailTemplatesTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Template Name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('key')
|
||||
->label('Key')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->fontFamily('mono')
|
||||
->size('sm')
|
||||
->copyable()
|
||||
->copyMessage('Key copied!')
|
||||
->copyMessageDuration(1500),
|
||||
|
||||
TextColumn::make('subject')
|
||||
->label('Subject')
|
||||
->searchable()
|
||||
->limit(50)
|
||||
->wrap(),
|
||||
|
||||
IconColumn::make('is_active')
|
||||
->label('Status')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor('success')
|
||||
->falseColor('danger')
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('updated_at')
|
||||
->label('Last Updated')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->since()
|
||||
->size('sm'),
|
||||
])
|
||||
->defaultSort('name')
|
||||
->filters([
|
||||
SelectFilter::make('is_active')
|
||||
->label('Status')
|
||||
->options([
|
||||
true => 'Active',
|
||||
false => 'Inactive',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
168
app/Filament/Resources/FailedJobResource.php
Normal file
168
app/Filament/Resources/FailedJobResource.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource\Pages;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FailedJobResource extends Resource
|
||||
{
|
||||
protected static ?string $model = null;
|
||||
|
||||
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
|
||||
protected static ?string $navigationLabel = 'Failed Jobs';
|
||||
|
||||
protected static \UnitEnum|string|null $navigationGroup = 'System';
|
||||
|
||||
protected static ?int $navigationSort = 99;
|
||||
|
||||
public static function getModel(): string
|
||||
{
|
||||
return config('queue.failed.database') ?? 'failed_jobs';
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return DB::table('failed_jobs')->orderBy('failed_at', 'desc');
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->query(fn () => DB::table('failed_jobs')->orderBy('failed_at', 'desc'))
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('queue')
|
||||
->badge()
|
||||
->color('info')
|
||||
->sortable()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('connection')
|
||||
->badge()
|
||||
->color('gray')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('payload')
|
||||
->label('Job Type')
|
||||
->getStateUsing(function ($record) {
|
||||
$payload = json_decode($record->payload, true);
|
||||
$displayName = $payload['displayName'] ?? 'Unknown';
|
||||
// Extract just the class name
|
||||
if (str_contains($displayName, '\\')) {
|
||||
return class_basename($displayName);
|
||||
}
|
||||
|
||||
return $displayName;
|
||||
})
|
||||
->badge()
|
||||
->color('warning')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('exception')
|
||||
->label('Error')
|
||||
->limit(100)
|
||||
->tooltip(fn ($record) => $record->exception)
|
||||
->wrap()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('failed_at')
|
||||
->label('Failed At')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->since()
|
||||
->description(fn ($record) => $record->failed_at),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('queue')
|
||||
->options(function () {
|
||||
return DB::table('failed_jobs')
|
||||
->distinct()
|
||||
->pluck('queue', 'queue')
|
||||
->toArray();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\Action::make('retry')
|
||||
->label('Retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function ($record) {
|
||||
Artisan::call('queue:retry', ['id' => [$record->id]]);
|
||||
})
|
||||
->successNotificationTitle('Job queued for retry'),
|
||||
Tables\Actions\Action::make('view_details')
|
||||
->label('View Details')
|
||||
->icon('heroicon-o-eye')
|
||||
->modalHeading('Failed Job Details')
|
||||
->modalContent(function ($record) {
|
||||
$payload = json_decode($record->payload, true);
|
||||
|
||||
return view('filament.resources.failed-job.view-details', [
|
||||
'record' => $record,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
})
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close'),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->label('Delete')
|
||||
->action(fn ($record) => DB::table('failed_jobs')->where('id', $record->id)->delete())
|
||||
->successNotificationTitle('Failed job deleted'),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkAction::make('retry_selected')
|
||||
->label('Retry Selected')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
Artisan::call('queue:retry', ['id' => [$record->id]]);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion()
|
||||
->successNotificationTitle('Selected jobs queued for retry'),
|
||||
Tables\Actions\BulkAction::make('delete_selected')
|
||||
->label('Delete Selected')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(function ($records) {
|
||||
$ids = collect($records)->pluck('id')->toArray();
|
||||
DB::table('failed_jobs')->whereIn('id', $ids)->delete();
|
||||
})
|
||||
->deselectRecordsAfterCompletion()
|
||||
->successNotificationTitle('Selected jobs deleted'),
|
||||
])
|
||||
->defaultSort('failed_at', 'desc')
|
||||
->poll('30s')
|
||||
->emptyStateHeading('No Failed Jobs')
|
||||
->emptyStateDescription('All jobs are processing successfully!')
|
||||
->emptyStateIcon('heroicon-o-check-circle');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListFailedJobs::route('/'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ListFailedJobs extends ListRecords
|
||||
{
|
||||
protected static string $resource = FailedJobResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('retry_all')
|
||||
->label('Retry All Failed Jobs')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Retry All Failed Jobs?')
|
||||
->modalDescription('This will attempt to retry all failed jobs in the queue.')
|
||||
->action(function () {
|
||||
Artisan::call('queue:retry', ['id' => ['all']]);
|
||||
})
|
||||
->successNotificationTitle('All failed jobs queued for retry')
|
||||
->visible(fn () => DB::table('failed_jobs')->count() > 0),
|
||||
Actions\Action::make('flush_all')
|
||||
->label('Delete All Failed Jobs')
|
||||
->icon('heroicon-o-trash')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Delete All Failed Jobs?')
|
||||
->modalDescription('This will permanently delete all failed job records. This action cannot be undone.')
|
||||
->action(function () {
|
||||
Artisan::call('queue:flush');
|
||||
})
|
||||
->successNotificationTitle('All failed jobs deleted')
|
||||
->visible(fn () => DB::table('failed_jobs')->count() > 0),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
FailedJobResource\Widgets\FailedJobsStatsWidget::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Widgets;
|
||||
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class FailedJobsStatsWidget extends BaseWidget
|
||||
{
|
||||
protected function getStats(): array
|
||||
{
|
||||
$totalFailed = DB::table('failed_jobs')->count();
|
||||
$failedToday = DB::table('failed_jobs')
|
||||
->whereDate('failed_at', today())
|
||||
->count();
|
||||
$failedThisWeek = DB::table('failed_jobs')
|
||||
->where('failed_at', '>=', now()->startOfWeek())
|
||||
->count();
|
||||
|
||||
// Get most common failed job type
|
||||
$commonFailure = DB::table('failed_jobs')
|
||||
->select('payload')
|
||||
->get()
|
||||
->map(function ($job) {
|
||||
$payload = json_decode($job->payload, true);
|
||||
|
||||
return $payload['displayName'] ?? 'Unknown';
|
||||
})
|
||||
->countBy()
|
||||
->sortDesc()
|
||||
->first();
|
||||
|
||||
return [
|
||||
Stat::make('Total Failed Jobs', $totalFailed)
|
||||
->description('All time')
|
||||
->descriptionIcon('heroicon-m-exclamation-triangle')
|
||||
->color($totalFailed > 0 ? 'danger' : 'success'),
|
||||
Stat::make('Failed Today', $failedToday)
|
||||
->description(now()->format('M d, Y'))
|
||||
->descriptionIcon('heroicon-m-calendar')
|
||||
->color($failedToday > 0 ? 'warning' : 'success'),
|
||||
Stat::make('Failed This Week', $failedThisWeek)
|
||||
->description('Since '.now()->startOfWeek()->format('M d'))
|
||||
->descriptionIcon('heroicon-m-chart-bar')
|
||||
->color($failedThisWeek > 0 ? 'warning' : 'success'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getPollingInterval(): ?string
|
||||
{
|
||||
return '30s';
|
||||
}
|
||||
}
|
||||
90
app/Filament/Resources/LabResource.php
Normal file
90
app/Filament/Resources/LabResource.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\LabResource\Pages;
|
||||
use App\Filament\Resources\LabResource\Schemas\LabForm;
|
||||
use App\Filament\Resources\LabResource\Tables\LabsTable;
|
||||
use App\Models\Lab;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class LabResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Lab::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-beaker';
|
||||
|
||||
protected static ?string $navigationLabel = 'Lab Tests';
|
||||
|
||||
protected static UnitEnum|string|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return LabForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return LabsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$query = parent::getEloquentQuery();
|
||||
|
||||
// Scope to user's business products and batches unless they're a super admin
|
||||
if (! auth()->user()->hasRole('super_admin')) {
|
||||
$businessId = auth()->user()->business_id;
|
||||
|
||||
$query->where(function ($q) use ($businessId) {
|
||||
// Include labs for products owned by this business
|
||||
$q->whereHas('product', function ($productQuery) use ($businessId) {
|
||||
$productQuery->whereHas('brand', function ($brandQuery) use ($businessId) {
|
||||
$brandQuery->where('business_id', $businessId);
|
||||
});
|
||||
})
|
||||
// OR labs for batches owned by this business
|
||||
->orWhereHas('batch', function ($batchQuery) use ($businessId) {
|
||||
$batchQuery->where('business_id', $businessId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListLabs::route('/'),
|
||||
'create' => Pages\CreateLab::route('/create'),
|
||||
'view' => Pages\ViewLab::route('/{record}'),
|
||||
'edit' => Pages\EditLab::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
// Show count of recent lab tests (last 30 days)
|
||||
return cache()->remember('recent_lab_tests_count', 300, function () {
|
||||
$query = static::getEloquentQuery();
|
||||
|
||||
return $query->where('test_date', '>=', now()->subDays(30))
|
||||
->count() ?: null;
|
||||
});
|
||||
}
|
||||
}
|
||||
11
app/Filament/Resources/LabResource/Pages/CreateLab.php
Normal file
11
app/Filament/Resources/LabResource/Pages/CreateLab.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Pages;
|
||||
|
||||
use App\Filament\Resources\LabResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateLab extends CreateRecord
|
||||
{
|
||||
protected static string $resource = LabResource::class;
|
||||
}
|
||||
20
app/Filament/Resources/LabResource/Pages/EditLab.php
Normal file
20
app/Filament/Resources/LabResource/Pages/EditLab.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Pages;
|
||||
|
||||
use App\Filament\Resources\LabResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditLab extends EditRecord
|
||||
{
|
||||
protected static string $resource = LabResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/LabResource/Pages/ListLabs.php
Normal file
19
app/Filament/Resources/LabResource/Pages/ListLabs.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Pages;
|
||||
|
||||
use App\Filament\Resources\LabResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListLabs extends ListRecords
|
||||
{
|
||||
protected static string $resource = LabResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Resources/LabResource/Pages/ViewLab.php
Normal file
19
app/Filament/Resources/LabResource/Pages/ViewLab.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Pages;
|
||||
|
||||
use App\Filament\Resources\LabResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewLab extends ViewRecord
|
||||
{
|
||||
protected static string $resource = LabResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
298
app/Filament/Resources/LabResource/Schemas/LabForm.php
Normal file
298
app/Filament/Resources/LabResource/Schemas/LabForm.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Schemas;
|
||||
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class LabForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('Lab Test Information')
|
||||
->tabs([
|
||||
Tab::make('Basic Information')
|
||||
->schema([
|
||||
Section::make('Test Details')
|
||||
->schema([
|
||||
Select::make('product_id')
|
||||
->label('Product')
|
||||
->relationship('product', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Product this test is for'),
|
||||
|
||||
Select::make('batch_id')
|
||||
->label('Batch')
|
||||
->relationship('batch', 'batch_number')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Specific batch tested'),
|
||||
|
||||
TextInput::make('lab_name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Testing laboratory name'),
|
||||
|
||||
TextInput::make('lab_license_number')
|
||||
->label('Lab License #')
|
||||
->maxLength(255)
|
||||
->helperText('State license number'),
|
||||
|
||||
DatePicker::make('test_date')
|
||||
->required()
|
||||
->default(now())
|
||||
->helperText('Date test was performed'),
|
||||
|
||||
TextInput::make('batch_number')
|
||||
->label('Lab Batch Number')
|
||||
->maxLength(255)
|
||||
->helperText('Internal lab tracking number'),
|
||||
|
||||
TextInput::make('sample_id')
|
||||
->label('Sample ID')
|
||||
->maxLength(255)
|
||||
->helperText('Sample identification'),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
|
||||
Tab::make('Cannabinoids')
|
||||
->schema([
|
||||
Section::make('Primary Cannabinoids')
|
||||
->schema([
|
||||
TextInput::make('thc_percentage')
|
||||
->label('THC %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('thca_percentage')
|
||||
->label('THCA %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('cbd_percentage')
|
||||
->label('CBD %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('cbda_percentage')
|
||||
->label('CBDA %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
])
|
||||
->columns(4),
|
||||
|
||||
Section::make('Minor Cannabinoids')
|
||||
->schema([
|
||||
TextInput::make('cbg_percentage')
|
||||
->label('CBG %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('cbn_percentage')
|
||||
->label('CBN %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('thcv_percentage')
|
||||
->label('THCV %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
TextInput::make('cbdv_percentage')
|
||||
->label('CBDV %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
])
|
||||
->columns(4),
|
||||
|
||||
Section::make('Calculated Totals')
|
||||
->schema([
|
||||
TextInput::make('total_thc')
|
||||
->label('Total THC')
|
||||
->numeric()
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->suffix('%')
|
||||
->helperText('Auto-calculated from THC + (THCA × 0.877)'),
|
||||
|
||||
TextInput::make('total_cbd')
|
||||
->label('Total CBD')
|
||||
->numeric()
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->suffix('%')
|
||||
->helperText('Auto-calculated from CBD + (CBDA × 0.877)'),
|
||||
|
||||
TextInput::make('total_cannabinoids')
|
||||
->label('Total Cannabinoids')
|
||||
->numeric()
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->suffix('%')
|
||||
->helperText('Sum of all cannabinoids'),
|
||||
])
|
||||
->columns(3)
|
||||
->description('These values are automatically calculated on save'),
|
||||
]),
|
||||
|
||||
Tab::make('Terpenes')
|
||||
->schema([
|
||||
Repeater::make('terpenes')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->helperText('Terpene name (e.g., Myrcene)'),
|
||||
|
||||
TextInput::make('percentage')
|
||||
->required()
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->step(0.001)
|
||||
->suffix('%')
|
||||
->helperText('Percentage'),
|
||||
])
|
||||
->columns(2)
|
||||
->collapsible()
|
||||
->helperText('Add terpene profile data'),
|
||||
]),
|
||||
|
||||
Tab::make('Compliance Tests')
|
||||
->schema([
|
||||
Section::make('Safety Tests')
|
||||
->schema([
|
||||
Toggle::make('pesticides_pass')
|
||||
->label('Pesticides Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('heavy_metals_pass')
|
||||
->label('Heavy Metals Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('microbials_pass')
|
||||
->label('Microbials Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('mycotoxins_pass')
|
||||
->label('Mycotoxins Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('residual_solvents_pass')
|
||||
->label('Residual Solvents Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
|
||||
Toggle::make('foreign_material_pass')
|
||||
->label('Foreign Material Pass')
|
||||
->default(true)
|
||||
->inline(false),
|
||||
])
|
||||
->columns(3)
|
||||
->description('All tests must pass for overall compliance'),
|
||||
|
||||
Section::make('Additional Tests')
|
||||
->schema([
|
||||
TextInput::make('moisture_content')
|
||||
->label('Moisture Content %')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->maxValue(100)
|
||||
->step(0.01)
|
||||
->suffix('%'),
|
||||
|
||||
Toggle::make('compliance_pass')
|
||||
->label('Overall Compliance Pass')
|
||||
->default(true)
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->helperText('Auto-calculated from all safety tests'),
|
||||
])
|
||||
->columns(2),
|
||||
]),
|
||||
|
||||
Tab::make('COA Files')
|
||||
->schema([
|
||||
Section::make('Certificate of Analysis Files')
|
||||
->schema([
|
||||
Repeater::make('coaFiles')
|
||||
->relationship()
|
||||
->schema([
|
||||
FileUpload::make('file_path')
|
||||
->label('File')
|
||||
->required()
|
||||
->directory('compliance/coas')
|
||||
->acceptedFileTypes(['application/pdf', 'image/*'])
|
||||
->maxSize(10240),
|
||||
|
||||
TextInput::make('description')
|
||||
->maxLength(255)
|
||||
->helperText('Optional description'),
|
||||
|
||||
Toggle::make('is_primary')
|
||||
->label('Primary COA')
|
||||
->inline(false),
|
||||
])
|
||||
->columns(3)
|
||||
->collapsible()
|
||||
->helperText('Upload COA files (PDF or images)'),
|
||||
|
||||
TextInput::make('certificate_url')
|
||||
->label('External COA URL')
|
||||
->url()
|
||||
->maxLength(255)
|
||||
->helperText('Link to COA on external site (optional)'),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Notes')
|
||||
->schema([
|
||||
Textarea::make('notes')
|
||||
->rows(5)
|
||||
->columnSpanFull()
|
||||
->helperText('Additional notes about this test'),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
app/Filament/Resources/LabResource/Tables/LabsTable.php
Normal file
136
app/Filament/Resources/LabResource/Tables/LabsTable.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\LabResource\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Support\Colors\Color;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class LabsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('lab_name')
|
||||
->label('Lab')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->weight('bold'),
|
||||
|
||||
TextColumn::make('product.name')
|
||||
->label('Product')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->limit(30),
|
||||
|
||||
TextColumn::make('batch.batch_number')
|
||||
->label('Batch')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('test_date')
|
||||
->date('M d, Y')
|
||||
->sortable()
|
||||
->color(fn ($record) => $record->test_date < now()->subDays(90) ? Color::Orange : null),
|
||||
|
||||
TextColumn::make('total_thc')
|
||||
->label('THC')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->suffix('%')
|
||||
->sortable()
|
||||
->color(fn ($state) => $state > 20 ? Color::Green : ($state > 15 ? Color::Amber : Color::Gray)),
|
||||
|
||||
TextColumn::make('total_cbd')
|
||||
->label('CBD')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->suffix('%')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
TextColumn::make('total_cannabinoids')
|
||||
->label('Total')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->suffix('%')
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
|
||||
IconColumn::make('compliance_pass')
|
||||
->label('Compliance')
|
||||
->boolean()
|
||||
->trueIcon('heroicon-o-check-circle')
|
||||
->falseIcon('heroicon-o-x-circle')
|
||||
->trueColor(Color::Green)
|
||||
->falseColor(Color::Red)
|
||||
->sortable(),
|
||||
|
||||
TextColumn::make('terpene_profile')
|
||||
->label('Top Terpenes')
|
||||
->limit(40)
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('test_date', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('lab_name')
|
||||
->options(function () {
|
||||
return \App\Models\Lab::query()
|
||||
->distinct('lab_name')
|
||||
->pluck('lab_name', 'lab_name')
|
||||
->toArray();
|
||||
})
|
||||
->searchable(),
|
||||
|
||||
SelectFilter::make('product')
|
||||
->relationship('product', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
SelectFilter::make('batch')
|
||||
->relationship('batch', 'batch_number')
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
TernaryFilter::make('compliance_pass')
|
||||
->label('Compliant'),
|
||||
|
||||
Filter::make('recent')
|
||||
->label('Recent (Last 30 days)')
|
||||
->query(fn (Builder $query): Builder => $query->where('test_date', '>=', now()->subDays(30)))
|
||||
->toggle(),
|
||||
|
||||
Filter::make('high_thc')
|
||||
->label('High THC (>20%)')
|
||||
->query(fn (Builder $query): Builder => $query->where('total_thc', '>', 20))
|
||||
->toggle(),
|
||||
|
||||
Filter::make('high_cbd')
|
||||
->label('High CBD (>10%)')
|
||||
->query(fn (Builder $query): Builder => $query->where('total_cbd', '>', 10))
|
||||
->toggle(),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ class OrderResource extends Resource
|
||||
{
|
||||
return [
|
||||
RelationManagers\ItemsRelationManager::class,
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ class ProductResource extends Resource
|
||||
BatchesRelationManager::class,
|
||||
ComponentsRelationManager::class,
|
||||
VarietiesRelationManager::class,
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,22 @@ class UserResource extends Resource
|
||||
'suspended' => 'Suspended',
|
||||
])
|
||||
->default('active'),
|
||||
TextInput::make('password')
|
||||
->label('Password')
|
||||
->password()
|
||||
->required(fn ($record) => $record === null)
|
||||
->dehydrated(fn ($state) => filled($state))
|
||||
->minLength(8)
|
||||
->maxLength(255)
|
||||
->helperText('Leave blank to keep current password when editing')
|
||||
->visible(fn ($livewire) => $livewire instanceof CreateUser),
|
||||
TextInput::make('password_confirmation')
|
||||
->label('Confirm Password')
|
||||
->password()
|
||||
->required(fn ($record) => $record === null && filled($record?->password))
|
||||
->dehydrated(false)
|
||||
->same('password')
|
||||
->visible(fn ($livewire) => $livewire instanceof CreateUser),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Business Association')
|
||||
|
||||
@@ -4,8 +4,18 @@ namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class CreateUser extends CreateRecord
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (isset($data['password'])) {
|
||||
$data['password'] = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
143
app/Helpers/BusinessHelper.php
Normal file
143
app/Helpers/BusinessHelper.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class BusinessHelper
|
||||
{
|
||||
/**
|
||||
* Get current business context from session or user's primary business
|
||||
*/
|
||||
public static function current(): ?Business
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$businessId = session('current_business_id');
|
||||
|
||||
if ($businessId) {
|
||||
return Business::find($businessId);
|
||||
}
|
||||
|
||||
// Fall back to user's primary business if no session is set
|
||||
return Auth::user()->primaryBusiness();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a permission for current business
|
||||
*
|
||||
* This method now uses PermissionService internally for better architecture
|
||||
* while maintaining backward compatibility with existing code.
|
||||
*
|
||||
* @param string $permission Permission key (e.g. 'analytics.overview')
|
||||
*/
|
||||
public static function hasPermission(string $permission): bool
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use PermissionService for permission checking
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
return $permissionService->check($user, $permission, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is owner or admin for current business
|
||||
*/
|
||||
public static function isOwnerOrAdmin(): bool
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Super admin
|
||||
if ($user->user_type === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Business owner
|
||||
return $business->owner_user_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's role template for current business
|
||||
*/
|
||||
public static function getRoleTemplate(): ?string
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$businessUser = $user->businesses()
|
||||
->where('businesses.id', $business->id)
|
||||
->first();
|
||||
|
||||
return $businessUser?->pivot->role_template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's permissions array for current business
|
||||
*/
|
||||
public static function getPermissions(): array
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$business = self::current();
|
||||
|
||||
if (! $business) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use PermissionService for cached permission retrieval
|
||||
$permissionService = app(PermissionService::class);
|
||||
|
||||
return $permissionService->getUserPermissions($user, $business);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current business has a specific module enabled
|
||||
*
|
||||
* @param string $module Module name (sales, manufacturing, compliance)
|
||||
*/
|
||||
public static function hasModule(string $module): bool
|
||||
{
|
||||
$business = self::current();
|
||||
|
||||
return match ($module) {
|
||||
'sales' => true, // Sales is always enabled (base product)
|
||||
'manufacturing' => $business?->has_manufacturing ?? false,
|
||||
'compliance' => $business?->has_compliance ?? false,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
app/Helpers/helpers.php
Normal file
24
app/Helpers/helpers.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Helpers\BusinessHelper;
|
||||
|
||||
if (! function_exists('currentBusiness')) {
|
||||
function currentBusiness()
|
||||
{
|
||||
return BusinessHelper::current();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('currentBusinessId')) {
|
||||
function currentBusinessId()
|
||||
{
|
||||
return BusinessHelper::currentId();
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('hasBusinessPermission')) {
|
||||
function hasBusinessPermission(string $permission): bool
|
||||
{
|
||||
return BusinessHelper::hasPermission($permission);
|
||||
}
|
||||
}
|
||||
110
app/Http/Controllers/Admin/QuickSwitchController.php
Normal file
110
app/Http/Controllers/Admin/QuickSwitchController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class QuickSwitchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Ensure only admins can access
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth');
|
||||
$this->middleware(function ($request, $next) {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
abort(403, 'Not authenticated');
|
||||
}
|
||||
|
||||
$manager = app(\Lab404\Impersonate\Services\ImpersonateManager::class);
|
||||
|
||||
// If impersonating, check if the impersonator can impersonate
|
||||
// Otherwise check if the current user can impersonate
|
||||
$canAccess = $manager->isImpersonating()
|
||||
? $manager->getImpersonator()->canImpersonate()
|
||||
: $user->canImpersonate();
|
||||
|
||||
if (! $canAccess) {
|
||||
abort(403, 'Only administrators can access this feature. Please login as an admin.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show quick switch menu for testing
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Get all seller users for quick switching
|
||||
$users = User::where('user_type', 'seller')
|
||||
->with('businesses')
|
||||
->orderBy('email')
|
||||
->get();
|
||||
|
||||
return view('admin.quick-switch', compact('users'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick switch to user using impersonation (maintains admin session)
|
||||
*/
|
||||
public function switch(Request $request, User $user)
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
$manager = app(\Lab404\Impersonate\Services\ImpersonateManager::class);
|
||||
|
||||
// Get the actual admin user (might be the impersonator)
|
||||
$admin = $manager->isImpersonating()
|
||||
? $manager->getImpersonator()
|
||||
: $currentUser;
|
||||
|
||||
if (! $user->canBeImpersonated()) {
|
||||
abort(403, 'This user cannot be impersonated');
|
||||
}
|
||||
|
||||
// If already impersonating someone, leave that impersonation first
|
||||
if ($manager->isImpersonating()) {
|
||||
$manager->leave();
|
||||
}
|
||||
|
||||
// Use impersonation instead of session replacement
|
||||
// This allows multiple tabs with different impersonated users
|
||||
$manager->take($admin, $user, 'web');
|
||||
|
||||
// Redirect based on user type and business
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if ($business && $business->isParentCompany()) {
|
||||
return redirect()->route('seller.business.executive.dashboard', $business->slug);
|
||||
} elseif ($business) {
|
||||
return redirect()->route('seller.business.dashboard', $business->slug);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.dashboard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch back to admin (leave impersonation)
|
||||
*/
|
||||
public function backToAdmin()
|
||||
{
|
||||
$manager = app(\Lab404\Impersonate\Services\ImpersonateManager::class);
|
||||
|
||||
if (! $manager->isImpersonating()) {
|
||||
return redirect()->route('filament.admin.pages.dashboard')
|
||||
->with('info', 'You are not currently impersonating anyone');
|
||||
}
|
||||
|
||||
// Leave impersonation
|
||||
$manager->leave();
|
||||
|
||||
return redirect()->route('filament.admin.pages.dashboard')
|
||||
->with('success', 'Returned to admin panel');
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal file
101
app/Http/Controllers/Analytics/AnalyticsDashboardController.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\AnalyticsEvent;
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AnalyticsDashboardController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.overview')) {
|
||||
abort(403, 'Unauthorized to view analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30'); // days
|
||||
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Key metrics
|
||||
$metrics = [
|
||||
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
|
||||
'total_page_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->sum('page_views'),
|
||||
'total_product_views' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->count(),
|
||||
'unique_products_viewed' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->distinct('product_id')
|
||||
->count('product_id'),
|
||||
'high_intent_signals' => IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
|
||||
->where('signal_strength', '>=', IntentSignal::STRENGTH_HIGH)
|
||||
->count(),
|
||||
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('last_interaction_at', '>=', $startDate)->count(),
|
||||
];
|
||||
|
||||
// Traffic trend (daily breakdown)
|
||||
$trafficTrend = AnalyticsEvent::forBusiness($business->id)->where('created_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('COUNT(*) as total_events'),
|
||||
DB::raw('COUNT(DISTINCT session_id) as unique_sessions')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top products by views
|
||||
$topProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id', DB::raw('COUNT(*) as view_count'))
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('view_count')
|
||||
->limit(10)
|
||||
->with('product')
|
||||
->get();
|
||||
|
||||
// High-value buyers
|
||||
$highValueBuyers = BuyerEngagementScore::forBusiness($business->id)->highValue()
|
||||
->active()
|
||||
->orderByDesc('score')
|
||||
->limit(10)
|
||||
->with('buyerBusiness')
|
||||
->get();
|
||||
|
||||
// Recent high-intent signals
|
||||
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
|
||||
->where('detected_at', '>=', now()->subHours(24))
|
||||
->orderByDesc('detected_at')
|
||||
->limit(10)
|
||||
->with(['buyerBusiness', 'user'])
|
||||
->get();
|
||||
|
||||
// Engagement score distribution
|
||||
$engagementDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
|
||||
DB::raw('CASE
|
||||
WHEN score >= 80 THEN \'Very High\'
|
||||
WHEN score >= 60 THEN \'High\'
|
||||
WHEN score >= 40 THEN \'Medium\'
|
||||
ELSE \'Low\'
|
||||
END as score_range'),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.dashboard', compact(
|
||||
'business',
|
||||
'period',
|
||||
'metrics',
|
||||
'trafficTrend',
|
||||
'topProducts',
|
||||
'highValueBuyers',
|
||||
'recentIntentSignals',
|
||||
'engagementDistribution'
|
||||
));
|
||||
}
|
||||
}
|
||||
194
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal file
194
app/Http/Controllers/Analytics/BuyerIntelligenceController.php
Normal file
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\BuyerEngagementScore;
|
||||
use App\Models\Analytics\IntentSignal;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BuyerIntelligenceController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
// TODO: Re-enable when permission system is implemented
|
||||
// if (! hasBusinessPermission('analytics.buyers')) {
|
||||
// abort(403, 'Unauthorized to view buyer intelligence');
|
||||
// }
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$filter = $request->input('filter', 'all'); // all, high-value, at-risk, new
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Overall buyer metrics
|
||||
$metrics = [
|
||||
'total_buyers' => BuyerEngagementScore::forBusiness($business->id)->count(),
|
||||
'active_buyers' => BuyerEngagementScore::forBusiness($business->id)->active()->count(),
|
||||
'high_value_buyers' => BuyerEngagementScore::forBusiness($business->id)->highValue()->count(),
|
||||
'at_risk_buyers' => BuyerEngagementScore::forBusiness($business->id)->atRisk()->count(),
|
||||
'new_buyers' => BuyerEngagementScore::forBusiness($business->id)->where('first_interaction_at', '>=', now()->subDays(30))->count(),
|
||||
];
|
||||
|
||||
// Build query based on filter
|
||||
$buyersQuery = BuyerEngagementScore::forBusiness($business->id);
|
||||
|
||||
match ($filter) {
|
||||
'high-value' => $buyersQuery->highValue(),
|
||||
'at-risk' => $buyersQuery->atRisk(),
|
||||
'new' => $buyersQuery->where('first_interaction_at', '>=', now()->subDays(30)),
|
||||
default => $buyersQuery,
|
||||
};
|
||||
|
||||
$buyers = $buyersQuery->orderByDesc('score')
|
||||
->with('buyerBusiness')
|
||||
->paginate(20);
|
||||
|
||||
// Engagement score distribution
|
||||
$scoreDistribution = BuyerEngagementScore::forBusiness($business->id)->select(
|
||||
DB::raw("CASE
|
||||
WHEN score >= 80 THEN 'Very High (80-100)'
|
||||
WHEN score >= 60 THEN 'High (60-79)'
|
||||
WHEN score >= 40 THEN 'Medium (40-59)'
|
||||
WHEN score >= 20 THEN 'Low (20-39)'
|
||||
ELSE 'Very Low (0-19)'
|
||||
END as score_range"),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
// Tier distribution
|
||||
$tierDistribution = BuyerEngagementScore::forBusiness($business->id)->select('score_tier')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('score_tier')
|
||||
->get();
|
||||
|
||||
// Recent high-intent signals
|
||||
$recentIntentSignals = IntentSignal::forBusiness($business->id)->highIntent()
|
||||
->where('detected_at', '>=', now()->subDays(7))
|
||||
->orderByDesc('detected_at')
|
||||
->with(['buyerBusiness', 'user'])
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Intent signal breakdown
|
||||
$signalBreakdown = IntentSignal::forBusiness($business->id)->where('detected_at', '>=', $startDate)
|
||||
->select('signal_type')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->selectRaw('AVG(signal_strength) as avg_strength')
|
||||
->groupBy('signal_type')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.buyers', compact(
|
||||
'business',
|
||||
'period',
|
||||
'filter',
|
||||
'metrics',
|
||||
'buyers',
|
||||
'scoreDistribution',
|
||||
'tierDistribution',
|
||||
'recentIntentSignals',
|
||||
'signalBreakdown'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Business $buyer)
|
||||
{
|
||||
// TODO: Re-enable when permission system is implemented
|
||||
// if (! hasBusinessPermission('analytics.buyers')) {
|
||||
// abort(403, 'Unauthorized to view buyer intelligence');
|
||||
// }
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '90'); // Default to 90 days for buyer detail
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Get engagement score
|
||||
$engagementScore = BuyerEngagementScore::forBusiness($business->id)->where('buyer_business_id', $buyer->id)->first();
|
||||
|
||||
// Activity timeline
|
||||
$activityTimeline = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(viewed_at) as date'),
|
||||
DB::raw('COUNT(*) as product_views'),
|
||||
DB::raw('COUNT(DISTINCT product_id) as unique_products'),
|
||||
DB::raw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Products viewed
|
||||
$productsViewed = ProductView::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as view_count')
|
||||
->selectRaw('MAX(viewed_at) as last_viewed')
|
||||
->selectRaw('AVG(time_on_page) as avg_time')
|
||||
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_adds')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('view_count')
|
||||
->with('product')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
// Intent signals
|
||||
$intentSignals = IntentSignal::forBusiness($business->id)->where('buyer_business_id', $buyer->id)
|
||||
->where('detected_at', '>=', $startDate)
|
||||
->orderByDesc('detected_at')
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
// Email engagement
|
||||
$emailEngagement = DB::table('email_interactions')
|
||||
->join('users', 'email_interactions.recipient_user_id', '=', 'users.id')
|
||||
->join('business_user', 'users.id', '=', 'business_user.user_id')
|
||||
->where('email_interactions.business_id', $business->id)
|
||||
->where('business_user.business_id', $buyer->id)
|
||||
->where('email_interactions.sent_at', '>=', $startDate)
|
||||
->selectRaw('COUNT(*) as total_sent')
|
||||
->selectRaw('SUM(open_count) as total_opens')
|
||||
->selectRaw('SUM(click_count) as total_clicks')
|
||||
->selectRaw('AVG(engagement_score) as avg_engagement')
|
||||
->first();
|
||||
|
||||
// Order history
|
||||
$orderHistory = DB::table('orders')
|
||||
->where('seller_business_id', $business->id)
|
||||
->where('buyer_business_id', $buyer->id)
|
||||
->select(
|
||||
DB::raw('DATE(created_at) as date'),
|
||||
DB::raw('COUNT(*) as order_count'),
|
||||
DB::raw('SUM(total) as revenue')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
$totalOrders = DB::table('orders')
|
||||
->where('seller_business_id', $business->id)
|
||||
->where('buyer_business_id', $buyer->id)
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->selectRaw('SUM(total) as total_revenue')
|
||||
->selectRaw('AVG(total) as avg_order_value')
|
||||
->first();
|
||||
|
||||
return view('seller.analytics.buyer-detail', compact(
|
||||
'buyer',
|
||||
'period',
|
||||
'engagementScore',
|
||||
'activityTimeline',
|
||||
'productsViewed',
|
||||
'intentSignals',
|
||||
'emailEngagement',
|
||||
'orderHistory',
|
||||
'totalOrders'
|
||||
));
|
||||
}
|
||||
}
|
||||
173
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
173
app/Http/Controllers/Analytics/MarketingAnalyticsController.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\EmailCampaign;
|
||||
use App\Models\Analytics\EmailInteraction;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MarketingAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.marketing')) {
|
||||
abort(403, 'Unauthorized to view marketing analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Campaign overview metrics
|
||||
$metrics = [
|
||||
'total_campaigns' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->count(),
|
||||
'total_sent' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_sent'),
|
||||
'total_delivered' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_delivered'),
|
||||
'total_opened' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_opened'),
|
||||
'total_clicked' => EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)->sum('total_clicked'),
|
||||
];
|
||||
|
||||
// Calculate average rates
|
||||
$metrics['avg_open_rate'] = $metrics['total_delivered'] > 0
|
||||
? round(($metrics['total_opened'] / $metrics['total_delivered']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$metrics['avg_click_rate'] = $metrics['total_delivered'] > 0
|
||||
? round(($metrics['total_clicked'] / $metrics['total_delivered']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// Campaign performance
|
||||
$campaigns = EmailCampaign::forBusiness($business->id)->where('created_at', '>=', $startDate)
|
||||
->orderByDesc('sent_at')
|
||||
->with('emailInteractions')
|
||||
->paginate(20);
|
||||
|
||||
// Email engagement over time
|
||||
$engagementTrend = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(sent_at) as date'),
|
||||
DB::raw('COUNT(*) as sent'),
|
||||
DB::raw('SUM(CASE WHEN first_opened_at IS NOT NULL THEN 1 ELSE 0 END) as opened'),
|
||||
DB::raw('SUM(CASE WHEN first_clicked_at IS NOT NULL THEN 1 ELSE 0 END) as clicked')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top performing campaigns
|
||||
$topCampaigns = EmailCampaign::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->where('total_sent', '>', 0)
|
||||
->orderByRaw('(total_clicked / total_sent) DESC')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Email client breakdown
|
||||
$emailClients = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->whereNotNull('email_client')
|
||||
->select('email_client')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('email_client')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
// Device type breakdown
|
||||
$deviceTypes = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->whereNotNull('device_type')
|
||||
->select('device_type')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('device_type')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
// Engagement score distribution
|
||||
$engagementScores = EmailInteraction::forBusiness($business->id)->where('sent_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw("CASE
|
||||
WHEN engagement_score >= 80 THEN 'High'
|
||||
WHEN engagement_score >= 50 THEN 'Medium'
|
||||
WHEN engagement_score > 0 THEN 'Low'
|
||||
ELSE 'None'
|
||||
END as score_range"),
|
||||
DB::raw('COUNT(*) as count')
|
||||
)
|
||||
->groupBy('score_range')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.marketing', compact(
|
||||
'business',
|
||||
'period',
|
||||
'metrics',
|
||||
'campaigns',
|
||||
'engagementTrend',
|
||||
'topCampaigns',
|
||||
'emailClients',
|
||||
'deviceTypes',
|
||||
'engagementScores'
|
||||
));
|
||||
}
|
||||
|
||||
public function campaign(Request $request, EmailCampaign $campaign)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.marketing')) {
|
||||
abort(403, 'Unauthorized to view marketing analytics');
|
||||
}
|
||||
|
||||
// Verify campaign belongs to user's business
|
||||
if ($campaign->business_id !== currentBusinessId()) {
|
||||
abort(403, 'Unauthorized to view this campaign');
|
||||
}
|
||||
|
||||
// Campaign metrics
|
||||
$metrics = [
|
||||
'total_sent' => $campaign->total_sent,
|
||||
'total_delivered' => $campaign->total_delivered,
|
||||
'total_bounced' => $campaign->total_bounced,
|
||||
'total_opened' => $campaign->total_opened,
|
||||
'total_clicked' => $campaign->total_clicked,
|
||||
'open_rate' => $campaign->open_rate,
|
||||
'click_rate' => $campaign->click_rate,
|
||||
'bounce_rate' => $campaign->total_sent > 0
|
||||
? round(($campaign->total_bounced / $campaign->total_sent) * 100, 2)
|
||||
: 0,
|
||||
];
|
||||
|
||||
// Interaction timeline
|
||||
$timeline = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
|
||||
->select(
|
||||
DB::raw('DATE(sent_at) as date'),
|
||||
DB::raw('SUM(open_count) as opens'),
|
||||
DB::raw('SUM(click_count) as clicks')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top engaged recipients
|
||||
$topRecipients = EmailInteraction::forBusiness($campaign->business_id)->where('campaign_id', $campaign->id)
|
||||
->orderByDesc('engagement_score')
|
||||
->limit(20)
|
||||
->with('recipientUser')
|
||||
->get();
|
||||
|
||||
// Click breakdown by URL
|
||||
$clicksByUrl = DB::table('email_clicks')
|
||||
->join('email_interactions', 'email_clicks.email_interaction_id', '=', 'email_interactions.id')
|
||||
->where('email_interactions.campaign_id', $campaign->id)
|
||||
->select('email_clicks.url', 'email_clicks.link_identifier')
|
||||
->selectRaw('COUNT(*) as click_count')
|
||||
->selectRaw('COUNT(DISTINCT email_clicks.email_interaction_id) as unique_clicks')
|
||||
->groupBy('email_clicks.url', 'email_clicks.link_identifier')
|
||||
->orderByDesc('click_count')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.campaign-detail', compact(
|
||||
'campaign',
|
||||
'metrics',
|
||||
'timeline',
|
||||
'topRecipients',
|
||||
'clicksByUrl'
|
||||
));
|
||||
}
|
||||
}
|
||||
164
app/Http/Controllers/Analytics/ProductAnalyticsController.php
Normal file
164
app/Http/Controllers/Analytics/ProductAnalyticsController.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Helpers\BusinessHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\ProductView;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProductAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.products')) {
|
||||
abort(403, 'Unauthorized to view product analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Product performance metrics
|
||||
$productMetrics = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as total_views')
|
||||
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
|
||||
->selectRaw('AVG(time_on_page) as avg_time_on_page')
|
||||
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
|
||||
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
|
||||
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
|
||||
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('total_views')
|
||||
->with('product.brand')
|
||||
->paginate(20);
|
||||
|
||||
// Product view trend
|
||||
$viewTrend = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(viewed_at) as date'),
|
||||
DB::raw('COUNT(*) as views'),
|
||||
DB::raw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// High engagement products (quality over quantity)
|
||||
$highEngagementProducts = ProductView::forBusiness($business->id)->highEngagement()
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as engagement_count')
|
||||
->selectRaw('AVG(time_on_page) as avg_time')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('engagement_count')
|
||||
->limit(10)
|
||||
->with('product')
|
||||
->get();
|
||||
|
||||
// Products with most cart additions (high intent)
|
||||
$topCartProducts = ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)
|
||||
->where('added_to_cart', true)
|
||||
->select('product_id')
|
||||
->selectRaw('COUNT(*) as cart_count')
|
||||
->groupBy('product_id')
|
||||
->orderByDesc('cart_count')
|
||||
->limit(10)
|
||||
->with('product')
|
||||
->get();
|
||||
|
||||
// Engagement breakdown
|
||||
$engagementBreakdown = [
|
||||
'zoomed_image' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('zoomed_image', true)->count(),
|
||||
'watched_video' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('watched_video', true)->count(),
|
||||
'downloaded_spec' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('downloaded_spec', true)->count(),
|
||||
'added_to_cart' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_cart', true)->count(),
|
||||
'added_to_wishlist' => ProductView::forBusiness($business->id)->where('viewed_at', '>=', $startDate)->where('added_to_wishlist', true)->count(),
|
||||
];
|
||||
|
||||
return view('seller.analytics.products', compact(
|
||||
'business',
|
||||
'period',
|
||||
'productMetrics',
|
||||
'viewTrend',
|
||||
'highEngagementProducts',
|
||||
'topCartProducts',
|
||||
'engagementBreakdown'
|
||||
));
|
||||
}
|
||||
|
||||
public function show(Request $request, Product $product)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.products')) {
|
||||
abort(403, 'Unauthorized to view product analytics');
|
||||
}
|
||||
|
||||
// Verify product belongs to user's business brands
|
||||
$sellerBusiness = BusinessHelper::fromProduct($product);
|
||||
if ($sellerBusiness->id !== currentBusinessId()) {
|
||||
abort(403, 'Unauthorized to view this product');
|
||||
}
|
||||
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Product-specific metrics
|
||||
$metrics = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->selectRaw('COUNT(*) as total_views')
|
||||
->selectRaw('COUNT(DISTINCT buyer_business_id) as unique_buyers')
|
||||
->selectRaw('COUNT(DISTINCT session_id) as unique_sessions')
|
||||
->selectRaw('AVG(time_on_page) as avg_time_on_page')
|
||||
->selectRaw('MAX(time_on_page) as max_time_on_page')
|
||||
->selectRaw('SUM(CASE WHEN zoomed_image = true THEN 1 ELSE 0 END) as zoomed_count')
|
||||
->selectRaw('SUM(CASE WHEN watched_video = true THEN 1 ELSE 0 END) as video_views')
|
||||
->selectRaw('SUM(CASE WHEN downloaded_spec = true THEN 1 ELSE 0 END) as spec_downloads')
|
||||
->selectRaw('SUM(CASE WHEN added_to_cart = true THEN 1 ELSE 0 END) as cart_additions')
|
||||
->first();
|
||||
|
||||
// View trend
|
||||
$viewTrend = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(viewed_at) as date'),
|
||||
DB::raw('COUNT(*) as views')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top buyers viewing this product
|
||||
$topBuyers = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->whereNotNull('buyer_business_id')
|
||||
->select('buyer_business_id')
|
||||
->selectRaw('COUNT(*) as view_count')
|
||||
->selectRaw('MAX(viewed_at) as last_viewed')
|
||||
->groupBy('buyer_business_id')
|
||||
->orderByDesc('view_count')
|
||||
->limit(10)
|
||||
->with('buyerBusiness')
|
||||
->get();
|
||||
|
||||
// Traffic sources
|
||||
$trafficSources = ProductView::forBusiness($sellerBusiness->id)->where('product_id', $product->id)
|
||||
->where('viewed_at', '>=', $startDate)
|
||||
->select('source')
|
||||
->selectRaw('COUNT(*) as count')
|
||||
->groupBy('source')
|
||||
->orderByDesc('count')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.product-detail', compact(
|
||||
'product',
|
||||
'period',
|
||||
'metrics',
|
||||
'viewTrend',
|
||||
'topBuyers',
|
||||
'trafficSources'
|
||||
));
|
||||
}
|
||||
}
|
||||
160
app/Http/Controllers/Analytics/SalesAnalyticsController.php
Normal file
160
app/Http/Controllers/Analytics/SalesAnalyticsController.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Analytics\UserSession;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SalesAnalyticsController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
if (! hasBusinessPermission('analytics.sales')) {
|
||||
abort(403, 'Unauthorized to view sales analytics');
|
||||
}
|
||||
|
||||
$business = currentBusiness();
|
||||
$period = $request->input('period', '30');
|
||||
$startDate = now()->subDays((int) $period);
|
||||
|
||||
// Sales funnel metrics
|
||||
$funnelMetrics = [
|
||||
'total_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)->count(),
|
||||
'sessions_with_product_views' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('product_views', '>', 0)
|
||||
->count(),
|
||||
'sessions_with_cart' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('interactions', '>', 0)
|
||||
->count(),
|
||||
'checkout_initiated' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('interactions', '>', 2)
|
||||
->count(),
|
||||
'orders_completed' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('converted', true)
|
||||
->count(),
|
||||
];
|
||||
|
||||
// Calculate conversion rates
|
||||
$funnelMetrics['product_view_rate'] = $funnelMetrics['total_sessions'] > 0
|
||||
? round(($funnelMetrics['sessions_with_product_views'] / $funnelMetrics['total_sessions']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$funnelMetrics['cart_rate'] = $funnelMetrics['sessions_with_product_views'] > 0
|
||||
? round(($funnelMetrics['sessions_with_cart'] / $funnelMetrics['sessions_with_product_views']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$funnelMetrics['checkout_rate'] = $funnelMetrics['sessions_with_cart'] > 0
|
||||
? round(($funnelMetrics['checkout_initiated'] / $funnelMetrics['sessions_with_cart']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
$funnelMetrics['conversion_rate'] = $funnelMetrics['checkout_initiated'] > 0
|
||||
? round(($funnelMetrics['orders_completed'] / $funnelMetrics['checkout_initiated']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// Sales metrics from orders table
|
||||
// Note: orders.business_id is the buyer's business
|
||||
// To get seller's orders, join through order_items → products → brands
|
||||
$salesMetrics = DB::table('orders')
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->where('brands.business_id', $business->id)
|
||||
->where('orders.created_at', '>=', $startDate)
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as total_orders')
|
||||
->selectRaw('SUM(order_items.line_total) as total_revenue')
|
||||
->selectRaw('AVG(orders.total) as avg_order_value')
|
||||
->selectRaw('COUNT(DISTINCT orders.business_id) as unique_buyers')
|
||||
->first();
|
||||
|
||||
// Revenue trend
|
||||
$revenueTrend = DB::table('orders')
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->where('brands.business_id', $business->id)
|
||||
->where('orders.created_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(orders.created_at) as date'),
|
||||
DB::raw('COUNT(DISTINCT orders.id) as orders'),
|
||||
DB::raw('SUM(order_items.line_total) as revenue')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Conversion funnel trend
|
||||
$conversionTrend = UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->select(
|
||||
DB::raw('DATE(started_at) as date'),
|
||||
DB::raw('COUNT(*) as sessions'),
|
||||
DB::raw('SUM(CASE WHEN product_views > 0 THEN 1 ELSE 0 END) as with_views'),
|
||||
DB::raw('SUM(CASE WHEN interactions > 0 THEN 1 ELSE 0 END) as with_interactions'),
|
||||
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as conversions'),
|
||||
DB::raw('SUM(CASE WHEN converted = true THEN 1 ELSE 0 END) as orders')
|
||||
)
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
// Top revenue products
|
||||
$topProducts = DB::table('order_items')
|
||||
->join('orders', 'order_items.order_id', '=', 'orders.id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->where('brands.business_id', $business->id)
|
||||
->where('orders.created_at', '>=', $startDate)
|
||||
->select('products.id', 'products.name')
|
||||
->selectRaw('SUM(order_items.quantity) as units_sold')
|
||||
->selectRaw('SUM(order_items.line_total) as revenue')
|
||||
->groupBy('products.id', 'products.name')
|
||||
->orderByDesc('revenue')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Session abandonment analysis (sessions with interactions but no conversion)
|
||||
$cartAbandonment = [
|
||||
'total_interactive_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('interactions', '>', 0)
|
||||
->count(),
|
||||
'abandoned_sessions' => UserSession::forBusiness($business->id)->where('started_at', '>=', $startDate)
|
||||
->where('interactions', '>', 0)
|
||||
->where('converted', false)
|
||||
->count(),
|
||||
];
|
||||
|
||||
$cartAbandonment['abandonment_rate'] = $cartAbandonment['total_interactive_sessions'] > 0
|
||||
? round(($cartAbandonment['abandoned_sessions'] / $cartAbandonment['total_interactive_sessions']) * 100, 2)
|
||||
: 0;
|
||||
|
||||
// Top buyers by revenue
|
||||
$topBuyers = DB::table('orders')
|
||||
->join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->join('products', 'order_items.product_id', '=', 'products.id')
|
||||
->join('brands', 'products.brand_id', '=', 'brands.id')
|
||||
->join('businesses', 'orders.business_id', '=', 'businesses.id')
|
||||
->where('brands.business_id', $business->id)
|
||||
->where('orders.created_at', '>=', $startDate)
|
||||
->select('businesses.id', 'businesses.name')
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count')
|
||||
->selectRaw('SUM(order_items.line_total) as total_revenue')
|
||||
->selectRaw('AVG(orders.total) as avg_order_value')
|
||||
->groupBy('businesses.id', 'businesses.name')
|
||||
->orderByDesc('total_revenue')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.sales', compact(
|
||||
'business',
|
||||
'period',
|
||||
'funnelMetrics',
|
||||
'salesMetrics',
|
||||
'revenueTrend',
|
||||
'conversionTrend',
|
||||
'topProducts',
|
||||
'cartAbandonment',
|
||||
'topBuyers'
|
||||
));
|
||||
}
|
||||
}
|
||||
190
app/Http/Controllers/Analytics/TrackingController.php
Normal file
190
app/Http/Controllers/Analytics/TrackingController.php
Normal file
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Analytics;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Services\AnalyticsTracker;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TrackingController extends Controller
|
||||
{
|
||||
protected AnalyticsTracker $tracker;
|
||||
|
||||
public function __construct(AnalyticsTracker $tracker)
|
||||
{
|
||||
$this->tracker = $tracker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize or update session
|
||||
*/
|
||||
public function session(Request $request)
|
||||
{
|
||||
try {
|
||||
$session = $this->tracker->startSession();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'session_id' => $session->session_id,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Analytics session tracking failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Session tracking failed',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track various analytics events
|
||||
*/
|
||||
public function track(Request $request)
|
||||
{
|
||||
try {
|
||||
$eventType = $request->input('event_type');
|
||||
|
||||
switch ($eventType) {
|
||||
case 'page_view':
|
||||
$this->trackPageView($request);
|
||||
break;
|
||||
|
||||
case 'product_view':
|
||||
$this->trackProductView($request);
|
||||
break;
|
||||
|
||||
case 'page_engagement':
|
||||
$this->trackPageEngagement($request);
|
||||
break;
|
||||
|
||||
case 'click':
|
||||
$this->trackClick($request);
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->trackGenericEvent($request);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Analytics tracking failed', [
|
||||
'event_type' => $request->input('event_type'),
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Tracking failed',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*/
|
||||
protected function trackPageView(Request $request): void
|
||||
{
|
||||
$this->tracker->updateSessionPageView();
|
||||
|
||||
$this->tracker->trackEvent(
|
||||
'page_view',
|
||||
'navigation',
|
||||
'view',
|
||||
null,
|
||||
null,
|
||||
[
|
||||
'url' => $request->input('url'),
|
||||
'title' => $request->input('title'),
|
||||
'referrer' => $request->input('referrer'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track product view with engagement signals
|
||||
*/
|
||||
protected function trackProductView(Request $request): void
|
||||
{
|
||||
$productId = $request->input('product_id');
|
||||
|
||||
if (! $productId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$product = Product::find($productId);
|
||||
|
||||
if (! $product) {
|
||||
return;
|
||||
}
|
||||
|
||||
$signals = [
|
||||
'time_on_page' => $request->input('time_on_page'),
|
||||
'scroll_depth' => $request->input('scroll_depth'),
|
||||
'zoomed_image' => $request->boolean('zoomed_image'),
|
||||
'watched_video' => $request->boolean('watched_video'),
|
||||
'downloaded_spec' => $request->boolean('downloaded_spec'),
|
||||
'added_to_cart' => $request->boolean('added_to_cart'),
|
||||
'added_to_wishlist' => $request->boolean('added_to_wishlist'),
|
||||
];
|
||||
|
||||
$this->tracker->trackProductView($product, $signals);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track generic page engagement
|
||||
*/
|
||||
protected function trackPageEngagement(Request $request): void
|
||||
{
|
||||
$this->tracker->updateSessionPageView();
|
||||
|
||||
$this->tracker->trackEvent(
|
||||
'page_engagement',
|
||||
'engagement',
|
||||
'interact',
|
||||
null,
|
||||
null,
|
||||
[
|
||||
'time_on_page' => $request->input('time_on_page'),
|
||||
'scroll_depth' => $request->input('scroll_depth'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track click event
|
||||
*/
|
||||
protected function trackClick(Request $request): void
|
||||
{
|
||||
$this->tracker->trackClick(
|
||||
$request->input('element_type', 'unknown'),
|
||||
$request->input('element_id'),
|
||||
$request->input('element_label'),
|
||||
$request->input('url'),
|
||||
[
|
||||
'timestamp' => $request->input('timestamp'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track generic event
|
||||
*/
|
||||
protected function trackGenericEvent(Request $request): void
|
||||
{
|
||||
$this->tracker->trackEvent(
|
||||
$request->input('event_type', 'custom'),
|
||||
$request->input('category', 'general'),
|
||||
$request->input('action', 'action'),
|
||||
$request->input('subject_id'),
|
||||
$request->input('subject_type'),
|
||||
$request->input('metadata', [])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,20 +30,29 @@ class UnifiedAuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
// Smart routing based on user type
|
||||
// Log admin users into the admin guard for Filament access
|
||||
if ($user->user_type === 'admin') {
|
||||
Auth::guard('admin')->login($user);
|
||||
}
|
||||
|
||||
// Smart routing based on user type - use intended() to preserve redirect URL
|
||||
switch ($user->user_type) {
|
||||
case 'buyer':
|
||||
return redirect()->route('buyer.dashboard');
|
||||
return redirect()->intended(route('buyer.dashboard'));
|
||||
|
||||
case 'seller':
|
||||
return redirect()->route('seller.dashboard');
|
||||
return redirect()->intended(route('seller.dashboard'));
|
||||
|
||||
case 'admin':
|
||||
return redirect('/admin');
|
||||
return redirect()->intended('/admin');
|
||||
|
||||
case 'both':
|
||||
// For users with both types, default to seller dashboard
|
||||
return redirect()->intended(route('seller.dashboard'));
|
||||
|
||||
default:
|
||||
// Fallback for users without proper type
|
||||
return redirect()->route('buyer.profile');
|
||||
return redirect()->intended(route('buyer.profile'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class UserController extends Controller
|
||||
@@ -25,14 +28,57 @@ class UserController extends Controller
|
||||
|
||||
// Load users with their pivot data (contact_type, is_primary, permissions)
|
||||
$users = $business->users()
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->withPivot('contact_type', 'is_primary', 'permissions', 'role')
|
||||
->orderBy('is_primary', 'desc')
|
||||
->orderBy('first_name')
|
||||
->get();
|
||||
|
||||
// Available analytics permissions
|
||||
$analyticsPermissions = [
|
||||
'analytics.overview' => 'Access main analytics dashboard',
|
||||
'analytics.products' => 'View product performance analytics',
|
||||
'analytics.marketing' => 'View marketing and email analytics',
|
||||
'analytics.sales' => 'View sales intelligence and pipeline',
|
||||
'analytics.buyers' => 'View buyer intelligence and engagement',
|
||||
'analytics.export' => 'Export analytics data',
|
||||
];
|
||||
|
||||
return view('business.users.index', [
|
||||
'business' => $business,
|
||||
'users' => $users,
|
||||
'analyticsPermissions' => $analyticsPermissions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user permissions.
|
||||
*/
|
||||
public function updatePermissions(Request $request, User $user): JsonResponse
|
||||
{
|
||||
$business = auth()->user()->businesses()->first();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json(['error' => 'No business found'], 404);
|
||||
}
|
||||
|
||||
// Verify user belongs to this business
|
||||
if (! $business->users->contains($user->id)) {
|
||||
return response()->json(['error' => 'User not found in this business'], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'permissions' => 'array',
|
||||
'permissions.*' => 'string',
|
||||
]);
|
||||
|
||||
// Update permissions in pivot table
|
||||
$business->users()->updateExistingPivot($user->id, [
|
||||
'permissions' => $validated['permissions'] ?? [],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Permissions updated successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
185
app/Http/Controllers/Business/UserPermissionsController.php
Normal file
185
app/Http/Controllers/Business/UserPermissionsController.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Business;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\PermissionService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserPermissionsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionService $permissionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Update user permissions via AJAX
|
||||
*/
|
||||
public function update(Request $request, string $businessSlug, int $userId)
|
||||
{
|
||||
try {
|
||||
$business = currentBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Business not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Only owners and admins can manage permissions
|
||||
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You do not have permission to manage user permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$user = User::findOrFail($userId);
|
||||
|
||||
// Verify user belongs to this business
|
||||
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User does not belong to this business',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Prevent owner from modifying their own permissions
|
||||
if ($user->id === $business->owner_user_id) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Cannot modify owner permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'permissions' => 'array',
|
||||
'permissions.*' => 'string',
|
||||
'role_template' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$permissions = $validated['permissions'] ?? [];
|
||||
$roleTemplate = $validated['role_template'] ?? null;
|
||||
|
||||
// Set permissions using PermissionService
|
||||
$success = $this->permissionService->setPermissions(
|
||||
user: $user,
|
||||
permissions: $permissions,
|
||||
business: $business,
|
||||
roleTemplate: $roleTemplate,
|
||||
reason: 'Updated by '.auth()->user()->name.' via permissions modal'
|
||||
);
|
||||
|
||||
if ($success) {
|
||||
Log::info('User permissions updated', [
|
||||
'business_id' => $business->id,
|
||||
'target_user_id' => $user->id,
|
||||
'actor_user_id' => auth()->id(),
|
||||
'permissions_count' => count($permissions),
|
||||
'role_template' => $roleTemplate,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Permissions updated successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to update permissions',
|
||||
], 500);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error updating user permissions', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
'business_slug' => $businessSlug,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while updating permissions',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a role template to a user
|
||||
*/
|
||||
public function applyTemplate(Request $request, string $businessSlug, int $userId)
|
||||
{
|
||||
try {
|
||||
$business = currentBusiness();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Business not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Only owners and admins can manage permissions
|
||||
if (auth()->user()->user_type !== 'admin' && $business->owner_user_id !== auth()->id()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You do not have permission to manage user permissions',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$user = User::findOrFail($userId);
|
||||
|
||||
// Verify user belongs to this business
|
||||
if (! $user->businesses()->where('businesses.id', $business->id)->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'User does not belong to this business',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'template_key' => 'required|string',
|
||||
'merge' => 'boolean',
|
||||
]);
|
||||
|
||||
$templateKey = $validated['template_key'];
|
||||
$merge = $validated['merge'] ?? false;
|
||||
|
||||
// Apply role template
|
||||
$permissions = $this->permissionService->applyRoleTemplate(
|
||||
user: $user,
|
||||
templateKey: $templateKey,
|
||||
business: $business,
|
||||
merge: $merge
|
||||
);
|
||||
|
||||
if ($permissions === null) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Role template not found',
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Role template applied successfully',
|
||||
'permissions' => $permissions,
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error applying role template', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $userId,
|
||||
'business_slug' => $businessSlug,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while applying role template',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/Http/Controllers/Buyer/BackorderController.php
Normal file
143
app/Http/Controllers/Buyer/BackorderController.php
Normal file
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ProcessBackorderRequest;
|
||||
use App\Models\Product;
|
||||
use App\Services\BackorderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class BackorderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BackorderService $backorderService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a backorder
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'quantity' => 'required|integer|min:1',
|
||||
'notes' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$business = $user->businesses->first(); // Assuming user has at least one business
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You must have a business account to place backorders.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$product = Product::findOrFail($request->product_id);
|
||||
|
||||
// Check if product is actually out of stock
|
||||
if ($product->isInStock()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This product is currently in stock. Please add it to your cart instead.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Dispatch the job to process backorder in the background
|
||||
ProcessBackorderRequest::dispatch(
|
||||
userId: $user->id,
|
||||
buyerBusinessId: $business->id,
|
||||
productId: $product->id,
|
||||
quantity: $request->quantity,
|
||||
notes: $request->notes
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Backorder placed successfully! We will create an order automatically when inventory becomes available.',
|
||||
'backorder' => [
|
||||
'product_id' => $product->id,
|
||||
'product_name' => $product->name,
|
||||
'quantity' => $request->quantity,
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Failed to create backorder. Please try again.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's backorders
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
$business = $user->businesses->first();
|
||||
|
||||
if (! $business) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No business found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$backorders = \App\Models\Backorder::where('business_id', $business->id)
|
||||
->with(['product.brand', 'order'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'backorders' => $backorders->map(function ($backorder) {
|
||||
return [
|
||||
'id' => $backorder->id,
|
||||
'product' => [
|
||||
'name' => $backorder->product->name,
|
||||
'sku' => $backorder->product->sku,
|
||||
'brand_name' => $backorder->product->brand->name,
|
||||
],
|
||||
'quantity' => $backorder->quantity,
|
||||
'status' => $backorder->status,
|
||||
'order_number' => $backorder->order?->order_number,
|
||||
'created_at' => $backorder->created_at->toDateString(),
|
||||
'converted_at' => $backorder->converted_at?->toDateString(),
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a backorder
|
||||
*/
|
||||
public function cancel(Request $request, int $backorderId): JsonResponse
|
||||
{
|
||||
$cancelled = $this->backorderService->cancelBackorder($backorderId, auth()->id());
|
||||
|
||||
if ($cancelled) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Backorder cancelled successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Backorder not found or already processed.',
|
||||
], 404);
|
||||
}
|
||||
}
|
||||
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal file
63
app/Http/Controllers/Buyer/BrandBrowseController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BrandBrowseController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show brand menu for buyers to browse and order
|
||||
* This is the main product browsing interface for buyers
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function browse(Request $request, string $businessSlug, string $brandHashid)
|
||||
{
|
||||
// Manually resolve business and brand (cross-tenant access allowed)
|
||||
// Buyers can browse ANY seller's brand menu
|
||||
$business = Business::where('slug', $businessSlug)->firstOrFail();
|
||||
$brand = Brand::where('hashid', $brandHashid)
|
||||
->where('business_id', $business->id)
|
||||
->where('is_active', true)
|
||||
->firstOrFail();
|
||||
|
||||
// Load brand with business relationship
|
||||
$brand->load('business');
|
||||
|
||||
// Get products organized by product line
|
||||
$products = $brand->products()
|
||||
->with(['strain', 'images', 'productLine'])
|
||||
->where('is_active', true)
|
||||
->orderBy('product_line_id')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $products->groupBy(function ($product) {
|
||||
return $product->productLine ? $product->productLine->name : 'Other Products';
|
||||
});
|
||||
|
||||
// Get other brands from same business
|
||||
$otherBrands = $business
|
||||
->brands()
|
||||
->where('id', '!=', $brand->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Mark this as buyer view
|
||||
$isSeller = false;
|
||||
|
||||
return view('seller.brands.preview', compact(
|
||||
'business',
|
||||
'brand',
|
||||
'products',
|
||||
'productsByLine',
|
||||
'otherBrands',
|
||||
'isSeller'
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,14 @@ class CartController extends Controller
|
||||
// Fetch items once - calculate totals from loaded collection
|
||||
$items = $this->cartService->getCartItems($user, $sessionId);
|
||||
|
||||
$subtotal = $items->sum(fn ($item) => $item->quantity * ($item->product->wholesale_price ?? 0));
|
||||
$subtotal = $items->sum(function ($item) {
|
||||
$product = $item->product;
|
||||
$regularPrice = $product->wholesale_price ?? $product->msrp ?? 0;
|
||||
$hasSalePrice = $product->sale_price && $product->sale_price < $regularPrice;
|
||||
$unitPrice = $hasSalePrice ? $product->sale_price : $regularPrice;
|
||||
|
||||
return $item->quantity * $unitPrice;
|
||||
});
|
||||
|
||||
// Calculate tax based on business tax rate
|
||||
$taxRate = $business->getTaxRate() ?? 0.08;
|
||||
@@ -101,7 +108,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Update cart item quantity (Ajax).
|
||||
*/
|
||||
public function update(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
public function update(\App\Models\Business $business, Request $request, string $cartId): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'quantity' => 'required|integer|min:1',
|
||||
@@ -111,11 +118,17 @@ class CartController extends Controller
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
try {
|
||||
$cart = $this->cartService->updateQuantity($cartId, $request->integer('quantity'), $user, $sessionId);
|
||||
$cart = $this->cartService->updateQuantity((int) $cartId, $request->integer('quantity'), $user, $sessionId);
|
||||
|
||||
// Ensure product is loaded for JSON response
|
||||
$cart->load('product', 'brand');
|
||||
|
||||
// Calculate unit price (respecting sale pricing)
|
||||
$product = $cart->product;
|
||||
$regularPrice = $product->wholesale_price ?? $product->msrp ?? 0;
|
||||
$hasSalePrice = $product->sale_price && $product->sale_price < $regularPrice;
|
||||
$unitPrice = $hasSalePrice ? $product->sale_price : $regularPrice;
|
||||
|
||||
$subtotal = $this->cartService->getSubtotal($user, $sessionId);
|
||||
$tax = $this->cartService->getTax($user, $sessionId);
|
||||
$total = $this->cartService->getTotal($user, $sessionId);
|
||||
@@ -124,6 +137,7 @@ class CartController extends Controller
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'cart_item' => $cart,
|
||||
'unit_price' => $unitPrice,
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
@@ -140,7 +154,7 @@ class CartController extends Controller
|
||||
/**
|
||||
* Remove item from cart (Ajax).
|
||||
*/
|
||||
public function remove(\App\Models\Business $business, Request $request, int $cartId): JsonResponse
|
||||
public function remove(\App\Models\Business $business, Request $request, string $cartId): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$sessionId = $request->session()->getId();
|
||||
|
||||
@@ -145,14 +145,19 @@ class CheckoutController extends Controller
|
||||
|
||||
// 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;
|
||||
|
||||
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,
|
||||
'unit_price' => $unitPrice,
|
||||
'line_total' => $item->quantity * $unitPrice,
|
||||
'product_name' => $item->product->name,
|
||||
'product_sku' => $item->product->sku,
|
||||
'brand_name' => $item->brand->name ?? '',
|
||||
|
||||
@@ -4,10 +4,7 @@ namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\InvoiceService;
|
||||
use App\Services\OrderModificationService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -48,145 +45,7 @@ class InvoiceController extends Controller
|
||||
|
||||
$invoice->load(['order.items', 'business']);
|
||||
|
||||
// Prepare invoice items data for Alpine.js
|
||||
$invoiceItems = $invoice->order->items->map(function ($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'quantity' => $item->picked_qty,
|
||||
'originalQuantity' => $item->picked_qty,
|
||||
'unit_price' => $item->unit_price,
|
||||
'deleted' => false,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return view('buyer.invoices.show', compact('invoice', 'invoiceItems', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve the invoice without modifications.
|
||||
*/
|
||||
public function approve(\App\Models\Business $business, Invoice $invoice)
|
||||
{
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to approve this invoice.');
|
||||
}
|
||||
|
||||
if (! $invoice->canBeEditedByBuyer()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This invoice cannot be approved at this time.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$invoice->buyerApprove(auth()->user());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Invoice approved successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject the invoice.
|
||||
*/
|
||||
public function reject(\App\Models\Business $business, Request $request, Invoice $invoice)
|
||||
{
|
||||
$request->validate([
|
||||
'reason' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to reject this invoice.');
|
||||
}
|
||||
|
||||
if (! $invoice->canBeEditedByBuyer()) {
|
||||
return back()->with('error', 'This invoice cannot be rejected at this time.');
|
||||
}
|
||||
|
||||
$invoice->buyerReject(auth()->user(), $request->reason);
|
||||
|
||||
return redirect()->route('buyer.invoices.index')
|
||||
->with('success', 'Invoice rejected successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify the invoice (record buyer's changes).
|
||||
*/
|
||||
public function modify(\App\Models\Business $business, Request $request, Invoice $invoice, OrderModificationService $modificationService)
|
||||
{
|
||||
$request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:order_items,id',
|
||||
'items.*.quantity' => 'required|integer|min:0',
|
||||
'items.*.deleted' => 'required|boolean',
|
||||
]);
|
||||
|
||||
if (! $invoice->order || ! $invoice->order->belongsToBusiness($business)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Unauthorized to modify this invoice.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
if (! $invoice->canBeEditedByBuyer()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This invoice cannot be modified at this time.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Record all changes
|
||||
$hasChanges = false;
|
||||
foreach ($request->items as $itemData) {
|
||||
$item = OrderItem::find($itemData['id']);
|
||||
|
||||
// Skip if item doesn't belong to this order
|
||||
if ($item->order_id !== $invoice->order_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for deletion
|
||||
if ($itemData['deleted'] && ! $item->deleted_at) {
|
||||
$modificationService->recordItemDeletion($invoice, $item, auth()->user());
|
||||
$hasChanges = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for quantity change
|
||||
if ($itemData['quantity'] != $item->picked_qty) {
|
||||
// Validate: can only reduce, not increase
|
||||
if ($itemData['quantity'] > $item->picked_qty) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You can only reduce quantities, not increase them.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$modificationService->recordItemChange(
|
||||
$invoice,
|
||||
$item,
|
||||
['quantity' => $itemData['quantity']],
|
||||
auth()->user()
|
||||
);
|
||||
$hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $hasChanges) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'No changes detected.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Update invoice status to buyer_modified
|
||||
$invoice->buyerModify();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Changes saved successfully. The seller will review your modifications.',
|
||||
]);
|
||||
return view('buyer.invoices.show', compact('invoice', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DeliveryWindow;
|
||||
use App\Models\Order;
|
||||
use App\Services\DeliveryWindowService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private DeliveryWindowService $deliveryWindowService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display a listing of the user's orders.
|
||||
*/
|
||||
@@ -42,7 +50,7 @@ class OrderController extends Controller
|
||||
abort(403, 'Unauthorized to view this order.');
|
||||
}
|
||||
|
||||
$order->load(['items.product', 'business', 'location', 'user', 'invoice', 'manifest']);
|
||||
$order->load(['items.product.brand', 'business', 'location', 'user', 'invoice', 'manifest', 'deliveryWindow', 'pendingCancellationRequest']);
|
||||
|
||||
return view('buyer.orders.show', compact('business', 'order'));
|
||||
}
|
||||
@@ -65,8 +73,31 @@ class OrderController extends Controller
|
||||
return back()->with('success', "Order {$order->order_number} has been accepted.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Request cancellation of an order (buyer-initiated).
|
||||
*/
|
||||
public function requestCancellation(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
if (! $order->canRequestCancellation()) {
|
||||
return back()->with('error', 'This order cannot have a cancellation request at this stage.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'reason' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$order->requestCancellation(auth()->user(), $validated['reason']);
|
||||
|
||||
return back()->with('success', "Cancellation request submitted for order {$order->order_number}. The seller will review your request.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an order (buyer-initiated).
|
||||
* NOTE: This is the old direct cancel method, kept for backward compatibility.
|
||||
*/
|
||||
public function cancel(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
@@ -156,4 +187,433 @@ class OrderController extends Controller
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order's delivery window
|
||||
*/
|
||||
public function updateDeliveryWindow(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Ensure order belongs to buyer's business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow updates for delivery orders at approved_for_delivery status
|
||||
if ($order->status !== 'approved_for_delivery') {
|
||||
abort(422, 'Delivery window can only be set after buyer has approved the order for delivery');
|
||||
}
|
||||
|
||||
// Only delivery orders need delivery windows
|
||||
if (! $order->isDelivery()) {
|
||||
abort(422, 'Delivery window can only be set for delivery orders');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'delivery_window_id' => 'required|exists:delivery_windows,id',
|
||||
'delivery_window_date' => 'required|date|after_or_equal:today',
|
||||
'location_id' => 'required|exists:locations,id',
|
||||
]);
|
||||
|
||||
$window = DeliveryWindow::findOrFail($validated['delivery_window_id']);
|
||||
|
||||
// Get seller's business ID from the first order item's product's brand
|
||||
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
|
||||
|
||||
if (! $sellerBusinessId) {
|
||||
abort(422, 'Unable to determine seller business for this order');
|
||||
}
|
||||
|
||||
// Ensure window belongs to the SELLER's business
|
||||
if ($window->business_id !== $sellerBusinessId) {
|
||||
abort(422, 'Delivery window does not belong to seller business');
|
||||
}
|
||||
|
||||
$date = Carbon::parse($validated['delivery_window_date']);
|
||||
|
||||
// Validate that location belongs to buyer's business
|
||||
$location = \App\Models\Location::where('id', $validated['location_id'])
|
||||
->where('business_id', $business->id)
|
||||
->where('accepts_deliveries', true)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $location) {
|
||||
abort(422, 'Invalid delivery location');
|
||||
}
|
||||
|
||||
// Validate using service
|
||||
if (! $this->deliveryWindowService->validateWindowSelection($window, $date)) {
|
||||
abort(422, 'Invalid delivery window selection');
|
||||
}
|
||||
|
||||
$this->deliveryWindowService->updateOrderWindow($order, $window, $date, $validated['location_id']);
|
||||
|
||||
return redirect()
|
||||
->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Delivery scheduled for '.$date->format('l, F j, Y').' at '.$location->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available delivery windows for an order's seller business.
|
||||
*/
|
||||
public function getAvailableDeliveryWindows(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
// Ensure order belongs to buyer's business
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
$date = $request->query('date');
|
||||
if (! $date) {
|
||||
return response()->json(['error' => 'Date parameter required'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$selectedDate = Carbon::parse($date);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => 'Invalid date format'], 400);
|
||||
}
|
||||
|
||||
$dayOfWeek = $selectedDate->dayOfWeek;
|
||||
|
||||
// Get seller's business ID from the first order item's product's brand
|
||||
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
|
||||
|
||||
if (! $sellerBusinessId) {
|
||||
return response()->json(['windows' => []]);
|
||||
}
|
||||
|
||||
// Fetch active delivery windows for the seller on this day
|
||||
$windows = DeliveryWindow::where('business_id', $sellerBusinessId)
|
||||
->where('day_of_week', $dayOfWeek)
|
||||
->where('is_active', true)
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->map(function ($window) {
|
||||
return [
|
||||
'id' => $window->id,
|
||||
'day_name' => $window->day_name,
|
||||
'time_range' => $window->time_range,
|
||||
'start_time' => $window->start_time,
|
||||
'end_time' => $window->end_time,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['windows' => $windows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show pre-delivery approval form (Review #1: After picking, before delivery).
|
||||
* Buyer reviews order with COAs and can approve/reject entire line items.
|
||||
*/
|
||||
public function showPreDeliveryApproval(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
// Authorization check
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to access this order.');
|
||||
}
|
||||
|
||||
// Only ready_for_delivery orders can be reviewed
|
||||
if ($order->status !== 'ready_for_delivery') {
|
||||
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Only orders ready for delivery can be reviewed.');
|
||||
}
|
||||
|
||||
// Load relationships including COAs
|
||||
$order->load([
|
||||
'items.product.brand',
|
||||
'items.batch.coaFiles' => function ($query) {
|
||||
$query->orderBy('is_primary', 'desc')->orderBy('display_order');
|
||||
},
|
||||
'business',
|
||||
'location',
|
||||
]);
|
||||
|
||||
return view('buyer.orders.pre-delivery-review', compact('business', 'order'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pre-delivery approval (Review #1).
|
||||
* Buyer can approve order or reject specific line items.
|
||||
*/
|
||||
public function processPreDeliveryApproval(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Authorization check
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
// Only ready_for_delivery orders can be approved
|
||||
if ($order->status !== 'ready_for_delivery') {
|
||||
return back()->with('error', 'Only orders ready for delivery can be reviewed.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'action' => 'required|in:approve,reject',
|
||||
'rejected_items' => 'nullable|array',
|
||||
'rejected_items.*' => 'exists:order_items,id',
|
||||
'rejection_reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Validate that at least one line item remains active when approving with rejections
|
||||
if ($validated['action'] === 'approve' && ! empty($validated['rejected_items'])) {
|
||||
$totalItems = $order->items()->count();
|
||||
$rejectedItemsCount = count($validated['rejected_items']);
|
||||
|
||||
if ($rejectedItemsCount >= $totalItems) {
|
||||
return back()->withErrors([
|
||||
'rejected_items' => 'You cannot reject all items. If you wish to cancel the entire order, please use the "Request cancellation" option below.',
|
||||
])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$rejectedProductNames = [];
|
||||
|
||||
\DB::transaction(function () use ($order, $validated, &$rejectedProductNames) {
|
||||
if ($validated['action'] === 'reject') {
|
||||
// Reject entire order
|
||||
$order->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejected_reason' => $validated['rejection_reason'] ?? 'Order rejected by buyer during review',
|
||||
]);
|
||||
|
||||
// Return all inventory to stock
|
||||
foreach ($order->items as $item) {
|
||||
if ($item->batch_id && $item->batch) {
|
||||
$item->batch->deallocate($item->quantity);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Approve with optional item rejections
|
||||
if (! empty($validated['rejected_items'])) {
|
||||
|
||||
// Mark rejected items (keep in database for history)
|
||||
foreach ($validated['rejected_items'] as $itemId) {
|
||||
$item = $order->items()->find($itemId);
|
||||
if ($item) {
|
||||
$rejectedProductNames[] = $item->product_name;
|
||||
|
||||
// Return inventory
|
||||
if ($item->batch_id && $item->batch) {
|
||||
$item->batch->deallocate($item->quantity);
|
||||
}
|
||||
|
||||
// Mark item as rejected (don't delete - preserve history)
|
||||
$item->update(['pre_delivery_status' => 'rejected']);
|
||||
|
||||
// Delete related PickingTicketItems to prevent picking
|
||||
\App\Models\PickingTicketItem::where('order_item_id', $item->id)->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Add rejection instruction to the fulfillment work order
|
||||
if (! empty($rejectedProductNames) && $order->fulfillmentWorkOrder) {
|
||||
$rejectionMessage = 'Buyer rejected: '.implode(', ', $rejectedProductNames).'. Pull and restock these items.';
|
||||
|
||||
$order->fulfillmentWorkOrder->update([
|
||||
'instructions' => $order->fulfillmentWorkOrder->instructions
|
||||
? $order->fulfillmentWorkOrder->instructions."\n\n".$rejectionMessage
|
||||
: $rejectionMessage,
|
||||
]);
|
||||
}
|
||||
|
||||
// Check for empty picking tickets and delete them
|
||||
if ($order->fulfillmentWorkOrder) {
|
||||
foreach ($order->fulfillmentWorkOrder->pickingTickets as $ticket) {
|
||||
if ($ticket->items()->count() === 0) {
|
||||
$ticket->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate order totals based on non-rejected items only
|
||||
$order->refresh();
|
||||
$order->load('items');
|
||||
|
||||
$activeItems = $order->items->where('pre_delivery_status', '!=', 'rejected');
|
||||
$subtotal = $activeItems->sum('line_total');
|
||||
$surchargePercent = \App\Models\Order::getSurchargePercentage($order->payment_terms);
|
||||
$surcharge = $subtotal * ($surchargePercent / 100);
|
||||
$taxRate = $order->business->getTaxRate();
|
||||
$tax = ($subtotal + $surcharge) * $taxRate;
|
||||
$total = $subtotal + $surcharge + $tax;
|
||||
|
||||
$order->update([
|
||||
'subtotal' => $subtotal,
|
||||
'surcharge' => $surcharge,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if any non-rejected items remain
|
||||
$activeItemsCount = $order->items()->where(function ($q) {
|
||||
$q->whereNull('pre_delivery_status')->orWhere('pre_delivery_status', '!=', 'rejected');
|
||||
})->count();
|
||||
|
||||
if ($activeItemsCount === 0) {
|
||||
$order->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejected_reason' => 'All items rejected by buyer during review',
|
||||
]);
|
||||
} else {
|
||||
// Mark as approved for delivery
|
||||
$order->update([
|
||||
'status' => 'approved_for_delivery',
|
||||
'buyer_approved_at' => now(),
|
||||
'buyer_approved_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Notify seller if items were rejected
|
||||
if (! empty($rejectedProductNames)) {
|
||||
try {
|
||||
$sellerNotificationService = app(\App\Services\SellerNotificationService::class);
|
||||
$sellerNotificationService->itemsRejectedDuringReview($order, $rejectedProductNames);
|
||||
} catch (\Exception $e) {
|
||||
// Log the error but don't block the approval process
|
||||
\Log::error('Failed to send seller notification for rejected items', [
|
||||
'order_id' => $order->id,
|
||||
'order_number' => $order->order_number,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$message = match ($order->status) {
|
||||
'rejected' => 'Order rejected. Items have been returned to inventory.',
|
||||
'approved_for_delivery' => empty($validated['rejected_items'])
|
||||
? 'Order approved for delivery!'
|
||||
: 'Order approved with '.count($validated['rejected_items']).' item(s) removed.',
|
||||
default => 'Order updated.',
|
||||
};
|
||||
|
||||
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show delivery acceptance form for buyer to accept/reject items (Review #2: After delivery).
|
||||
*/
|
||||
public function showAcceptance(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
// Authorization check
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to access this order.');
|
||||
}
|
||||
|
||||
// Only delivered orders can be accepted
|
||||
if ($order->status !== 'delivered') {
|
||||
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Only delivered orders can be accepted.');
|
||||
}
|
||||
|
||||
// Load relationships including COAs
|
||||
$order->load([
|
||||
'items.product.brand',
|
||||
'items.batch.coaFiles' => function ($query) {
|
||||
$query->orderBy('is_primary', 'desc')->orderBy('display_order');
|
||||
},
|
||||
'business',
|
||||
'location',
|
||||
]);
|
||||
|
||||
return view('buyer.orders.accept', compact('business', 'order'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Process delivery acceptance (accept/reject line items).
|
||||
*/
|
||||
public function processAcceptance(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Authorization check
|
||||
if (! $order->belongsToBusiness($business)) {
|
||||
abort(403, 'Unauthorized to modify this order.');
|
||||
}
|
||||
|
||||
// Only delivered orders can be accepted
|
||||
if ($order->status !== 'delivered') {
|
||||
return back()->with('error', 'Only delivered orders can be accepted.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'items' => 'required|array',
|
||||
'items.*.accepted_qty' => 'required|integer|min:0',
|
||||
'items.*.rejected_qty' => 'required|integer|min:0',
|
||||
'items.*.rejection_reason' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
// Custom validation: accepted + rejected must equal ordered quantity
|
||||
$order->load('items');
|
||||
foreach ($validated['items'] as $itemId => $itemData) {
|
||||
$orderItem = $order->items->firstWhere('id', $itemId);
|
||||
|
||||
if (! $orderItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalQty = $itemData['accepted_qty'] + $itemData['rejected_qty'];
|
||||
if ($totalQty !== $orderItem->quantity) {
|
||||
return back()->withErrors([
|
||||
"items.{$itemId}" => "Accepted and rejected quantities must equal ordered quantity ({$orderItem->quantity})",
|
||||
]);
|
||||
}
|
||||
|
||||
// Validate rejection reason is provided when items are rejected
|
||||
if ($itemData['rejected_qty'] > 0 && empty($itemData['rejection_reason'])) {
|
||||
return back()->withErrors([
|
||||
"items.{$itemId}.rejection_reason" => 'Rejection reason is required when rejecting items',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Update each order item with acceptance data
|
||||
\DB::transaction(function () use ($order, $validated) {
|
||||
foreach ($validated['items'] as $itemId => $itemData) {
|
||||
$orderItem = $order->items->firstWhere('id', $itemId);
|
||||
|
||||
if ($orderItem) {
|
||||
$orderItem->update([
|
||||
'accepted_qty' => $itemData['accepted_qty'],
|
||||
'rejected_qty' => $itemData['rejected_qty'],
|
||||
'rejection_reason' => $itemData['rejection_reason'] ?? null,
|
||||
]);
|
||||
|
||||
// Return rejected items to inventory if batch is set
|
||||
if ($itemData['rejected_qty'] > 0 && $orderItem->batch_id && $orderItem->batch) {
|
||||
$orderItem->batch->deallocate($itemData['rejected_qty']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final order status
|
||||
$hasRejections = collect($validated['items'])->some(fn ($item) => $item['rejected_qty'] > 0);
|
||||
$allRejected = collect($validated['items'])->every(fn ($item) => $item['rejected_qty'] === ($order->items->firstWhere('id', array_search($item, $validated['items']))->quantity ?? 0));
|
||||
|
||||
if ($allRejected) {
|
||||
$order->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejected_reason' => 'All items rejected by buyer',
|
||||
]);
|
||||
} else {
|
||||
$order->markBuyerApproved();
|
||||
|
||||
// Create invoice based on accepted quantities
|
||||
$invoiceService = app(\App\Services\InvoiceService::class);
|
||||
$invoiceService->createFromDelivery($order);
|
||||
}
|
||||
});
|
||||
|
||||
$message = $order->status === 'rejected'
|
||||
? 'Order rejected. All items have been returned to inventory.'
|
||||
: 'Order accepted successfully. Invoice has been generated.';
|
||||
|
||||
return redirect()->route('buyer.business.orders.show', [$business->slug, $order])
|
||||
->with('success', $message);
|
||||
}
|
||||
}
|
||||
|
||||
163
app/Http/Controllers/Buyer/StockNotificationController.php
Normal file
163
app/Http/Controllers/Buyer/StockNotificationController.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Product;
|
||||
use App\Services\StockNotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class StockNotificationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected StockNotificationService $stockNotificationService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Subscribe to stock notification
|
||||
*/
|
||||
public function subscribe(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'email' => 'nullable|email',
|
||||
'phone_number' => 'nullable|string',
|
||||
'whatsapp' => 'nullable|string',
|
||||
'notification_method' => 'nullable|in:email,sms,whatsapp,all',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$product = Product::findOrFail($request->product_id);
|
||||
|
||||
// Check if product is actually out of stock
|
||||
if ($product->isInStock()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This product is currently in stock.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$userId = auth()->check() ? auth()->id() : null;
|
||||
$email = $request->email ?? auth()->user()?->email;
|
||||
$notificationMethod = $request->notification_method ?? 'email';
|
||||
|
||||
// Determine phone number based on notification method
|
||||
// For WhatsApp, use whatsapp field; for SMS use phone_number; for all, prefer phone_number
|
||||
$phoneNumber = null;
|
||||
if ($notificationMethod === 'whatsapp') {
|
||||
$phoneNumber = $request->whatsapp ?? auth()->user()?->phone_number;
|
||||
} elseif ($notificationMethod === 'sms') {
|
||||
$phoneNumber = $request->phone_number ?? auth()->user()?->phone_number;
|
||||
} elseif ($notificationMethod === 'all') {
|
||||
// For 'all', we'll store the primary phone number
|
||||
$phoneNumber = $request->phone_number ?? $request->whatsapp ?? auth()->user()?->phone_number;
|
||||
}
|
||||
|
||||
// Validate that we have at least one contact method
|
||||
if (! $email && ! $phoneNumber) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Please provide contact information.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Check if user already has a pending notification for this product
|
||||
if ($userId) {
|
||||
$existing = \App\Models\StockNotification::where('user_id', $userId)
|
||||
->where('product_id', $product->id)
|
||||
->pending()
|
||||
->notExpired()
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You already have a pending notification for this product.',
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
$notification = $this->stockNotificationService->createNotification(
|
||||
productId: $product->id,
|
||||
userId: $userId,
|
||||
email: $email,
|
||||
phoneNumber: $phoneNumber,
|
||||
notificationMethod: $notificationMethod
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'You will be notified when this product is back in stock!',
|
||||
'notification' => [
|
||||
'id' => $notification->id,
|
||||
'product_name' => $product->name,
|
||||
'expires_at' => $notification->expires_at->toDateString(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a stock notification
|
||||
*/
|
||||
public function cancel(Request $request, int $notificationId): JsonResponse
|
||||
{
|
||||
$userId = auth()->check() ? auth()->id() : null;
|
||||
|
||||
$cancelled = $this->stockNotificationService->cancelNotification($notificationId, $userId);
|
||||
|
||||
if ($cancelled) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Notification cancelled successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Notification not found or already processed.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* List user's pending notifications
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Authentication required.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$notifications = $this->stockNotificationService->getUserNotifications($user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'notifications' => $notifications->map(function ($notification) {
|
||||
return [
|
||||
'id' => $notification->id,
|
||||
'product' => [
|
||||
'id' => $notification->product->id,
|
||||
'name' => $notification->product->name,
|
||||
'sku' => $notification->product->sku,
|
||||
'brand_name' => $notification->product->brand->name,
|
||||
],
|
||||
'notification_method' => $notification->notification_method,
|
||||
'created_at' => $notification->created_at->toDateString(),
|
||||
'expires_at' => $notification->expires_at->toDateString(),
|
||||
];
|
||||
}),
|
||||
]);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/Concerns/HandlesPrecognition.php
Normal file
49
app/Http/Controllers/Concerns/HandlesPrecognition.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Trait HandlesPrecognition
|
||||
*
|
||||
* Adds Laravel Precognition support to controllers for real-time form validation.
|
||||
*
|
||||
* Usage in controller:
|
||||
* ```php
|
||||
* use HandlesPrecognition;
|
||||
*
|
||||
* public function store(Request $request, Business $business)
|
||||
* {
|
||||
* // Handle precognition validation
|
||||
* if ($this->isPrecognitive($request)) {
|
||||
* return;
|
||||
* }
|
||||
*
|
||||
* // Your normal validation and logic
|
||||
* $validated = $request->validate([...]);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
trait HandlesPrecognition
|
||||
{
|
||||
/**
|
||||
* Check if the request is a precognitive validation request
|
||||
*/
|
||||
protected function isPrecognitive(Request $request): bool
|
||||
{
|
||||
return $request->isPrecognitive();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle precognitive validation and return early if needed
|
||||
* This method can be called at the start of store/update methods
|
||||
*/
|
||||
protected function handlePrecognition(Request $request): void
|
||||
{
|
||||
if ($request->isPrecognitive()) {
|
||||
// Laravel automatically handles the validation response
|
||||
// No need to explicitly return anything
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
class Controller
|
||||
{
|
||||
//
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,23 @@ class DashboardController extends Controller
|
||||
$isPending = $business->status === 'submitted';
|
||||
$isRejected = $business->status === 'rejected';
|
||||
|
||||
// Get user's departments to determine which metrics to show
|
||||
$userDepartments = $user->departments ?? collect();
|
||||
$departmentCodes = $userDepartments->pluck('code');
|
||||
|
||||
// Determine dashboard type based on departments
|
||||
$hasSolventless = $departmentCodes->intersect(['LAZ-SOLV', 'CRG-SOLV'])->isNotEmpty();
|
||||
$hasSales = $departmentCodes->intersect(['CBD-SALES', 'CBD-MKTG'])->isNotEmpty();
|
||||
$hasDelivery = $departmentCodes->contains('CRG-DELV');
|
||||
$isOwner = $business->owner_user_id === $user->id;
|
||||
$isSuperAdmin = $user->hasRole('super-admin');
|
||||
|
||||
// Dashboard blocks determined ONLY by department groups (not by ownership or admin role)
|
||||
// Users see data for their assigned departments - add user to department for access
|
||||
$showSalesMetrics = $hasSales;
|
||||
$showProcessingMetrics = $hasSolventless;
|
||||
$showFleetMetrics = $hasDelivery;
|
||||
|
||||
// Get filtered brand IDs for multi-tenancy
|
||||
$brandIds = \App\Http\Controllers\Seller\BrandSwitcherController::getFilteredBrandIds();
|
||||
|
||||
@@ -56,25 +73,27 @@ class DashboardController extends Controller
|
||||
$previousStart = now()->subDays(60);
|
||||
$previousEnd = now()->subDays(30);
|
||||
|
||||
// Get order IDs that have items matching our brands
|
||||
$currentOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$currentStart, $currentEnd]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
// Get order IDs and revenue in single optimized queries using joins
|
||||
$currentStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$currentStart, $currentEnd])
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
|
||||
->first();
|
||||
|
||||
$previousOrderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$previousStart, $previousEnd]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
$previousStats = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$previousStart, $previousEnd])
|
||||
->selectRaw('COUNT(DISTINCT orders.id) as order_count, SUM(orders.total) as revenue')
|
||||
->first();
|
||||
|
||||
// Revenue
|
||||
$currentRevenue = \App\Models\Order::whereIn('id', $currentOrderIds)->sum('total') / 100;
|
||||
$previousRevenue = \App\Models\Order::whereIn('id', $previousOrderIds)->sum('total') / 100;
|
||||
$currentRevenue = ($currentStats->revenue ?? 0) / 100;
|
||||
$previousRevenue = ($previousStats->revenue ?? 0) / 100;
|
||||
$revenueChange = $previousRevenue > 0 ? (($currentRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
|
||||
|
||||
// Orders count
|
||||
$currentOrders = $currentOrderIds->count();
|
||||
$previousOrders = $previousOrderIds->count();
|
||||
$currentOrders = $currentStats->order_count ?? 0;
|
||||
$previousOrders = $previousStats->order_count ?? 0;
|
||||
$ordersChange = $previousOrders > 0 ? (($currentOrders - $previousOrders) / $previousOrders) * 100 : 0;
|
||||
|
||||
// Products count (active products for selected brand(s))
|
||||
@@ -141,18 +160,37 @@ class DashboardController extends Controller
|
||||
// Get chart data for revenue visualization
|
||||
$chartData = $this->getRevenueChartData($brandIds);
|
||||
|
||||
// Get processing metrics if user is in solventless departments
|
||||
$processingData = null;
|
||||
if ($showProcessingMetrics) {
|
||||
$processingData = $this->getProcessingMetrics($business, $userDepartments);
|
||||
}
|
||||
|
||||
// Get fleet metrics if user is in delivery department
|
||||
$fleetData = null;
|
||||
if ($showFleetMetrics) {
|
||||
$fleetData = $this->getFleetMetrics($business);
|
||||
}
|
||||
|
||||
return view('seller.dashboard', [
|
||||
'user' => $user,
|
||||
'business' => $business,
|
||||
'needsOnboarding' => $needsOnboarding,
|
||||
'isPending' => $isPending,
|
||||
'isRejected' => $isRejected,
|
||||
'isOwner' => $isOwner,
|
||||
'dashboardData' => $dashboardData,
|
||||
'progressData' => $progressData,
|
||||
'progressSummary' => $progressSummary,
|
||||
'chartData' => $chartData,
|
||||
'invoiceStats' => $stats,
|
||||
'recentInvoices' => $recentInvoices,
|
||||
'showSalesMetrics' => $showSalesMetrics,
|
||||
'showProcessingMetrics' => $showProcessingMetrics,
|
||||
'showFleetMetrics' => $showFleetMetrics,
|
||||
'processingData' => $processingData,
|
||||
'fleetData' => $fleetData,
|
||||
'userDepartments' => $userDepartments,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -188,16 +226,11 @@ class DashboardController extends Controller
|
||||
$start = now()->sub($count, $unit)->startOfDay();
|
||||
$end = now()->endOfDay();
|
||||
|
||||
// Get all order IDs for the period
|
||||
$orderIds = \App\Models\OrderItem::whereIn('brand_name', $brandNames)
|
||||
->whereHas('order', fn ($q) => $q->whereBetween('created_at', [$start, $end]))
|
||||
->pluck('order_id')
|
||||
->unique();
|
||||
|
||||
// Get orders with dates
|
||||
$orders = \App\Models\Order::whereIn('id', $orderIds)
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as date, SUM(total) as revenue')
|
||||
// Optimized query using join instead of subquery
|
||||
$orders = \App\Models\Order::join('order_items', 'orders.id', '=', 'order_items.order_id')
|
||||
->whereIn('order_items.brand_name', $brandNames)
|
||||
->whereBetween('orders.created_at', [$start, $end])
|
||||
->selectRaw('DATE(orders.created_at) as date, SUM(orders.total) as revenue')
|
||||
->groupBy('date')
|
||||
->orderBy('date', 'asc')
|
||||
->get();
|
||||
@@ -272,4 +305,426 @@ class DashboardController extends Controller
|
||||
'values' => $values,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get processing/manufacturing metrics for solventless departments
|
||||
*/
|
||||
private function getProcessingMetrics(Business $business, $userDepartments): array
|
||||
{
|
||||
$solventlessDepts = $userDepartments->whereIn('code', ['LAZ-SOLV', 'CRG-SOLV']);
|
||||
$departmentIds = $solventlessDepts->pluck('id');
|
||||
|
||||
// Current period (last 30 days)
|
||||
$currentStart = now()->subDays(30);
|
||||
$previousStart = now()->subDays(60);
|
||||
$previousEnd = now()->subDays(30);
|
||||
|
||||
// Get wash reports (hash washes) - using Conversion model
|
||||
$currentWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $currentStart)
|
||||
->count();
|
||||
|
||||
$previousWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('created_at', [$previousStart, $previousEnd])
|
||||
->count();
|
||||
|
||||
$washesChange = $previousWashes > 0 ? (($currentWashes - $previousWashes) / $previousWashes) * 100 : 0;
|
||||
|
||||
// Average Yield (calculate from metadata)
|
||||
$currentWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $currentStart)
|
||||
->get();
|
||||
|
||||
$currentYield = $currentWashesWithYield->avg(function ($conversion) {
|
||||
$stage1 = $conversion->getStage1Data();
|
||||
$stage2 = $conversion->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
}) ?? 0;
|
||||
|
||||
$previousWashesWithYield = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('created_at', [$previousStart, $previousEnd])
|
||||
->get();
|
||||
|
||||
$previousYield = $previousWashesWithYield->avg(function ($conversion) {
|
||||
$stage1 = $conversion->getStage1Data();
|
||||
$stage2 = $conversion->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
}) ?? 0;
|
||||
|
||||
$yieldChange = $previousYield > 0 ? (($currentYield - $previousYield) / $previousYield) * 100 : 0;
|
||||
|
||||
// Active Work Orders
|
||||
$activeWorkOrders = \App\Models\WorkOrder::where('business_id', $business->id)
|
||||
->whereIn('department_id', $departmentIds)
|
||||
->where('status', 'in_progress')
|
||||
->count();
|
||||
|
||||
// Completed Work Orders (30 days)
|
||||
$currentCompletedOrders = \App\Models\WorkOrder::where('business_id', $business->id)
|
||||
->whereIn('department_id', $departmentIds)
|
||||
->where('status', 'completed')
|
||||
->where('updated_at', '>=', $currentStart)
|
||||
->count();
|
||||
|
||||
$previousCompletedOrders = \App\Models\WorkOrder::where('business_id', $business->id)
|
||||
->whereIn('department_id', $departmentIds)
|
||||
->where('status', 'completed')
|
||||
->whereBetween('updated_at', [$previousStart, $previousEnd])
|
||||
->count();
|
||||
|
||||
$completedChange = $previousCompletedOrders > 0 ? (($currentCompletedOrders - $previousCompletedOrders) / $previousCompletedOrders) * 100 : 0;
|
||||
|
||||
// Get strain performance data
|
||||
$strainPerformance = $this->getStrainPerformanceData($business, $currentStart);
|
||||
|
||||
// Get Idle Fresh Frozen data
|
||||
$idleFreshFrozen = $this->getIdleFreshFrozen($business);
|
||||
|
||||
// Get current user's subdivision prefixes (first 3 chars of department codes)
|
||||
$userSubdivisions = auth()->user()->departments()
|
||||
->pluck('code')
|
||||
->map(fn ($code) => substr($code, 0, 3))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
// Get all user IDs in the same subdivisions
|
||||
$allowedOperatorIds = \App\Models\User::whereHas('departments', function ($q) use ($userSubdivisions) {
|
||||
$q->whereIn(\DB::raw('SUBSTRING(code, 1, 3)'), $userSubdivisions->toArray());
|
||||
})->pluck('id');
|
||||
|
||||
// Get Active Washes data
|
||||
$activeWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'in_progress')
|
||||
->whereIn('operator_user_id', $allowedOperatorIds)
|
||||
->with(['operator.departments'])
|
||||
->orderBy('started_at', 'desc')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'washes' => [
|
||||
'current' => $currentWashes,
|
||||
'previous' => $previousWashes,
|
||||
'change' => round($washesChange, 1),
|
||||
],
|
||||
'yield' => [
|
||||
'current' => number_format($currentYield, 1),
|
||||
'previous' => number_format($previousYield, 1),
|
||||
'change' => round($yieldChange, 1),
|
||||
],
|
||||
'active_orders' => [
|
||||
'current' => $activeWorkOrders,
|
||||
'previous' => $activeWorkOrders, // No historical tracking
|
||||
'change' => 0,
|
||||
],
|
||||
'completed_orders' => [
|
||||
'current' => $currentCompletedOrders,
|
||||
'previous' => $previousCompletedOrders,
|
||||
'change' => round($completedChange, 1),
|
||||
],
|
||||
'strain_performance' => $strainPerformance,
|
||||
'idle_fresh_frozen' => $idleFreshFrozen,
|
||||
'active_washes' => $activeWashes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get idle Fresh Frozen components ready for processing
|
||||
*/
|
||||
private function getIdleFreshFrozen(Business $business): array
|
||||
{
|
||||
// Find Fresh Frozen category
|
||||
$ffCategory = \App\Models\ComponentCategory::where('business_id', $business->id)
|
||||
->where('slug', 'fresh-frozen')
|
||||
->first();
|
||||
|
||||
if (! $ffCategory) {
|
||||
return [
|
||||
'components' => collect([]),
|
||||
'total_count' => 0,
|
||||
'total_weight' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
// Get all Fresh Frozen components with inventory
|
||||
$components = \App\Models\Component::where('business_id', $business->id)
|
||||
->where('component_category_id', $ffCategory->id)
|
||||
->where('quantity_on_hand', '>', 0)
|
||||
->where('is_active', true)
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(5) // Show top 5 on dashboard
|
||||
->get();
|
||||
|
||||
// Add past performance data for each component
|
||||
$componentsWithPerformance = $components->map(function ($component) use ($business) {
|
||||
$strainName = str_replace(' - Fresh Frozen', '', $component->name);
|
||||
|
||||
// Get past washes for this strain
|
||||
$pastWashes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereJsonContains('metadata->stage_1->strain', $strainName)
|
||||
->orderBy('completed_at', 'desc')
|
||||
->take(10)
|
||||
->get();
|
||||
|
||||
if ($pastWashes->isEmpty()) {
|
||||
$component->past_performance = [
|
||||
'has_data' => false,
|
||||
'wash_count' => 0,
|
||||
'avg_yield' => null,
|
||||
'avg_hash_quality' => null,
|
||||
];
|
||||
} else {
|
||||
// Calculate average yield
|
||||
$avgYield = $pastWashes->avg(function ($wash) {
|
||||
$stage1 = $wash->getStage1Data();
|
||||
$stage2 = $wash->getStage2Data();
|
||||
if (! $stage1 || ! $stage2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
return $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
});
|
||||
|
||||
// Calculate average hash quality (Stage 2) - defensive extraction
|
||||
$qualityGrades = [];
|
||||
foreach ($pastWashes as $wash) {
|
||||
$stage2 = $wash->getStage2Data();
|
||||
if (! $stage2 || ! isset($stage2['yields'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check all yield types for quality data (handles both hash and rosin structures)
|
||||
foreach ($stage2['yields'] as $yieldType => $yieldData) {
|
||||
if (isset($yieldData['quality']) && $yieldData['quality']) {
|
||||
$qualityGrades[] = $yieldData['quality'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only include quality if we have the data
|
||||
if (empty($qualityGrades)) {
|
||||
$component->past_performance = [
|
||||
'has_data' => true, // Has wash data
|
||||
'wash_count' => $pastWashes->count(),
|
||||
'avg_yield' => round($avgYield, 1),
|
||||
'avg_hash_quality' => null, // No quality data tracked
|
||||
];
|
||||
} else {
|
||||
$avgQuality = $this->calculateAverageQuality($qualityGrades);
|
||||
$component->past_performance = [
|
||||
'has_data' => true,
|
||||
'wash_count' => $pastWashes->count(),
|
||||
'avg_yield' => round($avgYield, 1),
|
||||
'avg_hash_quality' => $avgQuality['letter'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $component;
|
||||
});
|
||||
|
||||
return [
|
||||
'components' => $componentsWithPerformance,
|
||||
'total_count' => $componentsWithPerformance->count(),
|
||||
'total_weight' => $componentsWithPerformance->sum('quantity_on_hand'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get strain-specific performance metrics for processing department
|
||||
*/
|
||||
private function getStrainPerformanceData(Business $business, $startDate): array
|
||||
{
|
||||
// Get all completed washes for the period
|
||||
$washes = \App\Models\Conversion::where('business_id', $business->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->where('created_at', '>=', $startDate)
|
||||
->get();
|
||||
|
||||
// Group by strain and calculate metrics
|
||||
$strainData = [];
|
||||
foreach ($washes as $wash) {
|
||||
$stage1 = $wash->getStage1Data();
|
||||
$stage2 = $wash->getStage2Data();
|
||||
|
||||
if (! $stage1 || ! $stage2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$strain = $stage1['strain'] ?? 'Unknown';
|
||||
$startingWeight = $stage1['starting_weight'] ?? 0;
|
||||
$totalYield = $stage2['total_yield'] ?? 0;
|
||||
|
||||
if (! isset($strainData[$strain])) {
|
||||
$strainData[$strain] = [
|
||||
'strain' => $strain,
|
||||
'wash_count' => 0,
|
||||
'total_input' => 0,
|
||||
'total_output' => 0,
|
||||
'yields' => [],
|
||||
'hash_stage1_quality_grades' => [],
|
||||
'hash_stage2_quality_grades' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$strainData[$strain]['wash_count']++;
|
||||
$strainData[$strain]['total_input'] += $startingWeight;
|
||||
$strainData[$strain]['total_output'] += $totalYield;
|
||||
|
||||
// Calculate yield percentage as number
|
||||
$yieldPercentage = $startingWeight > 0 ? ($totalYield / $startingWeight) * 100 : 0;
|
||||
$strainData[$strain]['yields'][] = $yieldPercentage;
|
||||
|
||||
// Collect quality grades from Stage 1 (hash - initial assessment)
|
||||
if (isset($stage1['quality_grades'])) {
|
||||
foreach ($stage1['quality_grades'] as $micron => $grade) {
|
||||
if ($grade) {
|
||||
$strainData[$strain]['hash_stage1_quality_grades'][] = $grade;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collect quality grades from Stage 2 (hash - final assessment after drying)
|
||||
if (isset($stage2['yields'])) {
|
||||
foreach ($stage2['yields'] as $type => $data) {
|
||||
if (isset($data['quality']) && $data['quality']) {
|
||||
$strainData[$strain]['hash_stage2_quality_grades'][] = $data['quality'];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate averages and format data
|
||||
$results = [];
|
||||
foreach ($strainData as $strain => $data) {
|
||||
$avgYield = count($data['yields']) > 0 ? array_sum($data['yields']) / count($data['yields']) : 0;
|
||||
|
||||
// Calculate average quality grades
|
||||
// Stage 1: Initial hash assessment during washing
|
||||
// Stage 2: Final hash assessment after drying
|
||||
$hashStage1Quality = $this->calculateAverageQuality($data['hash_stage1_quality_grades']);
|
||||
$hashStage2Quality = $this->calculateAverageQuality($data['hash_stage2_quality_grades']);
|
||||
|
||||
$results[] = [
|
||||
'strain' => $strain,
|
||||
'wash_count' => $data['wash_count'],
|
||||
'total_input_g' => round($data['total_input'], 2),
|
||||
'total_output_g' => round($data['total_output'], 2),
|
||||
'avg_yield_percentage' => round($avgYield, 2),
|
||||
'avg_input_per_wash' => $data['wash_count'] > 0 ? round($data['total_input'] / $data['wash_count'], 2) : 0,
|
||||
'avg_output_per_wash' => $data['wash_count'] > 0 ? round($data['total_output'] / $data['wash_count'], 2) : 0,
|
||||
'avg_hash_quality' => $hashStage1Quality['letter'], // Stage 1 assessment
|
||||
'avg_rosin_quality' => $hashStage2Quality['letter'], // Stage 2 assessment (still called rosin for backward compat with views)
|
||||
'hash_quality_score' => $hashStage1Quality['score'],
|
||||
'rosin_quality_score' => $hashStage2Quality['score'], // Actually hash Stage 2 score
|
||||
];
|
||||
}
|
||||
|
||||
// Sort by wash count (most processed strains first)
|
||||
usort($results, function ($a, $b) {
|
||||
return $b['wash_count'] - $a['wash_count'];
|
||||
});
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average quality grade from array of letter grades
|
||||
*
|
||||
* @param array $grades Array of letter grades (A, B, C, D, F)
|
||||
* @return array ['letter' => 'A', 'score' => 4.0]
|
||||
*/
|
||||
private function calculateAverageQuality(array $grades): array
|
||||
{
|
||||
if (empty($grades)) {
|
||||
return ['letter' => null, 'score' => null];
|
||||
}
|
||||
|
||||
// Convert letters to numeric scores
|
||||
$gradeMap = ['A' => 4, 'B' => 3, 'C' => 2, 'D' => 1, 'F' => 0];
|
||||
$scores = array_map(fn ($grade) => $gradeMap[$grade] ?? 0, $grades);
|
||||
|
||||
$avgScore = array_sum($scores) / count($scores);
|
||||
|
||||
// Convert back to letter grade
|
||||
if ($avgScore >= 3.5) {
|
||||
$letter = 'A';
|
||||
} elseif ($avgScore >= 2.5) {
|
||||
$letter = 'B';
|
||||
} elseif ($avgScore >= 1.5) {
|
||||
$letter = 'C';
|
||||
} elseif ($avgScore >= 0.5) {
|
||||
$letter = 'D';
|
||||
} else {
|
||||
$letter = 'F';
|
||||
}
|
||||
|
||||
return ['letter' => $letter, 'score' => round($avgScore, 2)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fleet/delivery metrics
|
||||
*/
|
||||
private function getFleetMetrics(Business $business): array
|
||||
{
|
||||
// Current metrics
|
||||
$totalDrivers = \App\Models\Driver::where('business_id', $business->id)->count();
|
||||
$activeVehicles = \App\Models\Vehicle::where('business_id', $business->id)
|
||||
->where('status', 'active')
|
||||
->count();
|
||||
$totalVehicles = \App\Models\Vehicle::where('business_id', $business->id)->count();
|
||||
|
||||
// Deliveries today (would need Delivery model - placeholder)
|
||||
$deliveriesToday = 0;
|
||||
|
||||
return [
|
||||
'drivers' => [
|
||||
'current' => $totalDrivers,
|
||||
'previous' => $totalDrivers,
|
||||
'change' => 0,
|
||||
],
|
||||
'active_vehicles' => [
|
||||
'current' => $activeVehicles,
|
||||
'previous' => $activeVehicles,
|
||||
'change' => 0,
|
||||
],
|
||||
'total_vehicles' => [
|
||||
'current' => $totalVehicles,
|
||||
'previous' => $totalVehicles,
|
||||
'change' => 0,
|
||||
],
|
||||
'deliveries_today' => [
|
||||
'current' => $deliveriesToday,
|
||||
'previous' => 0,
|
||||
'change' => 0,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
129
app/Http/Controllers/ImageController.php
Normal file
129
app/Http/Controllers/ImageController.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Brand;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class ImageController extends Controller
|
||||
{
|
||||
/**
|
||||
* Serve a brand logo at a specific size
|
||||
* URL: /images/brand-logo/{brand}/{width?}
|
||||
*/
|
||||
public function brandLogo(Brand $brand, ?int $width = null)
|
||||
{
|
||||
if (! $brand->logo_path || ! Storage::disk('public')->exists($brand->logo_path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// If no width specified, return original
|
||||
if (! $width) {
|
||||
$path = storage_path('app/public/'.$brand->logo_path);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
$sizeNames = [
|
||||
160 => 'thumb', // 2x retina for 80px display
|
||||
600 => 'medium', // 2x retina for 300px display
|
||||
1600 => 'large', // 2x retina for 800px display
|
||||
];
|
||||
|
||||
// Check if pre-generated thumbnail exists (from import)
|
||||
if (isset($sizeNames[$width])) {
|
||||
// Try PNG first, then JPG (for backwards compatibility)
|
||||
$ext = pathinfo($brand->logo_path, PATHINFO_EXTENSION);
|
||||
$pregenPath = str_replace('.'.$ext, '-'.$sizeNames[$width].'.'.$ext, $brand->logo_path);
|
||||
if (Storage::disk('public')->exists($pregenPath)) {
|
||||
$path = storage_path('app/public/'.$pregenPath);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists
|
||||
$ext = pathinfo($brand->logo_path, PATHINFO_EXTENSION);
|
||||
$thumbnailName = str_replace('.'.$ext, "-{$width}w.{$ext}", basename($brand->logo_path));
|
||||
$thumbnailPath = 'brands/cache/'.$thumbnailName;
|
||||
|
||||
if (! Storage::disk('public')->exists($thumbnailPath)) {
|
||||
// Generate thumbnail on-the-fly
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read(storage_path('app/public/'.$brand->logo_path));
|
||||
$image->scale(width: $width);
|
||||
|
||||
// Cache the thumbnail
|
||||
if (! Storage::disk('public')->exists('brands/cache')) {
|
||||
Storage::disk('public')->makeDirectory('brands/cache');
|
||||
}
|
||||
|
||||
// Save as PNG or JPEG based on original format
|
||||
$encoded = $ext === 'png' ? $image->toPng() : $image->toJpeg(quality: 90);
|
||||
Storage::disk('public')->put($thumbnailPath, $encoded);
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a brand banner at a specific width
|
||||
* URL: /images/brand-banner/{brand}/{width?}
|
||||
*/
|
||||
public function brandBanner(Brand $brand, ?int $width = null)
|
||||
{
|
||||
if (! $brand->banner_path || ! Storage::disk('public')->exists($brand->banner_path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// If no width specified, return original
|
||||
if (! $width) {
|
||||
$path = storage_path('app/public/'.$brand->banner_path);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
|
||||
// Map common widths to pre-generated sizes (retina-optimized)
|
||||
$sizeNames = [
|
||||
1344 => 'medium', // 2x retina for 672px display
|
||||
2560 => 'large', // 2x retina for 1280px display
|
||||
];
|
||||
|
||||
// Check if pre-generated thumbnail exists (from import)
|
||||
if (isset($sizeNames[$width])) {
|
||||
$pregenPath = str_replace('.jpg', '-'.$sizeNames[$width].'.jpg', $brand->banner_path);
|
||||
if (Storage::disk('public')->exists($pregenPath)) {
|
||||
$path = storage_path('app/public/'.$pregenPath);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cached dynamic thumbnail exists
|
||||
$thumbnailName = str_replace('.jpg', "-{$width}w.jpg", basename($brand->banner_path));
|
||||
$thumbnailPath = 'brands/cache/'.$thumbnailName;
|
||||
|
||||
if (! Storage::disk('public')->exists($thumbnailPath)) {
|
||||
// Generate thumbnail on-the-fly
|
||||
$manager = new ImageManager(new Driver);
|
||||
$image = $manager->read(storage_path('app/public/'.$brand->banner_path));
|
||||
$image->scale(width: $width);
|
||||
|
||||
// Cache the thumbnail
|
||||
if (! Storage::disk('public')->exists('brands/cache')) {
|
||||
Storage::disk('public')->makeDirectory('brands/cache');
|
||||
}
|
||||
|
||||
Storage::disk('public')->put($thumbnailPath, $image->toJpeg(quality: 90));
|
||||
}
|
||||
|
||||
$path = storage_path('app/public/'.$thumbnailPath);
|
||||
|
||||
return response()->file($path);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\DeliveryWindow;
|
||||
use App\Models\Manifest;
|
||||
use App\Models\Order;
|
||||
use App\Services\DeliveryWindowService;
|
||||
use App\Services\ManifestService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -16,6 +19,10 @@ use Illuminate\View\View;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private DeliveryWindowService $deliveryWindowService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display list of orders for sellers.
|
||||
* Shows all orders including new, in-progress, completed, rejected, and cancelled.
|
||||
@@ -30,11 +37,12 @@ class OrderController extends Controller
|
||||
'new',
|
||||
'accepted',
|
||||
'in_progress',
|
||||
'ready_for_invoice',
|
||||
'awaiting_invoice_approval',
|
||||
'ready_for_manifest',
|
||||
'ready_for_delivery',
|
||||
'approved_for_delivery',
|
||||
'out_for_delivery',
|
||||
'delivered',
|
||||
'completed',
|
||||
'rejected',
|
||||
'cancelled',
|
||||
])
|
||||
@@ -82,11 +90,66 @@ class OrderController extends Controller
|
||||
*/
|
||||
public function show(\App\Models\Business $business, Order $order): View
|
||||
{
|
||||
$order->load(['business', 'user', 'location', 'items.product.brand']);
|
||||
$order->load([
|
||||
'business',
|
||||
'user',
|
||||
'location',
|
||||
'items.product.brand',
|
||||
'audits' => function ($query) {
|
||||
$query->with('user')->orderBy('created_at', 'desc');
|
||||
},
|
||||
'pendingCancellationRequest.requestedBy',
|
||||
'cancellationRequests',
|
||||
'cancellationRequests.audits' => function ($query) {
|
||||
$query->with('user')->orderBy('created_at', 'desc');
|
||||
},
|
||||
'cancellationRequests.requestedBy',
|
||||
'cancellationRequests.reviewedBy',
|
||||
]);
|
||||
|
||||
return view('seller.orders.show', compact('order', 'business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a cancellation request (seller action).
|
||||
*/
|
||||
public function approveCancellationRequest(\App\Models\Business $business, Order $order, \App\Models\OrderCancellationRequest $cancellationRequest)
|
||||
{
|
||||
if (! $cancellationRequest->isPending()) {
|
||||
return back()->with('error', 'This cancellation request has already been reviewed.');
|
||||
}
|
||||
|
||||
if ($cancellationRequest->order_id !== $order->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$cancellationRequest->approve(auth()->user());
|
||||
|
||||
return back()->with('success', "Cancellation request approved. Order {$order->order_number} has been cancelled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny a cancellation request (seller action).
|
||||
*/
|
||||
public function denyCancellationRequest(\App\Models\Business $business, Order $order, \App\Models\OrderCancellationRequest $cancellationRequest, Request $request)
|
||||
{
|
||||
if (! $cancellationRequest->isPending()) {
|
||||
return back()->with('error', 'This cancellation request has already been reviewed.');
|
||||
}
|
||||
|
||||
if ($cancellationRequest->order_id !== $order->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'notes' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
$cancellationRequest->deny(auth()->user(), $validated['notes']);
|
||||
|
||||
return back()->with('success', 'Cancellation request denied.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a new order (seller accepting buyer's order).
|
||||
*/
|
||||
@@ -138,14 +201,40 @@ class OrderController extends Controller
|
||||
return back()->with('success', "Order {$order->order_number} has been cancelled.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve order for delivery (after buyer selects delivery method).
|
||||
*/
|
||||
public function approveForDelivery(\App\Models\Business $business, Order $order)
|
||||
{
|
||||
try {
|
||||
$order->approveForDelivery();
|
||||
|
||||
return back()->with('success', 'Order approved for delivery. You can now schedule delivery/pickup.');
|
||||
} catch (\Exception $e) {
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show picking ticket interface for warehouse/lab staff.
|
||||
* Mobile-friendly interface for updating picked quantities.
|
||||
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K
|
||||
*/
|
||||
public function pick(\App\Models\Business $business, Order $pickingTicket): View|RedirectResponse
|
||||
public function pick(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket): View|RedirectResponse
|
||||
{
|
||||
$order = $pickingTicket; // For clarity in blade templates
|
||||
// Handle both old (Order) and new (PickingTicket) systems
|
||||
if ($pickingTicket instanceof \App\Models\PickingTicket) {
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Load relationships for the ticket
|
||||
$ticket->load(['items.orderItem.product', 'department']);
|
||||
|
||||
return view('seller.orders.pick', compact('order', 'ticket', 'business'));
|
||||
}
|
||||
|
||||
// Old system: Order model
|
||||
$order = $pickingTicket;
|
||||
|
||||
// Only allow picking for accepted or in_progress orders
|
||||
if (! in_array($order->status, ['accepted', 'in_progress'])) {
|
||||
@@ -163,49 +252,106 @@ class OrderController extends Controller
|
||||
* Allows partial fulfillment - invoice will reflect actual picked quantities.
|
||||
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K/complete
|
||||
*/
|
||||
public function complete(\App\Models\Business $business, Order $pickingTicket)
|
||||
public function complete(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
|
||||
{
|
||||
$order = $pickingTicket; // For clarity
|
||||
// Handle new PickingTicket system
|
||||
if ($pickingTicket instanceof \App\Models\PickingTicket) {
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Mark this ticket as complete
|
||||
$ticket->complete();
|
||||
|
||||
// PickingTicket->complete() handles:
|
||||
// - Setting ticket status to 'completed'
|
||||
// - Checking if all tickets are complete
|
||||
// - Advancing order to ready_for_delivery if all tickets done
|
||||
// The order status flow is now: accepted -> in_progress -> ready_for_delivery
|
||||
|
||||
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Picking ticket completed successfully!');
|
||||
}
|
||||
|
||||
// Handle old single picking ticket system (Order model)
|
||||
$order = $pickingTicket;
|
||||
|
||||
// Calculate final workorder status based on picked quantities
|
||||
$order->updatePickingStatus();
|
||||
$order->refresh();
|
||||
|
||||
// Recalculate order totals based on picked quantities
|
||||
$subtotal = 0;
|
||||
foreach ($order->items as $item) {
|
||||
// Update line total based on picked quantity
|
||||
$newLineTotal = $item->unit_price * $item->picked_qty;
|
||||
$item->update(['line_total' => $newLineTotal]);
|
||||
$subtotal += $newLineTotal;
|
||||
}
|
||||
|
||||
// Update order totals
|
||||
$tax = $subtotal * 0.0; // TODO: Calculate tax based on company settings
|
||||
$total = $subtotal + $tax;
|
||||
|
||||
$order->update([
|
||||
'subtotal' => $subtotal,
|
||||
'tax' => $tax,
|
||||
'total' => $total,
|
||||
'status' => 'ready_for_invoice',
|
||||
'ready_for_invoice_at' => now(),
|
||||
]);
|
||||
|
||||
// Automatically generate invoice for buyer approval
|
||||
$invoiceService = app(\App\Services\InvoiceService::class);
|
||||
$invoice = $invoiceService->generateFromOrder($order);
|
||||
|
||||
// Update order to awaiting invoice approval status
|
||||
$order->update([
|
||||
'status' => 'awaiting_invoice_approval',
|
||||
'invoiced_at' => now(),
|
||||
]);
|
||||
|
||||
// Invoice is now ready for buyer approval with approval_status = 'pending_buyer_approval'
|
||||
// NOTE: Do NOT auto-advance to ready_for_delivery
|
||||
// Seller must manually click "Mark Order Ready for Buyer Review" button
|
||||
|
||||
return redirect()->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Picking ticket completed! Invoice has been generated based on fulfilled quantities.');
|
||||
->with('success', 'Picking ticket completed! You can now mark the order ready for buyer review.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-open a completed picking ticket to allow editing.
|
||||
*/
|
||||
public function reopen(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
|
||||
{
|
||||
// Handle new PickingTicket system
|
||||
if ($pickingTicket instanceof \App\Models\PickingTicket) {
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Only allow re-opening if seller hasn't marked order ready for buyer review yet
|
||||
// Once seller clicks "Mark Order Ready for Buyer Review", picking is locked
|
||||
if (! in_array($order->status, ['accepted', 'in_progress'])) {
|
||||
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
|
||||
->with('error', 'Cannot re-open ticket - seller has already marked this order ready for buyer review.');
|
||||
}
|
||||
|
||||
// Re-open the ticket
|
||||
$ticket->update([
|
||||
'status' => 'in_progress',
|
||||
'completed_at' => null,
|
||||
]);
|
||||
|
||||
// If work order was marked as complete, recalculate status
|
||||
if ($ticket->fulfillmentWorkOrder && $ticket->fulfillmentWorkOrder->status === 'completed') {
|
||||
$workOrderService = app(\App\Services\FulfillmentWorkOrderService::class);
|
||||
$workOrderService->recalculateWorkOrderStatus($ticket->fulfillmentWorkOrder);
|
||||
}
|
||||
|
||||
return redirect()->route('seller.business.pick', [$business->slug, $ticket->ticket_number])
|
||||
->with('success', 'Picking ticket re-opened successfully. You can now make changes.');
|
||||
}
|
||||
|
||||
// Handle old system
|
||||
$order = $pickingTicket;
|
||||
|
||||
return redirect()->route('seller.business.pick', [$business->slug, $order->picking_ticket_number])
|
||||
->with('error', 'Re-opening tickets is only supported for the new picking ticket system.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display picking ticket as PDF in browser.
|
||||
* Accessed via PT-XXXXX format: /s/{business}/pick/PT-A3X7K/pdf
|
||||
*/
|
||||
public function downloadPickingTicketPdf(\App\Models\Business $business, Order|\App\Models\PickingTicket $pickingTicket)
|
||||
{
|
||||
// Handle both old (Order) and new (PickingTicket) systems
|
||||
if ($pickingTicket instanceof \App\Models\PickingTicket) {
|
||||
$ticket = $pickingTicket;
|
||||
$order = $ticket->fulfillmentWorkOrder->order;
|
||||
|
||||
// Load relationships for the ticket
|
||||
$ticket->load(['items.orderItem.product.brand', 'department']);
|
||||
|
||||
$pdf = \PDF::loadView('seller.orders.pick-pdf', compact('order', 'ticket', 'business'));
|
||||
|
||||
return $pdf->stream('picking-ticket-'.$ticket->ticket_number.'.pdf');
|
||||
}
|
||||
|
||||
// Old system: Order model
|
||||
$order = $pickingTicket;
|
||||
$order->load(['business', 'user', 'location', 'items.product.brand']);
|
||||
|
||||
$pdf = \PDF::loadView('seller.orders.pick-pdf', compact('order', 'business'));
|
||||
|
||||
return $pdf->stream('picking-ticket-'.$order->picking_ticket_number.'.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -621,4 +767,389 @@ class OrderController extends Controller
|
||||
'delivery_url' => $deliveryUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update pickup date for an order
|
||||
*/
|
||||
public function updatePickupDate(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
// Ensure order can be accessed by this business (seller)
|
||||
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
|
||||
if ($sellerBusinessId !== $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow updates for pickup orders at ready_for_delivery or approved_for_delivery status
|
||||
if (! in_array($order->status, ['ready_for_delivery', 'approved_for_delivery'])) {
|
||||
abort(422, 'Pickup date can only be set when order is ready for pickup');
|
||||
}
|
||||
|
||||
if (! $order->isPickup()) {
|
||||
abort(422, 'Pickup date can only be set for pickup orders');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'pickup_date' => 'required|date|after_or_equal:today',
|
||||
]);
|
||||
|
||||
$order->update([
|
||||
'pickup_date' => $validated['pickup_date'],
|
||||
]);
|
||||
|
||||
// Return JSON for AJAX requests
|
||||
if ($request->expectsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Pickup date updated successfully',
|
||||
'pickup_date' => $order->pickup_date->format('l, F j, Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Pickup date updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark order as ready for delivery (seller action).
|
||||
* Only available when all picking tickets are completed.
|
||||
*/
|
||||
public function markReadyForDelivery(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
// Verify business owns this order
|
||||
$isSellerOrder = $order->items()->whereHas('product.brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->exists();
|
||||
|
||||
if (! $isSellerOrder) {
|
||||
abort(403, 'Unauthorized access to this order');
|
||||
}
|
||||
|
||||
// Only allow when order is accepted or in_progress
|
||||
if (! in_array($order->status, ['accepted', 'in_progress'])) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order cannot be marked as ready for delivery from current status');
|
||||
}
|
||||
|
||||
// Verify all items have been picked (workorder at 100% OR all picking tickets completed)
|
||||
// Note: We check picking tickets first because there may be short-picks where workorder < 100%
|
||||
// but the warehouse has completed all tickets (meaning they picked everything available)
|
||||
if (! $order->allPickingTicketsCompleted() && $order->workorder_status < 100) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'All order items must be picked before marking order ready for delivery');
|
||||
}
|
||||
|
||||
// Mark order as ready for delivery
|
||||
$success = $order->markReadyForDelivery();
|
||||
|
||||
if ($success) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Order marked as ready for delivery. Buyer has been notified.');
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Failed to mark order as ready for delivery');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available delivery windows for a specific date (for sellers).
|
||||
*/
|
||||
public function getAvailableDeliveryWindows(\App\Models\Business $business, Order $order, Request $request)
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
$date = $request->query('date');
|
||||
if (! $date) {
|
||||
return response()->json(['error' => 'Date parameter required'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$selectedDate = \Carbon\Carbon::parse($date);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['error' => 'Invalid date format'], 400);
|
||||
}
|
||||
|
||||
$dayOfWeek = $selectedDate->dayOfWeek;
|
||||
|
||||
// Fetch active delivery windows for the seller's business on this day
|
||||
$windows = \App\Models\DeliveryWindow::where('business_id', $business->id)
|
||||
->where('day_of_week', $dayOfWeek)
|
||||
->where('is_active', true)
|
||||
->orderBy('start_time')
|
||||
->get()
|
||||
->map(function ($window) {
|
||||
return [
|
||||
'id' => $window->id,
|
||||
'day_name' => $window->day_name,
|
||||
'time_range' => $window->time_range,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['windows' => $windows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update order's delivery window (seller action).
|
||||
*/
|
||||
public function updateDeliveryWindow(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
$sellerBusinessId = $order->items->first()?->product?->brand?->business_id;
|
||||
if ($sellerBusinessId !== $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow updates for delivery orders at approved_for_delivery status
|
||||
if ($order->status !== 'approved_for_delivery') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Delivery window can only be set after buyer has approved the order for delivery');
|
||||
}
|
||||
|
||||
// Only delivery orders need delivery windows
|
||||
if (! $order->isDelivery()) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Delivery window can only be set for delivery orders');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'delivery_window_id' => 'required|exists:delivery_windows,id',
|
||||
'delivery_window_date' => 'required|date|after_or_equal:today',
|
||||
]);
|
||||
|
||||
$window = DeliveryWindow::findOrFail($validated['delivery_window_id']);
|
||||
|
||||
// Ensure window belongs to seller's business
|
||||
if ($window->business_id !== $business->id) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Delivery window does not belong to your business');
|
||||
}
|
||||
|
||||
$date = Carbon::parse($validated['delivery_window_date']);
|
||||
|
||||
// Validate using service
|
||||
if (! $this->deliveryWindowService->validateWindowSelection($window, $date)) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Invalid delivery window selection');
|
||||
}
|
||||
|
||||
$this->deliveryWindowService->updateOrderWindow($order, $window, $date);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Delivery window updated successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark order as out for delivery (for delivery orders at approved_for_delivery status).
|
||||
*/
|
||||
public function markOutForDelivery(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow for delivery orders at approved_for_delivery status
|
||||
if ($order->status !== 'approved_for_delivery') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order must be approved for delivery before marking as out for delivery');
|
||||
}
|
||||
|
||||
if (! $order->isDelivery()) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'This action is only available for delivery orders');
|
||||
}
|
||||
|
||||
// Require delivery window to be set
|
||||
if (! $order->deliveryWindow || ! $order->delivery_window_date) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Please schedule a delivery window before marking order as out for delivery');
|
||||
}
|
||||
|
||||
// Update order status
|
||||
$order->update([
|
||||
'status' => 'out_for_delivery',
|
||||
'out_for_delivery_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Order marked as out for delivery');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm pickup complete (for pickup orders at approved_for_delivery status).
|
||||
*/
|
||||
public function confirmPickup(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow for pickup orders at approved_for_delivery status
|
||||
if ($order->status !== 'approved_for_delivery') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order must be approved for delivery before confirming pickup');
|
||||
}
|
||||
|
||||
if (! $order->isPickup()) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'This action is only available for pickup orders');
|
||||
}
|
||||
|
||||
// Require pickup date to be set
|
||||
if (! $order->pickup_date) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Please set a pickup date before confirming pickup completion');
|
||||
}
|
||||
|
||||
// Update order status to delivered (pickup complete)
|
||||
$order->update([
|
||||
'status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Pickup confirmed! Order marked as delivered.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm delivery complete (for delivery orders).
|
||||
*/
|
||||
public function confirmDelivery(\App\Models\Business $business, Order $order): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if (! $order->items->first()?->product?->brand?->business_id === $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow for delivery orders at out_for_delivery status
|
||||
if ($order->status !== 'out_for_delivery') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order must be out for delivery before confirming delivery completion');
|
||||
}
|
||||
|
||||
if (! $order->isDelivery()) {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'This action is only available for delivery orders');
|
||||
}
|
||||
|
||||
// Update order status to delivered
|
||||
$order->update([
|
||||
'status' => 'delivered',
|
||||
'delivered_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Delivery confirmed! Order marked as delivered. You can now finalize the order.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize order after delivery - confirm actual delivered quantities and complete the order.
|
||||
*/
|
||||
public function finalizeOrder(\App\Models\Business $business, Order $order, Request $request): RedirectResponse
|
||||
{
|
||||
// Ensure order is for seller's business
|
||||
if ($order->items->first()?->product?->brand?->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized access to order');
|
||||
}
|
||||
|
||||
// Only allow finalization for delivered orders
|
||||
if ($order->status !== 'delivered') {
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('error', 'Order must be delivered before it can be finalized');
|
||||
}
|
||||
|
||||
// Validate the request
|
||||
$validated = $request->validate([
|
||||
'delivery_notes' => 'nullable|string|max:5000',
|
||||
'items' => 'required|array',
|
||||
'items.*.id' => 'required|exists:order_items,id',
|
||||
'items.*.delivered_qty' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
\DB::transaction(function () use ($order, $validated) {
|
||||
|
||||
foreach ($validated['items'] as $itemData) {
|
||||
$orderItem = $order->items()->findOrFail($itemData['id']);
|
||||
$deliveredQty = (float) $itemData['delivered_qty'];
|
||||
$pickedQty = (float) $orderItem->picked_qty;
|
||||
|
||||
// Calculate rejected quantity (items that were picked but not delivered)
|
||||
$rejectedQty = $pickedQty - $deliveredQty;
|
||||
|
||||
// Update the order item with delivered quantity and acceptance data
|
||||
// delivered_qty = what seller confirmed was delivered
|
||||
// accepted_qty = same as delivered_qty (what will be invoiced)
|
||||
// rejected_qty = what was picked but not delivered/accepted
|
||||
$orderItem->update([
|
||||
'delivered_qty' => $deliveredQty,
|
||||
'accepted_qty' => $deliveredQty,
|
||||
'rejected_qty' => $rejectedQty,
|
||||
]);
|
||||
|
||||
// Return rejected items to inventory if any
|
||||
if ($rejectedQty > 0 && $orderItem->batch_id && $orderItem->batch) {
|
||||
$orderItem->batch->increment('quantity_available', $rejectedQty);
|
||||
}
|
||||
}
|
||||
|
||||
// Update order with finalization details
|
||||
$order->update([
|
||||
'delivery_notes' => $validated['delivery_notes'],
|
||||
'finalized_at' => now(),
|
||||
'finalized_by_user_id' => Auth::id(),
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
// Recalculate line totals for each item based on delivered quantities
|
||||
$newSubtotal = 0;
|
||||
foreach ($order->items as $item) {
|
||||
$deliveredQty = $item->delivered_qty ?? $item->picked_qty;
|
||||
$lineTotal = $deliveredQty * $item->unit_price;
|
||||
|
||||
$item->update(['line_total' => $lineTotal]);
|
||||
$newSubtotal += $lineTotal;
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'subtotal' => $newSubtotal,
|
||||
'total' => $newSubtotal + ($order->tax ?? 0) + ($order->delivery_fee ?? 0),
|
||||
]);
|
||||
|
||||
// Refresh order to get updated items with delivered_qty
|
||||
$order->refresh();
|
||||
|
||||
// Generate final invoice based on delivered quantities
|
||||
$invoiceService = app(\App\Services\InvoiceService::class);
|
||||
$invoiceService->generateFromOrder($order);
|
||||
});
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.orders.show', [$business->slug, $order])
|
||||
->with('success', 'Order finalized successfully. Final invoice generated.');
|
||||
}
|
||||
}
|
||||
|
||||
118
app/Http/Controllers/PublicCoaController.php
Normal file
118
app/Http/Controllers/PublicCoaController.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Batch;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PublicCoaController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display COA information for a specific batch
|
||||
* Public route: /coa/{batchNumber}
|
||||
*/
|
||||
public function show(string $batchNumber)
|
||||
{
|
||||
// Find batch by batch number
|
||||
$batch = Batch::where('batch_number', $batchNumber)
|
||||
->with(['product', 'lab.coaFiles', 'business'])
|
||||
->first();
|
||||
|
||||
if (! $batch) {
|
||||
abort(404, 'Batch not found');
|
||||
}
|
||||
|
||||
// Get lab test and COA files
|
||||
$lab = $batch->lab;
|
||||
|
||||
if (! $lab) {
|
||||
abort(404, 'No lab test available for this batch');
|
||||
}
|
||||
|
||||
// Get all COA files
|
||||
$coaFiles = $lab->getAllCoas();
|
||||
$primaryCoa = $lab->getPrimaryCoa();
|
||||
|
||||
return view('public.coa.show', [
|
||||
'batch' => $batch,
|
||||
'lab' => $lab,
|
||||
'coaFiles' => $coaFiles,
|
||||
'primaryCoa' => $primaryCoa,
|
||||
'product' => $batch->product,
|
||||
'business' => $batch->business,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific COA file
|
||||
*/
|
||||
public function download(string $batchNumber, int $coaFileId)
|
||||
{
|
||||
// Find batch
|
||||
$batch = Batch::where('batch_number', $batchNumber)
|
||||
->with('lab.coaFiles')
|
||||
->first();
|
||||
|
||||
if (! $batch || ! $batch->lab) {
|
||||
abort(404, 'Batch or lab test not found');
|
||||
}
|
||||
|
||||
// Find COA file
|
||||
$coaFile = $batch->lab->coaFiles()->find($coaFileId);
|
||||
|
||||
if (! $coaFile) {
|
||||
abort(404, 'COA file not found');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (! $coaFile->exists()) {
|
||||
abort(404, 'File not found in storage');
|
||||
}
|
||||
|
||||
// Download the file
|
||||
return Storage::download($coaFile->file_path, $coaFile->file_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* View a specific COA file inline (for PDFs)
|
||||
*/
|
||||
public function view(string $batchNumber, int $coaFileId): StreamedResponse
|
||||
{
|
||||
// Find batch
|
||||
$batch = Batch::where('batch_number', $batchNumber)
|
||||
->with('lab.coaFiles')
|
||||
->first();
|
||||
|
||||
if (! $batch || ! $batch->lab) {
|
||||
abort(404, 'Batch or lab test not found');
|
||||
}
|
||||
|
||||
// Find COA file
|
||||
$coaFile = $batch->lab->coaFiles()->find($coaFileId);
|
||||
|
||||
if (! $coaFile) {
|
||||
abort(404, 'COA file not found');
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
if (! $coaFile->exists()) {
|
||||
abort(404, 'File not found in storage');
|
||||
}
|
||||
|
||||
// Stream the file for inline viewing
|
||||
return Storage::response($coaFile->file_path, $coaFile->file_name, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$coaFile->file_name.'"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy route support: /retail/labs/{batchNumber}
|
||||
* Redirects to new COA route
|
||||
*/
|
||||
public function legacyShow(string $batchNumber)
|
||||
{
|
||||
return redirect()->route('public.coa.show', ['batchNumber' => $batchNumber], 301);
|
||||
}
|
||||
}
|
||||
33
app/Http/Controllers/Seller/BackorderController.php
Normal file
33
app/Http/Controllers/Seller/BackorderController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Services\BackorderService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BackorderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected BackorderService $backorderService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Show backorders landing page (under Transactions)
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Get backorders for this seller business
|
||||
$backorders = $this->backorderService->getBackordersForSeller($business);
|
||||
|
||||
// Get stats
|
||||
$stats = $this->backorderService->getBackorderStats($business);
|
||||
|
||||
return view('seller.backorders.index', [
|
||||
'business' => $business,
|
||||
'backorders' => $backorders,
|
||||
'stats' => $stats,
|
||||
]);
|
||||
}
|
||||
}
|
||||
364
app/Http/Controllers/Seller/BatchController.php
Normal file
364
app/Http/Controllers/Seller/BatchController.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Batch;
|
||||
use App\Models\Business;
|
||||
use App\Models\Product;
|
||||
use App\Services\QrCodeService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class BatchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of batches for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
// Build query for batches
|
||||
$query = Batch::where('business_id', $business->id)
|
||||
->with(['product.brand', 'coaFiles'])
|
||||
->orderBy('production_date', 'desc');
|
||||
|
||||
// Search filter
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('batch_number', 'LIKE', "%{$search}%")
|
||||
->orWhere('test_id', 'LIKE', "%{$search}%")
|
||||
->orWhere('lot_number', 'LIKE', "%{$search}%")
|
||||
->orWhereHas('product', function ($productQuery) use ($search) {
|
||||
$productQuery->where('name', 'LIKE', "%{$search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$batches = $query->paginate(20)->withQueryString();
|
||||
|
||||
// Separate active and inactive batches
|
||||
$activeBatches = $batches->filter(fn ($batch) => $batch->is_active);
|
||||
$inactiveBatches = $batches->filter(fn ($batch) => ! $batch->is_active);
|
||||
|
||||
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new batch
|
||||
*/
|
||||
public function create(Request $request, Business $business)
|
||||
{
|
||||
// Get products owned by this business
|
||||
$products = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->orderBy('name', 'asc')->get();
|
||||
|
||||
// For the new architecture, components are products (the view expects $components)
|
||||
$components = $products;
|
||||
|
||||
// Get existing component batches that can be used as sources for homogenized batches
|
||||
$componentBatches = Batch::where('business_id', $business->id)
|
||||
->where('quantity_remaining', '>', 0)
|
||||
->where('is_active', true)
|
||||
->where('is_quarantined', false)
|
||||
->with('component')
|
||||
->orderBy('batch_number')
|
||||
->get();
|
||||
|
||||
return view('seller.batches.create', compact('business', 'products', 'components', 'componentBatches'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created batch
|
||||
*/
|
||||
public function store(Request $request, Business $business)
|
||||
{
|
||||
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
||||
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number',
|
||||
'production_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'notes' => 'nullable|string',
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
// Set business_id
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['is_active'] = true; // New batches are active by default
|
||||
|
||||
// Create batch (calculations happen in model boot method)
|
||||
$batch = Batch::create($validated);
|
||||
|
||||
// Handle COA file uploads
|
||||
if ($request->hasFile('coa_files')) {
|
||||
foreach ($request->file('coa_files') as $index => $file) {
|
||||
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
||||
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
||||
$filePath = $file->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
$batch->coaFiles()->create([
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $filePath,
|
||||
'file_type' => $file->getClientOriginalExtension(),
|
||||
'file_size' => $file->getSize(),
|
||||
'is_primary' => $index === 0,
|
||||
'display_order' => $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-generate QR code for the new batch (with brand logo if available)
|
||||
$qrService = app(QrCodeService::class);
|
||||
$qrService->generateWithLogo($batch);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch created successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified batch
|
||||
*/
|
||||
public function edit(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Get products owned by this business
|
||||
$products = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->orderBy('name', 'asc')->get();
|
||||
|
||||
$batch->load('coaFiles');
|
||||
|
||||
return view('seller.batches.edit', compact('business', 'batch', 'products'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified batch
|
||||
*/
|
||||
public function update(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Determine max value based on unit (% vs mg/g, mg/ml, mg/unit)
|
||||
$maxValue = $request->cannabinoid_unit === '%' ? 100 : 1000;
|
||||
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'cannabinoid_unit' => 'required|string|in:%,MG/ML,MG/G,MG/UNIT',
|
||||
'batch_number' => 'nullable|string|max:100|unique:batches,batch_number,'.$batch->id,
|
||||
'production_date' => 'nullable|date',
|
||||
'test_date' => 'nullable|date',
|
||||
'test_id' => 'nullable|string|max:100',
|
||||
'lot_number' => 'nullable|string|max:100',
|
||||
'lab_name' => 'nullable|string|max:255',
|
||||
'thc_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'thca_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbd_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbda_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbg_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'cbn_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'delta_9_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'total_terps_percentage' => "nullable|numeric|min:0|max:{$maxValue}",
|
||||
'notes' => 'nullable|string',
|
||||
'coa_files.*' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240', // 10MB max per file
|
||||
]);
|
||||
|
||||
// Verify product belongs to this business
|
||||
$product = Product::whereHas('brand', function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
})->findOrFail($validated['product_id']);
|
||||
|
||||
// Update batch (calculations happen in model boot method)
|
||||
$batch->update($validated);
|
||||
|
||||
// Handle new COA file uploads
|
||||
if ($request->hasFile('coa_files')) {
|
||||
$existingFilesCount = $batch->coaFiles()->count();
|
||||
foreach ($request->file('coa_files') as $index => $file) {
|
||||
$storagePath = "businesses/{$business->uuid}/batches/{$batch->id}/coas";
|
||||
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
||||
$filePath = $file->storeAs($storagePath, $fileName, 'public');
|
||||
|
||||
$batch->coaFiles()->create([
|
||||
'file_name' => $file->getClientOriginalName(),
|
||||
'file_path' => $filePath,
|
||||
'file_type' => $file->getClientOriginalExtension(),
|
||||
'file_size' => $file->getSize(),
|
||||
'is_primary' => $existingFilesCount === 0 && $index === 0,
|
||||
'display_order' => $existingFilesCount + $index,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch updated successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified batch
|
||||
*/
|
||||
public function destroy(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
// Delete associated COA files from storage
|
||||
foreach ($batch->coaFiles as $coaFile) {
|
||||
if (Storage::disk('public')->exists($coaFile->file_path)) {
|
||||
Storage::disk('public')->delete($coaFile->file_path);
|
||||
}
|
||||
}
|
||||
|
||||
$batch->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.batches.index', $business->slug)
|
||||
->with('success', 'Batch deleted successfully.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate QR code for a batch
|
||||
*/
|
||||
public function generateQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->generateWithLogo($batch);
|
||||
|
||||
// Refresh batch to get updated qr_code_path
|
||||
$batch->refresh();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
||||
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download QR code for a batch
|
||||
*/
|
||||
public function downloadQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$download = $qrService->download($batch);
|
||||
|
||||
if (! $download) {
|
||||
return back()->with('error', 'QR code not found');
|
||||
}
|
||||
|
||||
return $download;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate QR code for a batch
|
||||
*/
|
||||
public function regenerateQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->regenerate($batch);
|
||||
|
||||
// Refresh batch to get updated qr_code_path
|
||||
$batch->refresh();
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
'qr_code_url' => $batch->qr_code_path ? Storage::url($batch->qr_code_path) : null,
|
||||
'download_url' => $batch->qr_code_path ? route('seller.business.batches.qr-code.download', [$business->slug, $batch->id]) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete QR code for a batch
|
||||
*/
|
||||
public function deleteQrCode(Request $request, Business $business, Batch $batch)
|
||||
{
|
||||
// Verify batch belongs to this business
|
||||
if ($batch->business_id !== $business->id) {
|
||||
abort(403, 'Unauthorized');
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->delete($batch);
|
||||
|
||||
return response()->json([
|
||||
'success' => $result['success'],
|
||||
'message' => $result['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk generate QR codes for multiple batches
|
||||
*/
|
||||
public function bulkGenerateQrCodes(Request $request, Business $business)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'batch_ids' => 'required|array',
|
||||
'batch_ids.*' => 'exists:batches,id',
|
||||
]);
|
||||
|
||||
// Verify all batches belong to this business
|
||||
$batches = Batch::whereIn('id', $validated['batch_ids'])
|
||||
->where('business_id', $business->id)
|
||||
->get();
|
||||
|
||||
if ($batches->count() !== count($validated['batch_ids'])) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Some batches do not belong to this business',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$qrService = app(QrCodeService::class);
|
||||
$result = $qrService->bulkGenerate($validated['batch_ids']);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
}
|
||||
649
app/Http/Controllers/Seller/BrandController.php
Normal file
649
app/Http/Controllers/Seller/BrandController.php
Normal file
@@ -0,0 +1,649 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Concerns\HandlesPrecognition;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreBrandRequest;
|
||||
use App\Http\Requests\UpdateBrandRequest;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class BrandController extends Controller
|
||||
{
|
||||
use HandlesPrecognition;
|
||||
|
||||
/**
|
||||
* Display a listing of brands for the business
|
||||
*/
|
||||
public function index(Request $request, Business $business)
|
||||
{
|
||||
$this->authorize('viewAny', [Brand::class, $business]);
|
||||
|
||||
// Get brands for this business and parent company (if division)
|
||||
$brands = Brand::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.brands.index', compact('business', 'brands'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new brand
|
||||
*/
|
||||
public function create(Business $business)
|
||||
{
|
||||
$this->authorize('create', [Brand::class, $business]);
|
||||
|
||||
return view('seller.brands.create', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Nexus version of the brand create form (demo/test)
|
||||
*/
|
||||
public function createNexus(Business $business)
|
||||
{
|
||||
return view('seller.brands.create-nexus', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Nexus version of the brand edit form (demo/test)
|
||||
*/
|
||||
public function editNexus(Business $business, Brand $brand)
|
||||
{
|
||||
return view('seller.brands.edit-nexus', compact('business', 'brand'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created brand in storage
|
||||
*/
|
||||
public function store(StoreBrandRequest $request, Business $business)
|
||||
{
|
||||
// Authorization is handled by StoreBrandRequest
|
||||
$validated = $request->validated();
|
||||
|
||||
// Clean and normalize website URL - strip any protocol user entered, then add https://
|
||||
if ($request->filled('website_url')) {
|
||||
$url = $validated['website_url'];
|
||||
|
||||
// Strip http:// or https:// if user entered it
|
||||
$url = preg_replace('#^https?://#i', '', $url);
|
||||
|
||||
// Strip any leading/trailing whitespace
|
||||
$url = trim($url);
|
||||
|
||||
// Validate that we have a valid domain format
|
||||
if (! empty($url) && ! filter_var('https://'.$url, FILTER_VALIDATE_URL)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->withErrors(['website_url' => 'Please enter a valid website URL (e.g., example.com)']);
|
||||
}
|
||||
|
||||
// Add https:// prefix
|
||||
$validated['website_url'] = ! empty($url) ? 'https://'.$url : null;
|
||||
}
|
||||
|
||||
// Generate slug from name
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
|
||||
// Handle logo upload
|
||||
if ($request->hasFile('logo')) {
|
||||
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
|
||||
}
|
||||
|
||||
// Handle banner upload
|
||||
if ($request->hasFile('banner')) {
|
||||
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
|
||||
}
|
||||
|
||||
// Set boolean defaults
|
||||
$validated['is_public'] = $request->boolean('is_public');
|
||||
$validated['is_featured'] = $request->boolean('is_featured');
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
// Create brand
|
||||
$brand = $business->brands()->create($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand created successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified brand (read-only view)
|
||||
*/
|
||||
public function show(Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Load relationships
|
||||
$brand->load(['business', 'products']);
|
||||
|
||||
return view('seller.brands.show', compact('business', 'brand'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview the brand as it would appear to buyers
|
||||
*/
|
||||
public function preview(Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('view', [$brand, $business]);
|
||||
|
||||
// Load relationships including active products with images, strain, unit, and product line
|
||||
$brand->load([
|
||||
'business',
|
||||
'products' => function ($query) {
|
||||
$query->where('is_active', true)
|
||||
->with(['images', 'strain', 'unit', 'productLine'])
|
||||
->orderBy('name');
|
||||
},
|
||||
]);
|
||||
|
||||
// Get other brands from the same business
|
||||
$otherBrands = Brand::where('business_id', $brand->business_id)
|
||||
->where('id', '!=', $brand->id)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $brand->products->groupBy(function ($product) {
|
||||
return $product->productLine->name ?? 'Uncategorized';
|
||||
});
|
||||
|
||||
// Allow viewing as buyer with ?as=buyer query parameter (for testing)
|
||||
$isSeller = request()->query('as') !== 'buyer';
|
||||
|
||||
return view('seller.brands.preview', compact('business', 'brand', 'otherBrands', 'productsByLine', 'isSeller'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified brand
|
||||
*/
|
||||
public function edit(Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('update', [$brand, $business]);
|
||||
|
||||
return view('seller.brands.edit', compact('business', 'brand'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified brand in storage
|
||||
*/
|
||||
public function update(UpdateBrandRequest $request, Business $business, Brand $brand)
|
||||
{
|
||||
// Authorization is handled by UpdateBrandRequest
|
||||
$validated = $request->validated();
|
||||
|
||||
// Clean and normalize website URL - strip any protocol user entered, then add https://
|
||||
if ($request->filled('website_url')) {
|
||||
$url = $validated['website_url'];
|
||||
|
||||
// Strip http:// or https:// if user entered it
|
||||
$url = preg_replace('#^https?://#i', '', $url);
|
||||
|
||||
// Strip any leading/trailing whitespace
|
||||
$url = trim($url);
|
||||
|
||||
// Validate that we have a valid domain format
|
||||
if (! empty($url) && ! filter_var('https://'.$url, FILTER_VALIDATE_URL)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->withErrors(['website_url' => 'Please enter a valid website URL (e.g., example.com)']);
|
||||
}
|
||||
|
||||
// Add https:// prefix
|
||||
$validated['website_url'] = ! empty($url) ? 'https://'.$url : null;
|
||||
} else {
|
||||
$validated['website_url'] = null;
|
||||
}
|
||||
|
||||
// Update slug if name changed
|
||||
if ($validated['name'] !== $brand->name) {
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
}
|
||||
|
||||
// Handle logo removal
|
||||
if ($request->boolean('remove_logo') && $brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
$validated['logo_path'] = null;
|
||||
}
|
||||
|
||||
// Handle logo upload
|
||||
if ($request->hasFile('logo')) {
|
||||
// Delete old logo
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
}
|
||||
$validated['logo_path'] = $request->file('logo')->store('brands/logos', 'public');
|
||||
}
|
||||
|
||||
// Handle banner removal
|
||||
if ($request->boolean('remove_banner') && $brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
$validated['banner_path'] = null;
|
||||
}
|
||||
|
||||
// Handle banner upload
|
||||
if ($request->hasFile('banner')) {
|
||||
// Delete old banner
|
||||
if ($brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
}
|
||||
$validated['banner_path'] = $request->file('banner')->store('brands/banners', 'public');
|
||||
}
|
||||
|
||||
// Set boolean defaults
|
||||
$validated['is_public'] = $request->boolean('is_public');
|
||||
$validated['is_featured'] = $request->boolean('is_featured');
|
||||
$validated['is_active'] = $request->boolean('is_active');
|
||||
|
||||
// Remove form-only fields
|
||||
unset($validated['remove_logo'], $validated['remove_banner']);
|
||||
|
||||
// Update brand
|
||||
$brand->update($validated);
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand updated successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show brand performance statistics
|
||||
*/
|
||||
public function stats(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
Gate::authorize('view', [$brand, $business]);
|
||||
|
||||
// Determine date range from request
|
||||
$preset = $request->input('preset', 'last_30_days');
|
||||
$startDate = null;
|
||||
$endDate = null;
|
||||
|
||||
switch ($preset) {
|
||||
case 'this_week':
|
||||
$startDate = now()->startOfWeek();
|
||||
$endDate = now()->endOfWeek();
|
||||
break;
|
||||
case 'last_week':
|
||||
$startDate = now()->subWeek()->startOfWeek();
|
||||
$endDate = now()->subWeek()->endOfWeek();
|
||||
break;
|
||||
case 'next_week':
|
||||
$startDate = now()->addWeek()->startOfWeek();
|
||||
$endDate = now()->addWeek()->endOfWeek();
|
||||
break;
|
||||
case 'this_month':
|
||||
$startDate = now()->startOfMonth();
|
||||
$endDate = now()->endOfMonth();
|
||||
break;
|
||||
case 'last_month':
|
||||
$startDate = now()->subMonth()->startOfMonth();
|
||||
$endDate = now()->subMonth()->endOfMonth();
|
||||
break;
|
||||
case 'this_year':
|
||||
$startDate = now()->startOfYear();
|
||||
$endDate = now()->endOfYear();
|
||||
break;
|
||||
case 'custom':
|
||||
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->subDays(30);
|
||||
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
|
||||
break;
|
||||
default: // last_30_days
|
||||
$startDate = now()->subDays(30);
|
||||
$endDate = now();
|
||||
}
|
||||
|
||||
// Load brand with products
|
||||
$brand->load(['products' => function ($query) {
|
||||
$query->with(['orderItems.order']);
|
||||
}]);
|
||||
|
||||
// Calculate overall brand metrics
|
||||
$totalProducts = $brand->products->count();
|
||||
$activeProducts = $brand->products->where('is_active', true)->count();
|
||||
|
||||
// Get all order items for this brand's products in the selected date range
|
||||
$orderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})
|
||||
->whereHas('order', function ($query) use ($startDate, $endDate) {
|
||||
$query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
})
|
||||
->with('order', 'product')
|
||||
->get();
|
||||
|
||||
// Calculate metrics
|
||||
$totalOrders = $orderItems->pluck('order_id')->unique()->count();
|
||||
$totalRevenue = $orderItems->sum('line_total');
|
||||
$totalUnits = $orderItems->sum('quantity');
|
||||
|
||||
// Previous period comparison (same duration before start date)
|
||||
$daysDiff = $startDate->diffInDays($endDate);
|
||||
$previousStartDate = $startDate->copy()->subDays($daysDiff + 1);
|
||||
$previousEndDate = $startDate->copy()->subDay();
|
||||
|
||||
$previousOrderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})
|
||||
->whereHas('order', function ($query) use ($previousStartDate, $previousEndDate) {
|
||||
$query->whereBetween('created_at', [$previousStartDate, $previousEndDate]);
|
||||
})
|
||||
->get();
|
||||
|
||||
$previousRevenue = $previousOrderItems->sum('line_total');
|
||||
$previousOrders = $previousOrderItems->pluck('order_id')->unique()->count();
|
||||
|
||||
// Calculate percent changes
|
||||
$revenueChange = $previousRevenue > 0 ? (($totalRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
|
||||
$ordersChange = $previousOrders > 0 ? (($totalOrders - $previousOrders) / $previousOrders) * 100 : 0;
|
||||
|
||||
// Average order value
|
||||
$avgOrderValue = $totalOrders > 0 ? $totalRevenue / $totalOrders : 0;
|
||||
|
||||
// Revenue by day
|
||||
$revenueByDay = $orderItems->groupBy(function ($item) {
|
||||
return $item->order->created_at->format('Y-m-d');
|
||||
})->map(function ($items) {
|
||||
return $items->sum('line_total');
|
||||
})->sortKeys();
|
||||
|
||||
// Top products by revenue
|
||||
$productStats = $brand->products->map(function ($product) use ($startDate, $endDate) {
|
||||
$items = $product->orderItems()
|
||||
->whereHas('order', function ($query) use ($startDate, $endDate) {
|
||||
$query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
})
|
||||
->with('order')
|
||||
->get();
|
||||
|
||||
$revenue = $items->sum('line_total');
|
||||
$units = $items->sum('quantity');
|
||||
$orders = $items->pluck('order_id')->unique()->count();
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'revenue' => $revenue,
|
||||
'units' => $units,
|
||||
'orders' => $orders,
|
||||
];
|
||||
})->sortByDesc('revenue');
|
||||
|
||||
// Get best selling SKU
|
||||
$bestSellingSku = $productStats->first();
|
||||
|
||||
return view('seller.brands.stats', compact(
|
||||
'business',
|
||||
'brand',
|
||||
'totalProducts',
|
||||
'activeProducts',
|
||||
'totalOrders',
|
||||
'totalRevenue',
|
||||
'totalUnits',
|
||||
'avgOrderValue',
|
||||
'revenueChange',
|
||||
'ordersChange',
|
||||
'revenueByDay',
|
||||
'productStats',
|
||||
'bestSellingSku',
|
||||
'preset',
|
||||
'startDate',
|
||||
'endDate'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and download PDF report
|
||||
*/
|
||||
public function exportPdf(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
Gate::authorize('view', [$brand, $business]);
|
||||
|
||||
// Get the same data as stats view
|
||||
$statsData = $this->getStatsData($request, $business, $brand);
|
||||
|
||||
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('seller.brands.stats-pdf', $statsData);
|
||||
|
||||
return $pdf->download($brand->slug.'-stats-'.$statsData['startDate']->format('Y-m-d').'-to-'.$statsData['endDate']->format('Y-m-d').'.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Email PDF report to user
|
||||
*/
|
||||
public function emailPdf(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
Gate::authorize('view', [$brand, $business]);
|
||||
|
||||
// Validate email addresses (comma-separated)
|
||||
$validated = $request->validate([
|
||||
'emails' => 'required|string',
|
||||
]);
|
||||
|
||||
// Parse and validate each email address
|
||||
$emailList = array_map('trim', explode(',', $validated['emails']));
|
||||
$validEmails = [];
|
||||
|
||||
foreach ($emailList as $email) {
|
||||
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$validEmails[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($validEmails)) {
|
||||
return redirect()
|
||||
->back()
|
||||
->withInput()
|
||||
->withErrors(['emails' => 'Please provide at least one valid email address.']);
|
||||
}
|
||||
|
||||
// Get the same data as stats view
|
||||
$statsData = $this->getStatsData($request, $business, $brand);
|
||||
|
||||
$pdf = \Barryvdh\DomPDF\Facade\Pdf::loadView('seller.brands.stats-pdf', $statsData);
|
||||
|
||||
// Send email with PDF attachment to all recipients
|
||||
\Illuminate\Support\Facades\Mail::send('emails.stats-report', [
|
||||
'brand' => $brand,
|
||||
'business' => $business,
|
||||
'startDate' => $statsData['startDate'],
|
||||
'endDate' => $statsData['endDate'],
|
||||
], function ($message) use ($validEmails, $brand, $pdf, $statsData) {
|
||||
$message->to($validEmails)
|
||||
->subject('Brand Statistics Report: '.$brand->name)
|
||||
->attachData($pdf->output(), $brand->slug.'-stats-'.$statsData['startDate']->format('Y-m-d').'-to-'.$statsData['endDate']->format('Y-m-d').'.pdf');
|
||||
});
|
||||
|
||||
$recipientCount = count($validEmails);
|
||||
$recipientList = $recipientCount === 1 ? $validEmails[0] : $recipientCount.' recipients';
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.stats', [$business->slug, $brand->hashid, 'preset' => $statsData['preset']])
|
||||
->with('success', 'Report emailed to '.$recipientList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract stats data logic into reusable method
|
||||
*/
|
||||
private function getStatsData(Request $request, Business $business, Brand $brand): array
|
||||
{
|
||||
// Determine date range from request
|
||||
$preset = $request->input('preset', 'last_30_days');
|
||||
$startDate = null;
|
||||
$endDate = null;
|
||||
|
||||
switch ($preset) {
|
||||
case 'this_week':
|
||||
$startDate = now()->startOfWeek();
|
||||
$endDate = now()->endOfWeek();
|
||||
break;
|
||||
case 'last_week':
|
||||
$startDate = now()->subWeek()->startOfWeek();
|
||||
$endDate = now()->subWeek()->endOfWeek();
|
||||
break;
|
||||
case 'next_week':
|
||||
$startDate = now()->addWeek()->startOfWeek();
|
||||
$endDate = now()->addWeek()->endOfWeek();
|
||||
break;
|
||||
case 'this_month':
|
||||
$startDate = now()->startOfMonth();
|
||||
$endDate = now()->endOfMonth();
|
||||
break;
|
||||
case 'last_month':
|
||||
$startDate = now()->subMonth()->startOfMonth();
|
||||
$endDate = now()->subMonth()->endOfMonth();
|
||||
break;
|
||||
case 'this_year':
|
||||
$startDate = now()->startOfYear();
|
||||
$endDate = now()->endOfYear();
|
||||
break;
|
||||
case 'custom':
|
||||
$startDate = $request->input('start_date') ? \Carbon\Carbon::parse($request->input('start_date'))->startOfDay() : now()->subDays(30);
|
||||
$endDate = $request->input('end_date') ? \Carbon\Carbon::parse($request->input('end_date'))->endOfDay() : now();
|
||||
break;
|
||||
default: // last_30_days
|
||||
$startDate = now()->subDays(30);
|
||||
$endDate = now();
|
||||
}
|
||||
|
||||
// Load brand with products
|
||||
$brand->load(['products' => function ($query) {
|
||||
$query->with(['orderItems.order']);
|
||||
}]);
|
||||
|
||||
// Calculate overall brand metrics
|
||||
$totalProducts = $brand->products->count();
|
||||
$activeProducts = $brand->products->where('is_active', true)->count();
|
||||
|
||||
// Get all order items for this brand's products in the selected date range
|
||||
$orderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})
|
||||
->whereHas('order', function ($query) use ($startDate, $endDate) {
|
||||
$query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
})
|
||||
->with('order', 'product')
|
||||
->get();
|
||||
|
||||
// Calculate metrics
|
||||
$totalOrders = $orderItems->pluck('order_id')->unique()->count();
|
||||
$totalRevenue = $orderItems->sum('line_total');
|
||||
$totalUnits = $orderItems->sum('quantity');
|
||||
|
||||
// Previous period comparison (same duration before start date)
|
||||
$daysDiff = $startDate->diffInDays($endDate);
|
||||
$previousStartDate = $startDate->copy()->subDays($daysDiff + 1);
|
||||
$previousEndDate = $startDate->copy()->subDay();
|
||||
|
||||
$previousOrderItems = \App\Models\OrderItem::whereHas('product', function ($query) use ($brand) {
|
||||
$query->where('brand_id', $brand->id);
|
||||
})
|
||||
->whereHas('order', function ($query) use ($previousStartDate, $previousEndDate) {
|
||||
$query->whereBetween('created_at', [$previousStartDate, $previousEndDate]);
|
||||
})
|
||||
->get();
|
||||
|
||||
$previousRevenue = $previousOrderItems->sum('line_total');
|
||||
$previousOrders = $previousOrderItems->pluck('order_id')->unique()->count();
|
||||
|
||||
// Calculate percent changes
|
||||
$revenueChange = $previousRevenue > 0 ? (($totalRevenue - $previousRevenue) / $previousRevenue) * 100 : 0;
|
||||
$ordersChange = $previousOrders > 0 ? (($totalOrders - $previousOrders) / $previousOrders) * 100 : 0;
|
||||
|
||||
// Average order value
|
||||
$avgOrderValue = $totalOrders > 0 ? $totalRevenue / $totalOrders : 0;
|
||||
|
||||
// Revenue by day
|
||||
$revenueByDay = $orderItems->groupBy(function ($item) {
|
||||
return $item->order->created_at->format('Y-m-d');
|
||||
})->map(function ($items) {
|
||||
return $items->sum('line_total');
|
||||
})->sortKeys();
|
||||
|
||||
// Top products by revenue
|
||||
$productStats = $brand->products->map(function ($product) use ($startDate, $endDate) {
|
||||
$items = $product->orderItems()
|
||||
->whereHas('order', function ($query) use ($startDate, $endDate) {
|
||||
$query->whereBetween('created_at', [$startDate, $endDate]);
|
||||
})
|
||||
->with('order')
|
||||
->get();
|
||||
|
||||
$revenue = $items->sum('line_total');
|
||||
$units = $items->sum('quantity');
|
||||
$orders = $items->pluck('order_id')->unique()->count();
|
||||
|
||||
return [
|
||||
'product' => $product,
|
||||
'revenue' => $revenue,
|
||||
'units' => $units,
|
||||
'orders' => $orders,
|
||||
];
|
||||
})->sortByDesc('revenue');
|
||||
|
||||
// Get best selling SKU
|
||||
$bestSellingSku = $productStats->first();
|
||||
|
||||
return compact(
|
||||
'business',
|
||||
'brand',
|
||||
'totalProducts',
|
||||
'activeProducts',
|
||||
'totalOrders',
|
||||
'totalRevenue',
|
||||
'totalUnits',
|
||||
'avgOrderValue',
|
||||
'revenueChange',
|
||||
'ordersChange',
|
||||
'revenueByDay',
|
||||
'productStats',
|
||||
'bestSellingSku',
|
||||
'preset',
|
||||
'startDate',
|
||||
'endDate'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified brand from storage
|
||||
*/
|
||||
public function destroy(Business $business, Brand $brand)
|
||||
{
|
||||
$this->authorize('delete', [$brand, $business]);
|
||||
|
||||
// Check if brand has any products with sales/orders
|
||||
$hasProductsWithSales = $brand->products()
|
||||
->whereHas('orderItems')
|
||||
->exists();
|
||||
|
||||
if ($hasProductsWithSales) {
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('error', 'Cannot delete brand - it has products with sales activity.');
|
||||
}
|
||||
|
||||
// Delete logo and banner files
|
||||
if ($brand->logo_path) {
|
||||
Storage::disk('public')->delete($brand->logo_path);
|
||||
}
|
||||
if ($brand->banner_path) {
|
||||
Storage::disk('public')->delete($brand->banner_path);
|
||||
}
|
||||
|
||||
$brand->delete();
|
||||
|
||||
return redirect()
|
||||
->route('seller.business.brands.index', $business->slug)
|
||||
->with('success', 'Brand deleted successfully!');
|
||||
}
|
||||
}
|
||||
60
app/Http/Controllers/Seller/BrandPreviewController.php
Normal file
60
app/Http/Controllers/Seller/BrandPreviewController.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Brand;
|
||||
use App\Models\Business;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BrandPreviewController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show brand menu preview for sellers
|
||||
* This allows sellers to preview how buyers will see their brand menu
|
||||
*
|
||||
* @return \Illuminate\View\View
|
||||
*/
|
||||
public function preview(Request $request, Business $business, Brand $brand)
|
||||
{
|
||||
// Verify the brand belongs to the business (business isolation)
|
||||
if ($brand->business_id !== $business->id) {
|
||||
abort(404, 'Brand not found for this business');
|
||||
}
|
||||
|
||||
// Load brand with business relationship
|
||||
$brand->load('business');
|
||||
|
||||
// Get products organized by product line
|
||||
$products = $brand->products()
|
||||
->with(['strain', 'images', 'productLine'])
|
||||
->where('is_active', true)
|
||||
->orderBy('product_line_id')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Group products by product line
|
||||
$productsByLine = $products->groupBy(function ($product) {
|
||||
return $product->productLine ? $product->productLine->name : 'Other Products';
|
||||
});
|
||||
|
||||
// Get other brands from same business
|
||||
$otherBrands = $business
|
||||
->brands()
|
||||
->where('id', '!=', $brand->id)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
// Mark this as seller view
|
||||
$isSeller = true;
|
||||
|
||||
return view('seller.brands.preview', compact(
|
||||
'business',
|
||||
'brand',
|
||||
'products',
|
||||
'productsByLine',
|
||||
'otherBrands',
|
||||
'isSeller'
|
||||
));
|
||||
}
|
||||
}
|
||||
267
app/Http/Controllers/Seller/CategoryController.php
Normal file
267
app/Http/Controllers/Seller/CategoryController.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\ComponentCategory;
|
||||
use App\Models\ProductCategory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Business $business)
|
||||
{
|
||||
// Load product categories with nesting and counts (include parent if division)
|
||||
$productCategories = ProductCategory::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->whereNull('parent_id')
|
||||
->with(['children' => function ($query) {
|
||||
$query->orderBy('sort_order')->orderBy('name');
|
||||
}])
|
||||
->withCount('products')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
// Load component categories with nesting and counts (include parent if division)
|
||||
$componentCategories = ComponentCategory::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->whereNull('parent_id')
|
||||
->with(['children' => function ($query) {
|
||||
$query->orderBy('sort_order')->orderBy('name');
|
||||
}])
|
||||
->withCount('components')
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.settings.categories.index', compact('business', 'productCategories', 'componentCategories'));
|
||||
}
|
||||
|
||||
public function create(Business $business, string $type)
|
||||
{
|
||||
// Validate type
|
||||
if (! in_array($type, ['product', 'component'])) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Get all categories of this type for parent selection (include parent if division)
|
||||
$categories = $type === 'product'
|
||||
? ProductCategory::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->whereNull('parent_id')
|
||||
->with('children')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
: ComponentCategory::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->whereNull('parent_id')
|
||||
->with('children')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.settings.categories.create', compact('business', 'type', 'categories'));
|
||||
}
|
||||
|
||||
public function store(Request $request, Business $business, string $type)
|
||||
{
|
||||
// Validate type
|
||||
if (! in_array($type, ['product', 'component'])) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$tableName = $type === 'product' ? 'product_categories' : 'component_categories';
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'parent_id' => "nullable|exists:{$tableName},id",
|
||||
'description' => 'nullable|string',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
'image' => 'nullable|image|max:2048',
|
||||
]);
|
||||
|
||||
$validated['business_id'] = $business->id;
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
$validated['is_active'] = $request->has('is_active') ? true : false;
|
||||
|
||||
// Handle image upload
|
||||
if ($request->hasFile('image')) {
|
||||
$validated['image_path'] = $request->file('image')->store('categories', 'public');
|
||||
}
|
||||
|
||||
// Validate parent belongs to same business if provided
|
||||
if (! empty($validated['parent_id'])) {
|
||||
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
|
||||
$parent = $model::where('business_id', $business->id)->find($validated['parent_id']);
|
||||
|
||||
if (! $parent) {
|
||||
return back()->withErrors(['parent_id' => 'Invalid parent category'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
|
||||
$model::create($validated);
|
||||
|
||||
return redirect()->route('seller.business.settings.categories.index', $business->slug)
|
||||
->with('success', ucfirst($type).' category created successfully');
|
||||
}
|
||||
|
||||
public function edit(Business $business, string $type, int $id)
|
||||
{
|
||||
// Validate type
|
||||
if (! in_array($type, ['product', 'component'])) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
|
||||
// Allow accessing categories from parent company if division
|
||||
$category = $model::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})->findOrFail($id);
|
||||
|
||||
// Get all categories of this type for parent selection (excluding self and descendants, include parent if division)
|
||||
$categories = $model::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})
|
||||
->whereNull('parent_id')
|
||||
->where('id', '!=', $id)
|
||||
->with('children')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('seller.settings.categories.edit', compact('business', 'type', 'category', 'categories'));
|
||||
}
|
||||
|
||||
public function update(Request $request, Business $business, string $type, int $id)
|
||||
{
|
||||
// Validate type
|
||||
if (! in_array($type, ['product', 'component'])) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
|
||||
// Allow accessing categories from parent company if division
|
||||
$category = $model::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})->findOrFail($id);
|
||||
|
||||
$tableName = $type === 'product' ? 'product_categories' : 'component_categories';
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'parent_id' => "nullable|exists:{$tableName},id",
|
||||
'description' => 'nullable|string',
|
||||
'sort_order' => 'nullable|integer|min:0',
|
||||
'is_active' => 'boolean',
|
||||
'image' => 'nullable|image|max:2048',
|
||||
]);
|
||||
|
||||
$validated['slug'] = Str::slug($validated['name']);
|
||||
$validated['is_active'] = $request->has('is_active') ? true : false;
|
||||
|
||||
// Handle image upload
|
||||
if ($request->hasFile('image')) {
|
||||
// Delete old image if exists
|
||||
if ($category->image_path) {
|
||||
\Storage::disk('public')->delete($category->image_path);
|
||||
}
|
||||
$validated['image_path'] = $request->file('image')->store('categories', 'public');
|
||||
}
|
||||
|
||||
// Validate parent (can't be self or descendant)
|
||||
if (! empty($validated['parent_id'])) {
|
||||
if ($validated['parent_id'] == $id) {
|
||||
return back()->withErrors(['parent_id' => 'Category cannot be its own parent'])->withInput();
|
||||
}
|
||||
|
||||
$parent = $model::where('business_id', $business->id)->find($validated['parent_id']);
|
||||
if (! $parent) {
|
||||
return back()->withErrors(['parent_id' => 'Invalid parent category'])->withInput();
|
||||
}
|
||||
|
||||
// Check for circular reference (if parent's parent is this category)
|
||||
if ($parent->parent_id == $id) {
|
||||
return back()->withErrors(['parent_id' => 'This would create a circular reference'])->withInput();
|
||||
}
|
||||
}
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
return redirect()->route('seller.business.settings.categories.index', $business->slug)
|
||||
->with('success', ucfirst($type).' category updated successfully');
|
||||
}
|
||||
|
||||
public function destroy(Business $business, string $type, int $id)
|
||||
{
|
||||
// Validate type
|
||||
if (! in_array($type, ['product', 'component'])) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$model = $type === 'product' ? ProductCategory::class : ComponentCategory::class;
|
||||
// Allow accessing categories from parent company if division
|
||||
$category = $model::where(function ($query) use ($business) {
|
||||
$query->where('business_id', $business->id);
|
||||
if ($business->parent_id) {
|
||||
$query->orWhere('business_id', $business->parent_id);
|
||||
}
|
||||
})->findOrFail($id);
|
||||
|
||||
// Check if has products/components
|
||||
if ($type === 'product') {
|
||||
$count = $category->products()->count();
|
||||
if ($count > 0) {
|
||||
return back()->with('error', "Cannot delete category with {$count} products. Please reassign or delete products first.");
|
||||
}
|
||||
} else {
|
||||
$count = $category->components()->count();
|
||||
if ($count > 0) {
|
||||
return back()->with('error', "Cannot delete category with {$count} components. Please reassign or delete components first.");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if has children
|
||||
$childCount = $category->children()->count();
|
||||
if ($childCount > 0) {
|
||||
return back()->with('error', "Cannot delete category with {$childCount} subcategories. Please delete or move subcategories first.");
|
||||
}
|
||||
|
||||
// Delete image if exists
|
||||
if ($category->image_path) {
|
||||
\Storage::disk('public')->delete($category->image_path);
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
|
||||
return redirect()->route('seller.business.settings.categories.index', $business->slug)
|
||||
->with('success', ucfirst($type).' category deleted successfully');
|
||||
}
|
||||
}
|
||||
222
app/Http/Controllers/Seller/ConsolidatedAnalyticsController.php
Normal file
222
app/Http/Controllers/Seller/ConsolidatedAnalyticsController.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Business;
|
||||
use App\Models\Conversion;
|
||||
use App\Models\Department;
|
||||
use App\Models\WorkOrder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ConsolidatedAnalyticsController extends Controller
|
||||
{
|
||||
/**
|
||||
* Analytics overview
|
||||
*/
|
||||
public function index(Business $business)
|
||||
{
|
||||
if (! $business->isParentCompany()) {
|
||||
abort(403, 'Consolidated analytics only available for parent companies');
|
||||
}
|
||||
|
||||
return view('seller.analytics.index', compact('business'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Manufacturing analytics across all divisions
|
||||
*/
|
||||
public function manufacturing(Business $business, Request $request)
|
||||
{
|
||||
if (! $business->isParentCompany()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$divisionIds = $business->divisions->pluck('id');
|
||||
|
||||
// Date range filter
|
||||
$startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d'));
|
||||
$endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d'));
|
||||
|
||||
// Work Orders by Division
|
||||
$workOrdersByDivision = $business->divisions->map(function ($division) use ($startDate, $endDate) {
|
||||
return [
|
||||
'division' => $division->division_name,
|
||||
'total' => WorkOrder::where('business_id', $division->id)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->count(),
|
||||
'completed' => WorkOrder::where('business_id', $division->id)
|
||||
->where('status', 'completed')
|
||||
->whereBetween('completed_at', [$startDate, $endDate])
|
||||
->count(),
|
||||
'in_progress' => WorkOrder::where('business_id', $division->id)
|
||||
->where('status', 'in_progress')
|
||||
->count(),
|
||||
'overdue' => WorkOrder::where('business_id', $division->id)
|
||||
->overdue()
|
||||
->count(),
|
||||
];
|
||||
});
|
||||
|
||||
// Wash Reports by Division
|
||||
$washReportsByDivision = $business->divisions->map(function ($division) use ($startDate, $endDate) {
|
||||
$completed = Conversion::where('business_id', $division->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('completed_at', [$startDate, $endDate])
|
||||
->get();
|
||||
|
||||
return [
|
||||
'division' => $division->division_name,
|
||||
'total' => $completed->count(),
|
||||
'total_input_weight' => $completed->sum('input_weight'),
|
||||
'total_output_weight' => $completed->sum('output_weight'),
|
||||
'average_yield' => $completed->avg('yield_percentage'),
|
||||
];
|
||||
});
|
||||
|
||||
// Department Performance
|
||||
$departmentPerformance = Department::whereIn('business_id', $divisionIds)
|
||||
->with('business')
|
||||
->withCount(['workOrders as active_work_orders' => function ($q) {
|
||||
$q->active();
|
||||
}])
|
||||
->withCount(['workOrders as completed_work_orders' => function ($q) use ($startDate, $endDate) {
|
||||
$q->where('status', 'completed')
|
||||
->whereBetween('completed_at', [$startDate, $endDate]);
|
||||
}])
|
||||
->get()
|
||||
->map(function ($dept) {
|
||||
return [
|
||||
'division' => $dept->business->division_name ?? 'Unknown',
|
||||
'department' => $dept->name,
|
||||
'active_work_orders' => $dept->active_work_orders,
|
||||
'completed_work_orders' => $dept->completed_work_orders,
|
||||
];
|
||||
});
|
||||
|
||||
// Work Order Completion Trend (last 30 days)
|
||||
$completionTrend = WorkOrder::whereIn('business_id', $divisionIds)
|
||||
->where('status', 'completed')
|
||||
->whereBetween('completed_at', [now()->subDays(30), now()])
|
||||
->select(DB::raw('DATE(completed_at) as date'), DB::raw('COUNT(*) as count'))
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.manufacturing', compact(
|
||||
'business',
|
||||
'workOrdersByDivision',
|
||||
'washReportsByDivision',
|
||||
'departmentPerformance',
|
||||
'completionTrend',
|
||||
'startDate',
|
||||
'endDate'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Production analytics (detailed manufacturing metrics)
|
||||
*/
|
||||
public function production(Business $business, Request $request)
|
||||
{
|
||||
if (! $business->isParentCompany()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$divisionIds = $business->divisions->pluck('id');
|
||||
|
||||
// Date range
|
||||
$startDate = $request->input('start_date', now()->startOfMonth()->format('Y-m-d'));
|
||||
$endDate = $request->input('end_date', now()->endOfMonth()->format('Y-m-d'));
|
||||
|
||||
// Yield Analysis by Division
|
||||
$yieldAnalysis = $business->divisions->map(function ($division) use ($startDate, $endDate) {
|
||||
$washes = Conversion::where('business_id', $division->id)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('completed_at', [$startDate, $endDate])
|
||||
->get();
|
||||
|
||||
return [
|
||||
'division' => $division->division_name,
|
||||
'total_washes' => $washes->count(),
|
||||
'total_input_kg' => round($washes->sum('input_weight') / 1000, 2),
|
||||
'total_output_kg' => round($washes->sum('output_weight') / 1000, 2),
|
||||
'average_yield' => round($washes->avg('yield_percentage'), 2),
|
||||
'best_yield' => round($washes->max('yield_percentage'), 2),
|
||||
'worst_yield' => round($washes->min('yield_percentage'), 2),
|
||||
];
|
||||
});
|
||||
|
||||
// Top Strains (by output weight)
|
||||
$topStrains = Conversion::whereIn('business_id', $divisionIds)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('completed_at', [$startDate, $endDate])
|
||||
->whereNotNull('metadata->strain')
|
||||
->select(DB::raw("metadata->>'strain' as strain"), DB::raw('SUM(output_weight) as total_output'))
|
||||
->groupBy('strain')
|
||||
->orderByDesc('total_output')
|
||||
->limit(10)
|
||||
->get();
|
||||
|
||||
// Equipment Utilization (if tracked in metadata)
|
||||
$equipmentUtilization = Conversion::whereIn('business_id', $divisionIds)
|
||||
->where('conversion_type', 'hash_wash')
|
||||
->where('status', 'completed')
|
||||
->whereBetween('completed_at', [$startDate, $endDate])
|
||||
->whereNotNull('metadata->washer')
|
||||
->select(DB::raw("metadata->>'washer' as washer"), DB::raw('COUNT(*) as uses'))
|
||||
->groupBy('washer')
|
||||
->orderBy('washer')
|
||||
->get();
|
||||
|
||||
return view('seller.analytics.production', compact(
|
||||
'business',
|
||||
'yieldAnalysis',
|
||||
'topStrains',
|
||||
'equipmentUtilization',
|
||||
'startDate',
|
||||
'endDate'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Department efficiency report
|
||||
*/
|
||||
public function departments(Business $business, Request $request)
|
||||
{
|
||||
if (! $business->isParentCompany()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$divisionIds = $business->divisions->pluck('id');
|
||||
|
||||
$departments = Department::whereIn('business_id', $divisionIds)
|
||||
->with(['business', 'users'])
|
||||
->withCount('workOrders')
|
||||
->get()
|
||||
->map(function ($dept) {
|
||||
$activeWorkOrders = $dept->workOrders()->active()->count();
|
||||
$completedThisMonth = $dept->workOrders()
|
||||
->where('status', 'completed')
|
||||
->whereMonth('completed_at', now()->month)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'division' => $dept->business->division_name ?? 'Unknown',
|
||||
'department' => $dept->name,
|
||||
'code' => $dept->code,
|
||||
'users_count' => $dept->users->count(),
|
||||
'active_work_orders' => $activeWorkOrders,
|
||||
'completed_this_month' => $completedThisMonth,
|
||||
'total_work_orders' => $dept->work_orders_count,
|
||||
'is_active' => $dept->is_active,
|
||||
];
|
||||
});
|
||||
|
||||
return view('seller.analytics.departments', compact('business', 'departments'));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user