feat: Complete product and inventory system phases 1-5

Phase 1: Buyer URL Security
- Implemented hashid-based product URLs
- Route: /brands/{brandSlug}/products/{productHashid}
- Removed numeric ID exposure in buyer routes

Phase 2: Inventory Mode System
- Added inventory_mode enum (unlimited/simple/batched)
- Implemented mode-specific availability calculations
- Database migration for inventory_mode column

Phase 3: Inline Variety Management
- Created VarietyController for AJAX variety CRUD
- Varieties as child products with parent_product_id
- Real-time variety management in product edit

Phase 4: Batch Integration
- Product-scoped batch filtering via ?product={hashid}
- "Manage Batches" button in product edit
- Batch totals integration with inventory modes

Phase 5: Buyer View Polish
- Varieties displayed as selectable options
- Preview as Buyer functionality
- Inventory mode-aware stock display

Files Modified:
- Controllers: MarketplaceController, BatchController, ProductController, VarietyController (new)
- Models: Product (inventory mode logic)
- Views: Buyer marketplace templates, seller product/batch views
- Routes: buyer.php, seller.php
- Migrations: inventory_mode, has_inventory flags
This commit is contained in:
kelly
2025-11-20 22:02:59 -07:00
parent 162b742092
commit 36473e1c49
23 changed files with 1719 additions and 966 deletions

1
.gitignore vendored
View File

@@ -71,3 +71,4 @@ SESSION_ACTIVE
*.dev.md
NOTES.md
TODO.personal.md
SESSION_*

View File

@@ -5,8 +5,8 @@
**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\`
- **Main repo:** `/home/kelly/git/hub`
- **Worktrees folder:** `/home/kelly/git/hub-worktrees/`
---
@@ -21,13 +21,13 @@
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
cd /home/kelly/git/hub
git worktree add ../hub-worktrees/feature-descriptive-name -b feature/descriptive-name
```
2. **Switch to worktree:**
```bash
cd "C:\Users\Boss Man\Documents\GitHub\Work Trees/feature-descriptive-name"
cd /home/kelly/git/hub-worktrees/feature-descriptive-name
```
3. **Work on that ONE feature only:**
@@ -64,8 +64,6 @@
## Test Plan
- How to test
🤖 Generated with Claude Code
EOF
)"
```
@@ -76,12 +74,12 @@
1. **Return to main repo:**
```bash
cd "C:\Users\Boss Man\Documents\GitHub\hub"
cd /home/kelly/git/hub
```
2. **Remove worktree:**
```bash
git worktree remove "../Work Trees/feature-descriptive-name"
git worktree remove ../hub-worktrees/feature-descriptive-name
```
3. **Delete local branch:**

View File

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

View File

@@ -121,8 +121,9 @@ class MarketplaceController extends Controller
/**
* Show individual product (nested under brand)
* URL: /brands/{brandSlug}/products/{productHashid}
*/
public function showProduct($brandSlug, $productSlug)
public function showProduct($brandSlug, $productHashid)
{
// Find brand by slug
$brand = Brand::query()
@@ -130,12 +131,17 @@ class MarketplaceController extends Controller
->active()
->firstOrFail();
// Find product by slug within this brand
// Find product by hashid within this brand (security: hashid only, no numeric IDs)
$product = Product::query()
->with([
'brand',
'strain',
'labs',
'varieties' => function ($query) {
$query->where('is_active', true)
->orderBy('sort_order')
->orderBy('name');
},
'availableBatches' => function ($query) {
$query->with(['labs' => function ($q) {
$q->latest('test_date');
@@ -145,13 +151,7 @@ class MarketplaceController extends Controller
},
])
->where('brand_id', $brand->id)
->where(function ($query) use ($productSlug) {
$query->where('slug', $productSlug);
// Only try ID lookup if the value is numeric
if (is_numeric($productSlug)) {
$query->orWhere('id', $productSlug);
}
})
->where('hashid', $productHashid)
->active()
->firstOrFail();

View File

@@ -22,6 +22,22 @@ class BatchController extends Controller
->with(['product.brand', 'coaFiles'])
->orderBy('production_date', 'desc');
// Product filter (for product-scoped batch views)
$filteredProduct = null;
if ($request->filled('product')) {
// Resolve product by hashid
$product = Product::where('hashid', $request->product)
->whereHas('brand', function ($q) use ($business) {
$q->where('business_id', $business->id);
})
->first();
if ($product) {
$query->where('product_id', $product->id);
$filteredProduct = $product;
}
}
// Search filter
if ($request->filled('search')) {
$search = $request->search;
@@ -41,7 +57,7 @@ class BatchController extends Controller
$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'));
return view('seller.batches.index', compact('business', 'batches', 'activeBatches', 'inactiveBatches', 'filteredProduct'));
}
/**

View File

@@ -32,10 +32,13 @@ class DashboardController extends Controller
}
/**
* Display the inventory dashboard (premium tier)
* Display the inventory dashboard (base version for all users)
* Premium features (Items, Movements, Alerts management) are separate pages
*/
public function index(Request $request, Business $business)
{
$hasPremium = $business->has_inventory;
// Get inventory summary statistics
$totalItems = InventoryItem::where('business_id', $business->id)
->active()
@@ -191,6 +194,7 @@ class DashboardController extends Controller
return view('seller.inventory.dashboard', compact(
'business',
'hasPremium',
'totalItems',
'totalValue',
'lowStockCount',

View File

@@ -20,6 +20,7 @@ class AnalyticsDashboardController extends Controller
}
$business = currentBusiness();
$hasPremium = $business->has_analytics;
$period = $request->input('period', '30'); // days
$startDate = now()->subDays((int) $period);
@@ -89,6 +90,7 @@ class AnalyticsDashboardController extends Controller
return view('seller.marketing.analytics.dashboard', compact(
'business',
'hasPremium',
'period',
'metrics',
'trafficTrend',

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Http\Controllers\Seller\Product;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class VarietyController extends Controller
{
/**
* Get variety data for editing (AJAX)
*/
public function show(Request $request, Business $business, Product $product, Product $variety)
{
// Verify product belongs to business
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business.');
}
// Verify variety is actually a child of this product
if ($variety->parent_product_id !== $product->id) {
abort(403, 'This variety does not belong to this product.');
}
// Verify variety belongs to business
if (! $variety->belongsToBusiness($business)) {
abort(403, 'This variety does not belong to your business.');
}
return response()->json([
'success' => true,
'variety' => [
'id' => $variety->id,
'hashid' => $variety->hashid,
'name' => $variety->name,
'slug' => $variety->slug,
'sku' => $variety->sku,
'description' => $variety->description,
'wholesale_price' => $variety->wholesale_price,
'sale_price' => $variety->sale_price,
'msrp' => $variety->msrp,
'inventory_mode' => $variety->inventory_mode,
'quantity_on_hand' => $variety->quantity_on_hand,
'quantity_allocated' => $variety->quantity_allocated,
'min_order_qty' => $variety->min_order_qty,
'max_order_qty' => $variety->max_order_qty,
'is_active' => $variety->is_active,
],
]);
}
/**
* Store a new variety (AJAX)
*/
public function store(Request $request, Business $business, Product $product)
{
// Verify product belongs to business
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'sku' => ['nullable', 'string', 'max:100', Rule::unique('products', 'sku')],
'description' => 'nullable|string',
'wholesale_price' => 'nullable|numeric|min:0',
'sale_price' => 'nullable|numeric|min:0',
'msrp' => 'nullable|numeric|min:0',
'inventory_mode' => ['required', Rule::in(['unlimited', 'simple', 'batched'])],
'quantity_on_hand' => 'nullable|integer|min:0',
'quantity_allocated' => 'nullable|integer|min:0',
'min_order_qty' => 'nullable|integer|min:1',
'max_order_qty' => 'nullable|integer|min:1',
'is_active' => 'nullable|boolean',
]);
// Create variety as child product
$variety = new Product($validated);
$variety->parent_product_id = $product->id;
$variety->brand_id = $product->brand_id;
$variety->department_id = $product->department_id;
$variety->category_id = $product->category_id;
$variety->type = $product->type;
$variety->is_sellable = $product->is_sellable ?? true;
// Generate slug if not provided
if (empty($variety->slug)) {
$variety->slug = Str::slug($variety->name);
}
// Auto-generate SKU if not provided
if (empty($variety->sku)) {
$variety->sku = strtoupper(Str::random(8));
}
$variety->save();
return response()->json([
'success' => true,
'message' => 'Variety created successfully.',
'variety' => [
'id' => $variety->id,
'hashid' => $variety->hashid,
'name' => $variety->name,
'sku' => $variety->sku,
'wholesale_price' => $variety->wholesale_price,
'inventory_mode' => $variety->inventory_mode,
'available_quantity' => $variety->available_quantity,
'is_active' => $variety->is_active,
],
], 201);
}
/**
* Update an existing variety (AJAX)
*/
public function update(Request $request, Business $business, Product $product, Product $variety)
{
// Verify product belongs to business
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business.');
}
// Verify variety is actually a child of this product
if ($variety->parent_product_id !== $product->id) {
abort(403, 'This variety does not belong to this product.');
}
// Verify variety belongs to business
if (! $variety->belongsToBusiness($business)) {
abort(403, 'This variety does not belong to your business.');
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'sku' => ['nullable', 'string', 'max:100', Rule::unique('products', 'sku')->ignore($variety->id)],
'description' => 'nullable|string',
'wholesale_price' => 'nullable|numeric|min:0',
'sale_price' => 'nullable|numeric|min:0',
'msrp' => 'nullable|numeric|min:0',
'inventory_mode' => ['required', Rule::in(['unlimited', 'simple', 'batched'])],
'quantity_on_hand' => 'nullable|integer|min:0',
'quantity_allocated' => 'nullable|integer|min:0',
'min_order_qty' => 'nullable|integer|min:1',
'max_order_qty' => 'nullable|integer|min:1',
'is_active' => 'nullable|boolean',
]);
$variety->update($validated);
return response()->json([
'success' => true,
'message' => 'Variety updated successfully.',
'variety' => [
'id' => $variety->id,
'hashid' => $variety->hashid,
'name' => $variety->name,
'sku' => $variety->sku,
'wholesale_price' => $variety->wholesale_price,
'inventory_mode' => $variety->inventory_mode,
'available_quantity' => $variety->available_quantity,
'is_active' => $variety->is_active,
],
]);
}
/**
* Delete a variety (AJAX)
*/
public function destroy(Request $request, Business $business, Product $product, Product $variety)
{
// Verify product belongs to business
if (! $product->belongsToBusiness($business)) {
abort(403, 'This product does not belong to your business.');
}
// Verify variety is actually a child of this product
if ($variety->parent_product_id !== $product->id) {
abort(403, 'This variety does not belong to this product.');
}
// Verify variety belongs to business
if (! $variety->belongsToBusiness($business)) {
abort(403, 'This variety does not belong to your business.');
}
// Check if variety has orders
if ($variety->orderItems()->exists()) {
return response()->json([
'success' => false,
'message' => 'Cannot delete variety with existing orders. Consider deactivating instead.',
], 422);
}
$variety->delete();
return response()->json([
'success' => true,
'message' => 'Variety deleted successfully.',
]);
}
}

View File

@@ -68,18 +68,28 @@ class ProductController extends Controller
$sortDir = $request->get('sort_dir', 'asc');
$query->orderBy($sortBy, $sortDir);
$products = $query->paginate(20)->withQueryString();
// Get all products and format as arrays for the view
$products = $query->get()
->map(function ($product) use ($business) {
// TODO: Replace mock metrics with real data from analytics/order tracking
return [
'id' => $product->id,
'hashid' => $product->hashid,
'product' => $product->name,
'sku' => $product->sku ?? 'N/A',
'brand' => $product->brand->name ?? 'N/A',
'channel' => 'Marketplace', // TODO: Add channel field to products
'price' => $product->wholesale_price ?? 0,
'views' => rand(500, 3000), // TODO: Replace with real view tracking
'orders' => rand(10, 200), // TODO: Replace with real order count
'revenue' => rand(1000, 10000), // TODO: Replace with real revenue calculation
'status' => $product->is_active ? 'active' : 'inactive',
'visibility' => $product->is_featured ? 'featured' : ($product->is_public ?? true ? 'public' : 'private'),
'edit_url' => route('seller.business.products.edit', [$business->slug, $product->hashid]),
];
});
// Get all brands for filter dropdown
$brands = $business->brands()->orderBy('name')->get();
// Get product lines for this business with products count
$productLines = ProductLine::where('business_id', $business->id)
->withCount('products')
->orderBy('name')
->get();
return view('seller.products.index', compact('business', 'products', 'brands', 'productLines'));
return view('seller.products.index', compact('business', 'products'));
}
/**

View File

@@ -17,6 +17,11 @@ class Product extends Model implements Auditable
{
use BelongsToBusinessViaBrand, HasFactory, HasHashid, \OwenIt\Auditing\Auditable, SoftDeletes;
// Inventory Mode Constants
public const INVENTORY_UNLIMITED = 'unlimited';
public const INVENTORY_SIMPLE = 'simple';
public const INVENTORY_BATCHED = 'batched';
protected $fillable = [
// Foreign Keys
'brand_id',
@@ -56,6 +61,11 @@ class Product extends Model implements Auditable
'is_fpr',
'is_sellable',
// Inventory Management
'inventory_mode',
'quantity_on_hand',
'quantity_allocated',
// Pricing
'wholesale_price',
'sale_price',
@@ -167,6 +177,10 @@ class Product extends Model implements Auditable
'brand_display_order' => 'integer',
'sort_order' => 'integer',
// Inventory
'quantity_on_hand' => 'integer',
'quantity_allocated' => 'integer',
// Booleans
'is_assembly' => 'boolean',
'is_raw_material' => 'boolean',
@@ -428,6 +442,84 @@ class Product extends Model implements Auditable
});
}
// Inventory Mode Accessors
public function isUnlimited(): bool
{
return $this->inventory_mode === self::INVENTORY_UNLIMITED;
}
public function isSimpleStock(): bool
{
return $this->inventory_mode === self::INVENTORY_SIMPLE;
}
public function isBatchedInventory(): bool
{
return $this->inventory_mode === self::INVENTORY_BATCHED;
}
/**
* Get total available quantity based on inventory mode
*/
public function getAvailableQuantityAttribute(): ?int
{
if ($this->isUnlimited()) {
return null; // Unlimited stock
}
if ($this->isSimpleStock()) {
return max(0, ($this->quantity_on_hand ?? 0) - ($this->quantity_allocated ?? 0));
}
if ($this->isBatchedInventory()) {
return $this->getTotalFromBatches();
}
return 0;
}
/**
* Calculate total available quantity from all active batches
*/
public function getTotalFromBatches(): int
{
return $this->batches()
->where('is_active', true)
->where('is_released_for_sale', true)
->where('is_quarantined', false)
->sum('quantity_available') ?? 0;
}
/**
* Check if product is in stock based on inventory mode
*/
public function isInStock(): bool
{
if ($this->isUnlimited()) {
return true;
}
$available = $this->available_quantity;
return $available !== null && $available > 0;
}
/**
* Check if product is low stock (< min_order_qty)
*/
public function isLowStock(): bool
{
if ($this->isUnlimited()) {
return false;
}
$available = $this->available_quantity;
$minOrder = $this->min_order_qty ?? 1;
return $available !== null && $available > 0 && $available < $minOrder;
}
public function calculateMargin(): float
{
if (! $this->wholesale_price || ! $this->total_cost) {

View File

@@ -31,17 +31,25 @@ When the user says: **"I'm done for today, prepare for session end"** or uses `/
**You MUST:**
1. Check for uncommitted changes: `git status`
2. Ask if they want to commit changes
3. Update `SESSION_ACTIVE` with:
3. Create a SESSION_SUMMARY file with date stamp in Nextcloud:
- File: `/home/kelly/Nextcloud/Claude Sessions/hub-session/SESSION_SUMMARY_YYYY-MM-DD.md`
- Contents: What was completed today, decisions made, important context
4. Update `SESSION_ACTIVE` in Nextcloud with:
- What was completed today
- Current task status (if unfinished)
- What to do next session
- Important context or decisions made
- Known issues or blockers
4. If committing:
- Stage the SESSION_ACTIVE file
- Create descriptive commit message
- Commit the changes
5. Give a summary of what we accomplished today
5. If committing code:
- Stage ONLY the code changes (NOT SESSION files - they're in Nextcloud)
- Create descriptive commit message (NO Claude attribution)
- Commit and push changes
6. Give a summary of what we accomplished today
**IMPORTANT:**
- SESSION_ACTIVE is symlinked from repo to `/home/kelly/Nextcloud/Claude Sessions/hub-session/SESSION_ACTIVE`
- SESSION_SUMMARY files live ONLY in Nextcloud (NOT in the repo)
- NEVER commit SESSION_* files (they're in .gitignore)
## Personal Preferences
@@ -53,7 +61,7 @@ When the user says: **"I'm done for today, prepare for session end"** or uses `/
### Git Workflow
- Always working on feature branches or `develop`
- Never commit directly to `master`
- Session tracker (SESSION_ACTIVE) should be committed with work
- Session files live in Nextcloud (NOT committed to repo)
### Project Context
- Multi-tenant Laravel application

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('businesses', function (Blueprint $table) {
$table->boolean('has_inventory')->default(false)->after('has_processing');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('businesses', function (Blueprint $table) {
$table->dropColumn('has_inventory');
});
}
};

View File

@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// Inventory tracking mode (after has_varieties column)
$table->enum('inventory_mode', ['unlimited', 'simple', 'batched'])
->default('unlimited')
->after('has_varieties')
->comment('Inventory tracking mode: unlimited (no tracking), simple (qty tracking), batched (batch/lot tracking)');
// Simple inventory tracking fields
$table->integer('quantity_on_hand')
->nullable()
->after('inventory_mode')
->comment('Current quantity available (for simple mode only)');
$table->integer('quantity_allocated')
->nullable()
->after('quantity_on_hand')
->comment('Quantity reserved/allocated (for simple mode only)');
// Add index for inventory queries
$table->index(['inventory_mode', 'quantity_on_hand'], 'idx_products_inventory');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
$table->dropIndex('idx_products_inventory');
$table->dropColumn(['inventory_mode', 'quantity_on_hand', 'quantity_allocated']);
});
}
};

View File

@@ -47,7 +47,7 @@
<!-- Product Info -->
<div class="flex-1 min-w-0">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->slug ?? $product->id]) }}" class="font-semibold text-sm hover:text-primary line-clamp-2">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->hashid]) }}" class="font-semibold text-sm hover:text-primary line-clamp-2">
{{ $product->name }}
</a>
@@ -110,7 +110,7 @@
</div>
<div>
<div class="font-semibold">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->slug ?? $product->id]) }}" class="hover:text-primary">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->hashid]) }}" class="hover:text-primary">
{{ $product->name }}
</a>
</div>
@@ -160,7 +160,7 @@
<!-- Actions -->
<td class="text-right">
<div class="flex gap-2 justify-end items-center flex-nowrap">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->slug ?? $product->id]) }}" class="btn btn-ghost btn-sm">
<a href="{{ route('buyer.brands.products.show', [$brand->slug, $product->hashid]) }}" class="btn btn-ghost btn-sm">
<span class="icon-[heroicons--eye] size-4"></span>
</a>

View File

@@ -44,7 +44,7 @@
<span class="badge badge-lg bg-white/20 backdrop-blur-sm text-white border-white/30">THC {{ $featured->thc_percentage }}%</span>
@endif
</div>
<a href="{{ route('buyer.brands.products.show', [$featured->brand->slug, $featured->slug ?? $featured->id]) }}" class="btn btn-warning btn-lg">
<a href="{{ route('buyer.brands.products.show', [$featured->brand->slug, $featured->hashid]) }}" class="btn btn-warning btn-lg">
<span class="icon-[heroicons--shopping-cart] size-5"></span>
Shop Now
</a>
@@ -267,7 +267,7 @@
@endif
<!-- Stock Badge -->
@if($product->quantity_on_hand > 0)
@if($product->isInStock())
<div class="badge badge-success absolute top-2 right-2">In Stock</div>
@else
<div class="badge badge-error absolute top-2 right-2">Out of Stock</div>
@@ -286,7 +286,7 @@
<!-- Product Name -->
<h3 class="card-title text-lg">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->slug ?? $product->id]) }}" class="hover:text-primary">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" class="hover:text-primary">
{{ $product->name }}
</a>
</h3>
@@ -330,7 +330,7 @@
<!-- Actions -->
<div class="card-actions justify-between items-center mt-4"
x-data="{ productId: {{ $product->id }}, availableQty: {{ $product->available_quantity }} }">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->slug ?? $product->id]) }}" class="btn btn-ghost btn-sm">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" class="btn btn-ghost btn-sm">
<span class="icon-[heroicons--eye] size-4"></span>
Details
</a>

View File

@@ -154,12 +154,70 @@
@endif
</div>
@if($product->isInStock() || $product->availableBatches->count() > 0)
{{-- Varieties Selection --}}
@if($product->has_varieties && $product->varieties->count() > 0)
<div class="mb-6" x-data="varietySelection()">
<h3 class="font-semibold text-lg mb-3">Select Size / Variety</h3>
<div class="space-y-2">
@foreach($product->varieties as $variety)
<label class="cursor-pointer">
<input type="radio"
name="variety_selection"
value="{{ $variety->id }}"
class="peer sr-only"
x-model="selectedVariety"
@change="selectVariety({{ json_encode([
'id' => $variety->id,
'name' => $variety->name,
'sku' => $variety->sku,
'wholesale_price' => $variety->wholesale_price,
'price_unit' => $variety->price_unit,
'is_in_stock' => $variety->isInStock(),
'available_quantity' => $variety->available_quantity,
'inventory_mode' => $variety->inventory_mode,
'min_order_qty' => $variety->min_order_qty,
'max_order_qty' => $variety->max_order_qty,
]) }})">
<div class="card bg-base-100 border-2 border-base-300 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-semibold text-lg">{{ $variety->name }}</div>
<div class="text-sm text-base-content/70">SKU: {{ $variety->sku }}</div>
</div>
<div class="text-right">
<div class="text-2xl font-bold text-primary">
${{ number_format($variety->wholesale_price, 2) }}
@if($variety->price_unit)
<span class="text-sm text-base-content/60 font-normal">/ {{ $variety->price_unit }}</span>
@endif
</div>
@if($variety->isInStock())
<div class="badge badge-success badge-sm mt-1">
In Stock
@if($variety->available_quantity !== null)
({{ $variety->available_quantity }})
@endif
</div>
@else
<div class="badge badge-error badge-sm mt-1">Out of Stock</div>
@endif
</div>
</div>
</div>
</div>
</label>
@endforeach
</div>
</div>
@endif
@if($product->isInStock() || $product->availableBatches->count() > 0 || ($product->has_varieties && $product->varieties->where('is_active', true)->count() > 0))
@if(auth()->user()->business_onboarding_completed)
{{-- User completed onboarding: Show add to cart form --}}
<form action="{{ route('buyer.business.cart.add', auth()->user()->businesses->first()) }}" method="POST" x-data="batchSelection()">
<form action="{{ route('buyer.business.cart.add', auth()->user()->businesses->first()) }}" method="POST" x-data="varietyCartForm()">
@csrf
<input type="hidden" name="product_id" value="{{ $product->id }}">
<input type="hidden" name="product_id" x-bind:value="productId" value="{{ $product->id }}">
@if($product->availableBatches->count() > 0)
{{-- Batch selection enabled --}}
@@ -389,7 +447,7 @@
<div class="card-body">
<h4 class="card-title text-base">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->slug ?? $relatedProduct->id]) }}"
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->hashid]) }}"
class="hover:text-primary"
data-track-click="related-product"
data-track-id="{{ $relatedProduct->id }}"
@@ -403,7 +461,7 @@
</div>
<div class="card-actions justify-end mt-2">
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->slug ?? $relatedProduct->id]) }}"
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $relatedProduct->hashid]) }}"
class="btn btn-primary btn-sm"
data-track-click="related-product-cta"
data-track-id="{{ $relatedProduct->id }}"
@@ -458,6 +516,42 @@ function batchSelection() {
};
}
// Alpine.js component for variety selection
function varietySelection() {
return {
selectedVariety: null,
selectVariety(variety) {
// Dispatch event to parent form
window.dispatchEvent(new CustomEvent('variety-selected', { detail: variety }));
}
};
}
// Alpine.js component for variety cart form
function varietyCartForm() {
return {
productId: {{ $product->id }},
selectedVariety: null,
maxQuantity: {{ $product->available_quantity ?? 999 }},
init() {
// Listen for variety selection
window.addEventListener('variety-selected', (e) => {
this.selectedVariety = e.detail;
// Update product_id to the selected variety's ID
this.productId = e.detail.id;
// Update max quantity based on variety's inventory
if (e.detail.available_quantity !== null) {
this.maxQuantity = e.detail.available_quantity;
} else {
this.maxQuantity = 999; // Unlimited
}
});
}
};
}
document.addEventListener('DOMContentLoaded', function() {
// Handle all Add to Cart form submissions
document.querySelectorAll('form[action*="cart/add"]').forEach(form => {

View File

@@ -124,8 +124,8 @@
</div>
</div>
{{-- Buyer Analytics Module (Premium Feature - only show when enabled) --}}
@if($sidebarBusiness && $sidebarBusiness->has_analytics)
{{-- Buyer Analytics Module (Premium Feature - Show with badges if not enabled) --}}
@if($sidebarBusiness)
<p class="menu-label px-2.5 pt-3 pb-1.5">Intelligence</p>
<div class="group collapse">
<input
@@ -141,21 +141,44 @@
</div>
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.index') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.index', $sidebarBusiness->slug) }}">
<span class="grow">Overview</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.products*') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.products', $sidebarBusiness->slug) }}">
<span class="grow">Product Engagement</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.buyers*') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.buyers', $sidebarBusiness->slug) }}">
<span class="grow">Buyer Scores</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.marketing*') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.marketing', $sidebarBusiness->slug) }}">
<span class="grow">Email Campaigns</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.sales*') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.sales', $sidebarBusiness->slug) }}">
<span class="grow">Sales Funnel</span>
</a>
@if($sidebarBusiness->has_analytics)
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.index') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.index', $sidebarBusiness->slug) }}">
<span class="grow">Overview</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.products*') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.products', $sidebarBusiness->slug) }}">
<span class="grow">Product Engagement</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.buyers*') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.buyers', $sidebarBusiness->slug) }}">
<span class="grow">Buyer Scores</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.marketing*') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.marketing', $sidebarBusiness->slug) }}">
<span class="grow">Email Campaigns</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.buyer-intelligence.sales*') ? 'active' : '' }}" href="{{ route('seller.business.buyer-intelligence.sales', $sidebarBusiness->slug) }}">
<span class="grow">Sales Funnel</span>
</a>
@else
<div class="menu-item opacity-50 cursor-not-allowed" title="Upgrade to Premium to access this feature">
<span class="grow">Overview</span>
<span class="badge badge-sm bg-base-300 text-base-content/60 border-0">Premium</span>
</div>
<div class="menu-item opacity-50 cursor-not-allowed" title="Upgrade to Premium to access this feature">
<span class="grow">Product Engagement</span>
<span class="badge badge-sm bg-base-300 text-base-content/60 border-0">Premium</span>
</div>
<div class="menu-item opacity-50 cursor-not-allowed" title="Upgrade to Premium to access this feature">
<span class="grow">Buyer Scores</span>
<span class="badge badge-sm bg-base-300 text-base-content/60 border-0">Premium</span>
</div>
<div class="menu-item opacity-50 cursor-not-allowed" title="Upgrade to Premium to access this feature">
<span class="grow">Email Campaigns</span>
<span class="badge badge-sm bg-base-300 text-base-content/60 border-0">Premium</span>
</div>
<div class="menu-item opacity-50 cursor-not-allowed" title="Upgrade to Premium to access this feature">
<span class="grow">Sales Funnel</span>
<span class="badge badge-sm bg-base-300 text-base-content/60 border-0">Premium</span>
</div>
@endif
</div>
</div>
</div>
@@ -284,9 +307,6 @@
<a class="menu-item {{ request()->routeIs('seller.business.products.index') ? 'active' : '' }}" href="{{ route('seller.business.products.index', $sidebarBusiness->slug) }}">
<span class="grow">All Products</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.products.listings') ? 'active' : '' }}" href="{{ route('seller.business.products.listings', $sidebarBusiness->slug) }}">
<span class="grow">Listings</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.categories.*') ? 'active' : '' }}" href="{{ route('seller.business.categories.index', $sidebarBusiness->slug) }}">
<span class="grow">Categories / Types</span>
</a>
@@ -315,17 +335,43 @@
<div class="collapse-title px-2.5 py-1.5">
<span class="icon-[heroicons--cube] size-4"></span>
<span class="grow">Inventory</span>
@if($sidebarBusiness && $sidebarBusiness->has_inventory)
<span class="badge badge-xs badge-primary">Premium</span>
@endif
<span class="icon-[heroicons--chevron-right] arrow-icon size-3.5"></span>
</div>
@if($sidebarBusiness)
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
{{-- Base Tier - Always Available --}}
<a class="menu-item {{ request()->routeIs('seller.business.inventory.overview') ? 'active' : '' }}" href="{{ route('seller.business.inventory.overview', $sidebarBusiness->slug) }}">
<span class="grow">All Inventory</span>
<span class="grow">Dashboard</span>
</a>
{{-- Premium Features - Show with badge if not enabled --}}
@if($sidebarBusiness->has_inventory)
<a class="menu-item {{ request()->routeIs('seller.business.inventory.items.*') ? 'active' : '' }}" href="{{ route('seller.business.inventory.items.index', $sidebarBusiness->slug) }}">
<span class="grow">Stock Levels</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.inventory.movements.*') ? 'active' : '' }}" href="{{ route('seller.business.inventory.movements.index', $sidebarBusiness->slug) }}">
<span class="grow">Movements</span>
</a>
<a class="menu-item {{ request()->routeIs('seller.business.inventory.alerts.*') ? 'active' : '' }}" href="{{ route('seller.business.inventory.alerts.index', $sidebarBusiness->slug) }}">
<span class="grow">Alerts</span>
</a>
@else
<div class="menu-item opacity-50 cursor-not-allowed" title="Upgrade to Premium to access this feature">
<span class="grow">Stock Levels</span>
<span class="badge badge-sm bg-base-300 text-base-content/60 border-0">Premium</span>
</div>
<div class="menu-item opacity-50 cursor-not-allowed" title="Upgrade to Premium to access this feature">
<span class="grow">Movements</span>
<span class="badge badge-sm bg-base-300 text-base-content/60 border-0">Premium</span>
</div>
<div class="menu-item opacity-50 cursor-not-allowed" title="Upgrade to Premium to access this feature">
<span class="grow">Alerts</span>
<span class="badge badge-sm bg-base-300 text-base-content/60 border-0">Premium</span>
</div>
@endif
{{-- Base Tier - Batch & Bulk Actions --}}
<a class="menu-item {{ request()->routeIs('seller.business.batches.*') ? 'active' : '' }}" href="{{ route('seller.business.batches.index', $sidebarBusiness->slug) }}">
<span class="grow">Batches & Lots</span>
</a>
@@ -566,63 +612,6 @@
</div>
@endif
{{-- Orphaned Items Section - Premium inventory features that need reassignment --}}
@if($sidebarBusiness && $sidebarBusiness->has_inventory)
@php
$orphanedItems = [
[
'name' => 'Inventory Dashboard',
'route' => route('seller.business.inventory.dashboard', $sidebarBusiness->slug),
'active' => request()->routeIs('seller.business.inventory.dashboard'),
'category' => 'Inventory'
],
[
'name' => 'Stock Levels',
'route' => route('seller.business.inventory.items.index', $sidebarBusiness->slug),
'active' => request()->routeIs('seller.business.inventory.items.*'),
'category' => 'Inventory'
],
[
'name' => 'Inventory Movements',
'route' => route('seller.business.inventory.movements.index', $sidebarBusiness->slug),
'active' => request()->routeIs('seller.business.inventory.movements.*'),
'category' => 'Inventory'
],
[
'name' => 'Inventory Alerts',
'route' => route('seller.business.inventory.alerts.index', $sidebarBusiness->slug),
'active' => request()->routeIs('seller.business.inventory.alerts.*'),
'category' => 'Inventory'
]
];
@endphp
<p class="menu-label px-2.5 pt-3 pb-1.5">Orphaned Items</p>
<div class="group collapse" x-data="{ menuOrphaned: $persist(false).as('sidebar-menu-orphaned') }">
<input
aria-label="Sidemenu item trigger"
type="checkbox"
class="peer"
name="sidebar-menu-parent-item"
x-model="menuOrphaned" />
<div class="collapse-title px-2.5 py-1.5 opacity-60">
<span class="icon-[heroicons--archive-box] size-4"></span>
<span class="grow">Legacy Items</span>
<span class="badge badge-xs badge-warning">{{ count($orphanedItems) }}</span>
<span class="icon-[heroicons--chevron-right] arrow-icon size-3.5"></span>
</div>
<div class="collapse-content ms-6.5 !p-0">
<div class="mt-0.5 space-y-0.5">
@foreach($orphanedItems as $item)
<a class="menu-item {{ $item['active'] ? 'active' : '' }}" href="{{ $item['route'] }}">
<span class="grow">{{ $item['name'] }}</span>
<span class="text-xs text-base-content/50">{{ $item['category'] }}</span>
</a>
@endforeach
</div>
</div>
</div>
@endif
</div>
</div>

View File

@@ -86,11 +86,41 @@
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.dashboard', $business->slug) }}">Dashboard</a></li>
<li class="opacity-80">Batches</li>
@if($filteredProduct)
<li><a href="{{ route('seller.business.batches.index', $business->slug) }}">Batches</a></li>
<li class="opacity-80">{{ $filteredProduct->name }}</li>
@else
<li class="opacity-80">Batches</li>
@endif
</ul>
</div>
</div>
{{-- Product Filter Context --}}
@if($filteredProduct)
<div class="alert alert-info mb-6">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<div class="flex-1">
<h3 class="font-semibold">Filtered by Product: {{ $filteredProduct->name }}</h3>
<div class="text-sm mt-1">
<span class="opacity-70">SKU: {{ $filteredProduct->sku }}</span>
@if($filteredProduct->brand)
<span class="opacity-70 ml-3">Brand: {{ $filteredProduct->brand->name }}</span>
@endif
<span class="opacity-70 ml-3">Total Available from Batches: {{ $filteredProduct->getTotalFromBatches() }} units</span>
</div>
</div>
<div>
<a href="{{ route('seller.business.batches.index', $business->slug) }}" class="btn btn-sm btn-ghost">
Clear Filter
</a>
<a href="{{ route('seller.business.products.edit', [$business->slug, $filteredProduct->hashid]) }}" class="btn btn-sm btn-ghost">
View Product
</a>
</div>
</div>
@endif
<div class="flex items-center justify-between mb-6">
<div></div>
<a href="{{ route('seller.business.batches.create', $business->slug) }}" class="btn btn-primary btn-sm gap-2">

View File

@@ -44,12 +44,12 @@
{{-- Right: Action Buttons --}}
<div class="flex flex-col gap-2 flex-shrink-0">
{{-- View on Marketplace Button --}}
<a href="#" target="_blank" class="btn btn-outline btn-sm">
{{-- Preview as Buyer Button --}}
<a href="{{ route('buyer.brands.products.show', [$product->brand->slug, $product->hashid]) }}" target="_blank" class="btn btn-outline btn-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
View on Marketplace
Preview as Buyer
</a>
{{-- Manage BOM Button --}}
@@ -606,150 +606,192 @@
</label>
{{-- Show varieties section only when checkbox is checked --}}
<div x-show="hasVarieties" x-transition>
{{-- Existing Varieties --}}
<div x-show="hasVarieties" x-transition x-data="varietyManager()">
@if($product->has_varieties && $product->varieties->count() > 0)
<div class="space-y-4 mb-4">
@foreach($product->varieties as $variety)
<div class="card bg-base-200 shadow">
<div class="card-body p-4">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<div class="avatar">
<div class="w-16 rounded">
@if($variety->image_path)
<img src="{{ Storage::url($variety->image_path) }}" alt="{{ $variety->name }}">
@else
<div class="bg-base-300 w-full h-full flex items-center justify-center text-xs">No image</div>
@endif
{{-- Varieties Table --}}
<div class="overflow-x-auto mb-4">
<table class="table table-sm">
<thead>
<tr>
<th>Name</th>
<th>SKU</th>
<th>Wholesale</th>
<th>MSRP</th>
<th>Inventory</th>
<th>Stock</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($product->varieties as $variety)
<tr>
<td class="font-medium">{{ $variety->name }}</td>
<td class="text-sm text-base-content/70">{{ $variety->sku }}</td>
<td>${{ number_format($variety->wholesale_price ?? 0, 2) }}</td>
<td>${{ number_format($variety->msrp ?? 0, 2) }}</td>
<td>
@if($variety->isUnlimited())
<span class="badge badge-ghost badge-sm">Unlimited</span>
@elseif($variety->isSimpleStock())
<span class="badge badge-info badge-sm">Simple ({{ $variety->available_quantity }})</span>
@elseif($variety->isBatchedInventory())
<span class="badge badge-primary badge-sm">Batched ({{ $variety->available_quantity }})</span>
@endif
</td>
<td>
@if($variety->isInStock())
<span class="badge badge-success badge-sm">In Stock</span>
@else
<span class="badge badge-error badge-sm">Out of Stock</span>
@endif
</td>
<td>
@if($variety->is_active)
<span class="badge badge-success badge-sm">Active</span>
@else
<span class="badge badge-ghost badge-sm">Inactive</span>
@endif
</td>
<td>
<div class="flex gap-2">
<button type="button" @click="editVariety({{ $variety->id }})" class="btn btn-ghost btn-xs">Edit</button>
<button type="button" @click="deleteVariety({{ $variety->id }})" class="btn btn-ghost btn-xs text-error">Delete</button>
</div>
</div>
<div class="flex-1">
<p class="font-semibold">{{ $variety->name }}</p>
<p class="text-sm text-base-content/70">{{ $variety->sku }}</p>
</div>
<div class="text-left sm:text-right">
<p class="text-sm">Retail: ${{ number_format($variety->msrp ?? 0, 2) }}</p>
<p class="text-sm">Wholesale: ${{ number_format($variety->wholesale_price ?? 0, 2) }}</p>
<p class="text-sm">Stock: {{ $variety->quantity_on_hand }}</p>
</div>
<a href="{{ route('seller.business.products.edit11', [$business->slug, $variety->id]) }}" class="btn btn-sm btn-outline">Edit</a>
</div>
</div>
</div>
@endforeach
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="alert alert-info mb-4">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<span>No varieties yet. Click "Add Variety" to create one.</span>
</div>
@endif
{{-- New Varieties (Dynamic) --}}
<div class="space-y-4">
<template x-for="(variety, index) in varieties" :key="index">
<div class="card bg-base-100 border-2 border-base-300 shadow-sm">
<div class="card-body p-4">
{{-- Header with close button --}}
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold">New Variety</h3>
<button type="button" @click="varieties.splice(index, 1)" class="btn btn-ghost btn-xs btn-circle">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{{-- Variety Type --}}
<div class="form-control mb-4">
<label class="label"><span class="label-text font-semibold">Variety Type</span></label>
<div class="flex gap-4">
<label class="label cursor-pointer gap-2">
<input type="radio" :name="'varieties[' + index + '][type]'" value="new" class="radio radio-sm" x-model="variety.type">
<span class="label-text">New</span>
</label>
<label class="label cursor-pointer gap-2">
<input type="radio" :name="'varieties[' + index + '][type]'" value="existing" class="radio radio-sm" x-model="variety.type">
<span class="label-text">Existing</span>
</label>
</div>
</div>
{{-- Image Upload --}}
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Image</span>
<div class="tooltip tooltip-left" data-tip="Upload an image for this variety">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</label>
<input type="file" :name="'varieties[' + index + '][image]'" class="file-input file-input-bordered file-input-sm w-full">
</div>
{{-- Name and SKU --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label"><span class="label-text">Name</span></label>
<input type="text" :name="'varieties[' + index + '][name]'" x-model="variety.name" class="input input-bordered input-sm" placeholder="e.g., 1/8oz">
</div>
<div class="form-control">
<label class="label"><span class="label-text">SKU</span></label>
<input type="text" :name="'varieties[' + index + '][sku]'" x-model="variety.sku" class="input input-bordered input-sm" placeholder="SKU">
</div>
</div>
{{-- Retail, Wholesale, Inventory --}}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Retail (MSRP)</span></label>
<div class="join">
<span class="join-item btn btn-sm btn-disabled">$</span>
<input type="number" :name="'varieties[' + index + '][msrp]'" x-model="variety.msrp" step="0.01" min="0" class="input input-bordered input-sm join-item w-full" placeholder="0">
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Wholesale</span></label>
<div class="join">
<span class="join-item btn btn-sm btn-disabled">$</span>
<input type="number" :name="'varieties[' + index + '][wholesale_price]'" x-model="variety.wholesale_price" step="0.01" min="0" class="input input-bordered input-sm join-item w-full" placeholder="0">
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Inventory</span></label>
<input type="number" :name="'varieties[' + index + '][inventory]'" x-model="variety.inventory" step="1" min="0" class="input input-bordered input-sm" placeholder="0">
</div>
</div>
{{-- Status --}}
<div class="form-control mt-4">
<label class="label"><span class="label-text">Status</span></label>
<select :name="'varieties[' + index + '][status]'" x-model="variety.status" class="select select-bordered select-sm">
<option value="">Select status</option>
@foreach($statusOptions as $value => $label)
<option value="{{ $value }}">{{ $label }}</option>
@endforeach
</select>
</div>
</div>
</div>
</template>
</div>
{{-- Add Variety Button --}}
<button type="button"
@click="if (varieties.length < 6) varieties.push({ type: 'new', name: '', sku: '', msrp: '', wholesale_price: '', inventory: '', status: '' })"
:disabled="varieties.length >= 6"
class="btn btn-sm btn-outline mt-4 w-full sm:w-auto"
:class="{ 'btn-disabled': varieties.length >= 6 }">
<button type="button" @click="openModal()" class="btn btn-sm btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
<span x-text="varieties.length >= 6 ? 'Maximum varieties reached (6)' : '+ Add Variety'"></span>
Add Variety
</button>
{{-- Variety Modal --}}
<dialog x-ref="varietyModal" class="modal">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4" x-text="modalTitle"></h3>
<form @submit.prevent="saveVariety">
<div class="space-y-4">
{{-- Name and SKU --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Name *</span></label>
<input type="text" x-model="form.name" class="input input-bordered input-sm" required>
<template x-if="errors.name">
<span class="text-error text-xs mt-1" x-text="errors.name[0]"></span>
</template>
</div>
<div class="form-control">
<label class="label"><span class="label-text">SKU</span></label>
<input type="text" x-model="form.sku" class="input input-bordered input-sm">
<template x-if="errors.sku">
<span class="text-error text-xs mt-1" x-text="errors.sku[0]"></span>
</template>
</div>
</div>
{{-- Description --}}
<div class="form-control">
<label class="label"><span class="label-text">Description</span></label>
<textarea x-model="form.description" class="textarea textarea-bordered" rows="2"></textarea>
</div>
{{-- Pricing --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Wholesale Price</span></label>
<input type="number" x-model="form.wholesale_price" step="0.01" min="0" class="input input-bordered input-sm">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Sale Price</span></label>
<input type="number" x-model="form.sale_price" step="0.01" min="0" class="input input-bordered input-sm">
</div>
<div class="form-control">
<label class="label"><span class="label-text">MSRP</span></label>
<input type="number" x-model="form.msrp" step="0.01" min="0" class="input input-bordered input-sm">
</div>
</div>
{{-- Inventory Mode --}}
<div class="form-control">
<label class="label"><span class="label-text">Inventory Mode *</span></label>
<select x-model="form.inventory_mode" class="select select-bordered select-sm" required>
<option value="unlimited">Unlimited (No Tracking)</option>
<option value="simple">Simple Stock (Quantity Tracking)</option>
<option value="batched">Batched Inventory (Batch/Lot Tracking)</option>
</select>
</div>
{{-- Simple Stock Fields --}}
<div x-show="form.inventory_mode === 'simple'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Quantity on Hand</span></label>
<input type="number" x-model="form.quantity_on_hand" step="1" min="0" class="input input-bordered input-sm">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Quantity Allocated</span></label>
<input type="number" x-model="form.quantity_allocated" step="1" min="0" class="input input-bordered input-sm">
</div>
</div>
{{-- Order Quantities --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text">Min Order Quantity</span></label>
<input type="number" x-model="form.min_order_qty" step="1" min="1" class="input input-bordered input-sm">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Max Order Quantity</span></label>
<input type="number" x-model="form.max_order_qty" step="1" min="1" class="input input-bordered input-sm">
</div>
</div>
{{-- Active Status --}}
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input type="checkbox" x-model="form.is_active" class="checkbox checkbox-sm">
<span class="label-text">Active</span>
</label>
</div>
</div>
<div class="modal-action">
<button type="button" @click="closeModal()" class="btn btn-ghost btn-sm">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm" :disabled="saving">
<span x-show="!saving">Save</span>
<span x-show="saving">Saving...</span>
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</div>
</div>
</div>
<input type="radio" name="product_tabs" role="tab" class="tab whitespace-nowrap" aria-label="Inventory" />
<div role="tabpanel" class="tab-content pt-6" x-data="{ syncBamboo: {{ old('sync_bamboo', $product->sync_bamboo ?? false) ? 'true' : 'false' }} }">
<div role="tabpanel" class="tab-content pt-6" x-data="{
syncBamboo: {{ old('sync_bamboo', $product->sync_bamboo ?? false) ? 'true' : 'false' }},
inventoryMode: '{{ old('inventory_mode', $product->inventory_mode ?? 'unlimited') }}'
}">
{{-- Tab 3: Inventory --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{{-- First Row: Product Launch Date and Status --}}
@@ -780,7 +822,33 @@
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{{-- Inventory Mode Selector --}}
<div class="form-control mt-6">
<label class="label">
<span class="label-text font-semibold">Inventory Tracking Mode</span>
<div class="tooltip tooltip-left" data-tip="Choose how inventory is tracked for this product">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/60" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</label>
<select name="inventory_mode" x-model="inventoryMode" class="select select-bordered @error('inventory_mode') select-error @enderror">
<option value="unlimited">Unlimited (No Tracking)</option>
<option value="simple">Simple Stock (Quantity Tracking)</option>
<option value="batched">Batched Inventory (Batch/Lot Tracking)</option>
</select>
@error('inventory_mode')
<label class="label"><span class="label-text-alt text-error">{{ $message }}</span></label>
@enderror
<label class="label">
<span class="label-text-alt" x-show="inventoryMode === 'unlimited'">Product will always show as available</span>
<span class="label-text-alt" x-show="inventoryMode === 'simple'">Track quantity on hand and allocated</span>
<span class="label-text-alt" x-show="inventoryMode === 'batched'">Use batch/lot system with COAs and expiration dates</span>
</label>
</div>
{{-- Simple Stock Mode Fields --}}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4" x-show="inventoryMode === 'simple'">
{{-- Inventory Levels --}}
<div class="form-control">
<label class="label"><span class="label-text">On Hand</span></label>
@@ -809,6 +877,26 @@
</label>
<input type="text" value="{{ $product->available_quantity }}" class="input input-bordered" disabled>
</div>
</div>
{{-- Batched Inventory Mode --}}
<div class="mt-4" x-show="inventoryMode === 'batched'">
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<div>
<div class="font-semibold">Total from Batches: {{ $product->getTotalFromBatches() }} units</div>
<div class="text-sm">Inventory is managed through batches/lots with COA tracking</div>
</div>
</div>
<a href="{{ route('seller.business.batches.index', [$business->slug]) }}?product={{ $product->hashid }}"
class="btn btn-primary mt-4"
target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Manage Batches
</a>
</div>
{{-- Second Row: Bamboo Sync --}}
<div class="form-control md:col-span-3">
@@ -1559,6 +1647,153 @@
.catch(error => console.error('Reorder error:', error));
}
}));
// Variety Manager Component
Alpine.data('varietyManager', () => ({
editingId: null,
saving: false,
errors: {},
form: {
name: '',
sku: '',
description: '',
wholesale_price: null,
sale_price: null,
msrp: null,
inventory_mode: 'unlimited',
quantity_on_hand: null,
quantity_allocated: null,
min_order_qty: null,
max_order_qty: null,
is_active: true
},
get modalTitle() {
return this.editingId ? 'Edit Variety' : 'Add Variety';
},
openModal(varietyId = null) {
this.editingId = varietyId;
this.errors = {};
if (varietyId) {
// Fetch variety data
fetch(`/s/{{ $business->slug }}/products/{{ $product->hashid }}/varieties/${varietyId}`, {
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('input[name="_token"]').value
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
this.form = { ...data.variety };
}
})
.catch(error => {
console.error('Error fetching variety:', error);
alert('Failed to load variety data');
});
} else {
// Reset form for new variety
this.form = {
name: '',
sku: '',
description: '',
wholesale_price: null,
sale_price: null,
msrp: null,
inventory_mode: 'unlimited',
quantity_on_hand: null,
quantity_allocated: null,
min_order_qty: null,
max_order_qty: null,
is_active: true
};
}
this.$refs.varietyModal.showModal();
},
closeModal() {
this.$refs.varietyModal.close();
this.editingId = null;
this.errors = {};
},
saveVariety() {
this.saving = true;
this.errors = {};
const url = this.editingId
? `/s/{{ $business->slug }}/products/{{ $product->hashid }}/varieties/${this.editingId}`
: `/s/{{ $business->slug }}/products/{{ $product->hashid }}/varieties`;
const method = this.editingId ? 'PUT' : 'POST';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('input[name="_token"]').value
},
body: JSON.stringify(this.form)
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload page to show updated varieties
location.reload();
} else {
// Handle validation errors
if (data.errors) {
this.errors = data.errors;
} else {
alert(data.message || 'Failed to save variety');
}
}
})
.catch(error => {
console.error('Error saving variety:', error);
alert('Failed to save variety. Please try again.');
})
.finally(() => {
this.saving = false;
});
},
editVariety(varietyId) {
this.openModal(varietyId);
},
deleteVariety(varietyId) {
if (!confirm('Are you sure you want to delete this variety? This action cannot be undone.')) {
return;
}
fetch(`/s/{{ $business->slug }}/products/{{ $product->hashid }}/varieties/${varietyId}`, {
method: 'DELETE',
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': document.querySelector('input[name="_token"]').value
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Reload page to show updated varieties
location.reload();
} else {
alert(data.message || 'Failed to delete variety');
}
})
.catch(error => {
console.error('Error deleting variety:', error);
alert('Failed to delete variety. Please try again.');
});
}
}));
});
// Set primary image from sidebar thumbnails

File diff suppressed because it is too large Load Diff

View File

@@ -572,12 +572,12 @@
</div>
{{-- Actions --}}
<div class="flex gap-1 pt-2 border-t border-base-300">
<button @click.stop="openInspector(listing)" class="btn btn-xs btn-ghost flex-1">
<div class="flex gap-1 pt-2 border-t border-base-300" @click.stop>
<button type="button" @click="openInspector(listing)" class="btn btn-xs btn-ghost flex-1">
<span class="icon-[heroicons--eye] size-3"></span>
View
</button>
<a @click.stop :href="listing.edit_url" class="btn btn-xs btn-ghost flex-1">
<a :href="listing.edit_url" class="btn btn-xs btn-ghost flex-1">
<span class="icon-[heroicons--pencil] size-3"></span>
Edit
</a>

View File

@@ -94,7 +94,7 @@ Route::prefix('b')->name('buyer.')->group(function () {
// Brand directory and pages
Route::get('/brands', [\App\Http\Controllers\MarketplaceController::class, 'brands'])->name('brands.index');
Route::get('/brands/{brand}', [\App\Http\Controllers\MarketplaceController::class, 'showBrand'])->name('brands.show');
Route::get('/brands/{brand}/{product}', [\App\Http\Controllers\MarketplaceController::class, 'showProduct'])->name('brands.products.show');
Route::get('/brands/{brand}/products/{productHashid}', [\App\Http\Controllers\MarketplaceController::class, 'showProduct'])->name('brands.products.show');
// Stock Notification Routes (buyer wants to know when products are back in stock)
Route::post('/stock-notifications/subscribe', [\App\Http\Controllers\Buyer\StockNotificationController::class, 'subscribe'])->name('stock-notifications.subscribe');

View File

@@ -183,7 +183,7 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
// Dashboard variants (Overview, Analytics, Sales)
Route::get('/dashboard/overview', [DashboardController::class, 'overview'])->name('dashboard.overview');
Route::get('/dashboard/analytics', [DashboardController::class, 'analytics'])->name('dashboard.analytics')->middleware('module.analytics');
Route::get('/dashboard/analytics', [DashboardController::class, 'analytics'])->name('dashboard.analytics');
Route::get('/dashboard/sales', [DashboardController::class, 'sales'])->name('dashboard.sales');
// Fleet Management (Drivers and Vehicles) - accessible after onboarding, before approval
@@ -306,6 +306,14 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
Route::post('/reorder', [\App\Http\Controllers\Seller\ProductImageController::class, 'reorder'])->name('reorder');
Route::post('/{image}/set-primary', [\App\Http\Controllers\Seller\ProductImageController::class, 'setPrimary'])->name('set-primary');
});
// Variety Management for specific product
Route::prefix('{product}/varieties')->name('varieties.')->group(function () {
Route::get('/{variety}', [\App\Http\Controllers\Seller\Product\VarietyController::class, 'show'])->name('show');
Route::post('/', [\App\Http\Controllers\Seller\Product\VarietyController::class, 'store'])->name('store');
Route::put('/{variety}', [\App\Http\Controllers\Seller\Product\VarietyController::class, 'update'])->name('update');
Route::delete('/{variety}', [\App\Http\Controllers\Seller\Product\VarietyController::class, 'destroy'])->name('destroy');
});
});
// Product Lines Management (business-scoped)
@@ -557,20 +565,12 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
// ============================================
// INVENTORY MODULE
// ============================================
// Two-tier approach:
// - Basic (Free): Simple inventory overview at /inventory
// - Premium (has_inventory): Full warehouse management with submenu
Route::prefix('inventory')->name('inventory.')->group(function () {
// BASIC TIER - Always Available (Freemium)
// Simple inventory overview - shows product stock levels
Route::get('/', [\App\Http\Controllers\Seller\Inventory\DashboardController::class, 'basic'])->name('overview');
// Main Inventory Dashboard - Always Available
Route::get('/', [\App\Http\Controllers\Seller\Inventory\DashboardController::class, 'index'])->name('overview');
// PREMIUM TIER - Requires has_inventory module
// Full warehouse management with advanced features
// PREMIUM FEATURES - Require has_inventory module
Route::middleware(['module:has_inventory'])->group(function () {
// Advanced Dashboard
Route::get('/dashboard', [\App\Http\Controllers\Seller\Inventory\DashboardController::class, 'index'])->name('dashboard');
// Inventory Items Management
Route::get('/items', [\App\Http\Controllers\Seller\Inventory\InventoryItemController::class, 'index'])->name('items.index');
Route::get('/items/create', [\App\Http\Controllers\Seller\Inventory\InventoryItemController::class, 'create'])->name('items.create');
@@ -644,7 +644,7 @@ Route::prefix('s')->name('seller.')->middleware('seller')->group(function () {
// Flag: has_analytics
// Features: Business intelligence, cross-module reporting, executive dashboards
// URL: /s/{business}/buyer-intelligence/*
Route::prefix('buyer-intelligence')->name('buyer-intelligence.')->middleware('module.analytics')->group(function () {
Route::prefix('buyer-intelligence')->name('buyer-intelligence.')->middleware(['module:has_analytics'])->group(function () {
// Main analytics dashboard (overview)
Route::get('/', [\App\Http\Controllers\Seller\Marketing\Analytics\AnalyticsDashboardController::class, 'index'])->name('index');