Compare commits

...

12 Commits

Author SHA1 Message Date
kelly
05f77e6144 fix: ThreadController to use Business route model binding
All methods now accept Business $business as a route parameter instead
of incorrectly trying to access $request->user()->business which doesn't
exist in this app's architecture.
2025-12-03 13:04:14 -07:00
kelly
b92ba4b86d feat: add dev environment seeders and fixes
- Add DevCleanupSeeder to remove non-Thunder Bud products (keeps only TB- prefix)
- Add DevMediaSyncSeeder to update brand/product media paths from MinIO
- Fix CustomerController to pass $benefits array to feature-disabled view
- Update BrandSeeder to include Twisties brand
- Make vite.config.js read VITE_PORT from env (fixes port conflict)

Run on dev.cannabrands.app:
  php artisan db:seed --class=DevCleanupSeeder
  php artisan db:seed --class=DevMediaSyncSeeder
2025-12-03 12:47:33 -07:00
Jon
f8f219f00b Merge pull request 'feat: consolidate suites to 7 active and update user management UI' (#104) from feature/suite-consolidation-and-user-permissions into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/104
2025-12-03 18:06:07 +00:00
kelly
f16dac012d feat: consolidate suites to 7 active and update user management UI
- Consolidate from 14+ suites to 7 active suites (sales, processing,
  manufacturing, delivery, management, brand_manager, dispensary)
- Mark 9 legacy suites as deprecated (is_active=false)
- Update DepartmentSuitePermission with granular permissions per suite
- Update DevSuitesSeeder with correct business→suite assignments
- Rebuild Settings > Users page with new role system (owner/admin/manager/member)
- Add department assignment UI with department roles (operator/lead/supervisor/manager)
- Add suite-based permission overrides with tabbed UI
- Move SUITES_AND_PRICING_MODEL.md to docs root
- Add USER_MANAGEMENT.md documentation
2025-12-03 09:59:22 -07:00
Jon
f566b83cc6 Merge pull request 'fix: use /up health endpoint for K8s probes' (#103) from fix/health-probe-path into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/103
2025-12-03 08:07:26 +00:00
Jon Leopard
418da7a39e fix: use /up health endpoint for K8s probes
The root path (/) redirects to /register or /login when unauthenticated,
causing readiness/liveness probes to fail with 302 responses.

Laravel's /up health endpoint always returns 200 OK.
2025-12-03 01:00:52 -07:00
Jon
3c6fe92811 Merge pull request 'fix: force HTTPS scheme for asset URLs in non-local environments' (#102) from fix/force-https-scheme into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/102
2025-12-03 07:28:14 +00:00
Jon
7d3243b67e Merge branch 'develop' into fix/force-https-scheme 2025-12-03 07:20:30 +00:00
Jon Leopard
8f6597f428 fix: force HTTPS scheme for asset URLs in non-local environments
Filament v4 uses dynamic imports for components like tabs.js and select.js.
These use Laravel's asset() helper which doesn't automatically respect
X-Forwarded-Proto from TrustProxies middleware.

When SSL terminates at the K8s ingress, PHP sees HTTP requests, so asset()
generates HTTP URLs. The browser then blocks these as 'Mixed Content' when
the page is served over HTTPS.

URL::forceScheme('https') ensures all generated URLs use HTTPS in
development, staging, and production environments.
2025-12-03 00:17:04 -07:00
Jon
64d38b8b2f Merge pull request 'fix: add asset_url config key for ASSET_URL env var to work' (#101) from fix/filament-mixed-content-asset-url into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/101
2025-12-03 06:55:35 +00:00
Jon Leopard
7aa366eda9 fix: add asset_url config key for ASSET_URL env var to work
Laravel 11's minimal config/app.php doesn't include the asset_url
key by default. Without this, the ASSET_URL environment variable
is never read, causing asset() to not use the configured URL.
2025-12-02 23:43:52 -07:00
Jon
d7adaf0cba Merge pull request 'fix: add ASSET_URL to resolve Filament v4 mixed content errors' (#100) from fix/asset-url-mixed-content into develop
Reviewed-on: https://code.cannabrands.app/Cannabrands/hub/pulls/100
2025-12-03 06:24:17 +00:00
18 changed files with 2371 additions and 1468 deletions

View File

@@ -22,6 +22,12 @@ class CustomerController extends Controller
'business' => $business,
'feature' => 'Customers',
'description' => 'The Customers feature requires CRM to be enabled for your business.',
'benefits' => [
'Manage all your customer accounts in one place',
'Track contact information and order history',
'Build stronger customer relationships',
'Access customer insights and analytics',
],
]);
}
@@ -42,6 +48,12 @@ class CustomerController extends Controller
'business' => $business,
'feature' => 'Customers',
'description' => 'The Customers feature requires CRM to be enabled for your business.',
'benefits' => [
'Manage all your customer accounts in one place',
'Track contact information and order history',
'Build stronger customer relationships',
'Access customer insights and analytics',
],
]);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Seller\Crm;
use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\Crm\CrmActiveView;
use App\Models\Crm\CrmChannel;
use App\Models\Crm\CrmInternalNote;
@@ -24,10 +25,8 @@ class ThreadController extends Controller
/**
* Display unified inbox
*/
public function index(Request $request)
public function index(Request $request, Business $business)
{
$business = $request->user()->business;
$query = CrmThread::forBusiness($business->id)
->with(['contact', 'assignee', 'messages' => fn ($q) => $q->latest()->limit(1)])
->withCount('messages');
@@ -77,10 +76,8 @@ class ThreadController extends Controller
/**
* Show a single thread
*/
public function show(Request $request, CrmThread $thread)
public function show(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
// SECURITY: Verify business ownership
if ($thread->business_id !== $business->id) {
abort(404);
@@ -128,10 +125,8 @@ class ThreadController extends Controller
/**
* Send a reply in thread
*/
public function reply(Request $request, CrmThread $thread)
public function reply(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -177,10 +172,8 @@ class ThreadController extends Controller
/**
* Assign thread to user
*/
public function assign(Request $request, CrmThread $thread)
public function assign(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -206,10 +199,8 @@ class ThreadController extends Controller
/**
* Close thread
*/
public function close(Request $request, CrmThread $thread)
public function close(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -222,10 +213,8 @@ class ThreadController extends Controller
/**
* Reopen thread
*/
public function reopen(Request $request, CrmThread $thread)
public function reopen(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -241,10 +230,8 @@ class ThreadController extends Controller
/**
* Snooze thread
*/
public function snooze(Request $request, CrmThread $thread)
public function snooze(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -264,10 +251,8 @@ class ThreadController extends Controller
/**
* Add internal note
*/
public function addNote(Request $request, CrmThread $thread)
public function addNote(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -290,10 +275,8 @@ class ThreadController extends Controller
/**
* Generate AI reply draft
*/
public function generateAiReply(Request $request, CrmThread $thread)
public function generateAiReply(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
abort(404);
}
@@ -313,10 +296,8 @@ class ThreadController extends Controller
/**
* Heartbeat for active viewing
*/
public function heartbeat(Request $request, CrmThread $thread)
public function heartbeat(Request $request, Business $business, CrmThread $thread)
{
$business = $request->user()->business;
if ($thread->business_id !== $business->id) {
return response()->json(['error' => 'Unauthorized'], 403);
}

View File

@@ -6,7 +6,9 @@ use App\Http\Controllers\Controller;
use App\Models\Business;
use App\Models\DeliveryWindow;
use App\Models\Department;
use App\Models\DepartmentSuitePermission;
use App\Models\Driver;
use App\Models\Suite;
use App\Models\User;
use App\Models\Vehicle;
use Illuminate\Http\Request;
@@ -141,11 +143,6 @@ class SettingsController extends Controller
{
$query = $business->users();
// Exclude the business owner from the list
if ($business->owner_user_id) {
$query->where('users.id', '!=', $business->owner_user_id);
}
// Search
if ($request->filled('search')) {
$search = $request->search;
@@ -155,10 +152,15 @@ class SettingsController extends Controller
});
}
// Filter by account type (role)
if ($request->filled('account_type')) {
$query->whereHas('roles', function ($q) use ($request) {
$q->where('name', $request->account_type);
// Filter by role
if ($request->filled('role')) {
$query->wherePivot('role', $request->role);
}
// Filter by department
if ($request->filled('department_id')) {
$query->whereHas('departments', function ($q) use ($request) {
$q->where('departments.id', $request->department_id);
});
}
@@ -170,9 +172,20 @@ class SettingsController extends Controller
$query->where('last_login_at', '<=', $request->last_login_end.' 23:59:59');
}
$users = $query->with('roles')->paginate(15);
$users = $query->with(['roles', 'departments'])->paginate(15);
return view('seller.settings.users', compact('business', 'users'));
// Get all departments for this business
$departments = Department::where('business_id', $business->id)
->orderBy('sort_order')
->get();
// Get business owner info
$owner = $business->owner;
// Get the suites assigned to this business for permission reference
$businessSuites = $business->suites()->active()->get();
return view('seller.settings.users', compact('business', 'users', 'departments', 'owner', 'businessSuites'));
}
/**
@@ -186,7 +199,9 @@ class SettingsController extends Controller
'email' => 'required|email|unique:users,email',
'phone' => 'nullable|string|max:20',
'position' => 'nullable|string|max:255',
'role' => 'required|string|in:company-owner,company-manager,company-user,company-sales,company-accounting,company-manufacturing,company-processing',
'role' => 'required|string|in:owner,admin,manager,member',
'department_ids' => 'nullable|array',
'department_ids.*' => 'exists:departments,id',
'is_point_of_contact' => 'nullable|boolean',
]);
@@ -196,21 +211,32 @@ class SettingsController extends Controller
// Create user and associate with business
$user = \App\Models\User::create([
'name' => $fullName,
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'email' => $validated['email'],
'phone' => $validated['phone'],
'phone' => $validated['phone'] ?? null,
'position' => $validated['position'] ?? null,
'user_type' => $business->business_type, // Match business type
'password' => bcrypt(str()->random(32)), // Temporary password
]);
// Assign role
$user->assignRole($validated['role']);
// Associate with business with additional pivot data
// Associate with business with role
$business->users()->attach($user->id, [
'role' => $validated['role'],
'is_primary' => false,
'contact_type' => $request->has('is_point_of_contact') ? 'primary' : null,
'permissions' => [], // Empty initially, assigned via department
]);
// Assign to departments if specified
if (! empty($validated['department_ids'])) {
foreach ($validated['department_ids'] as $departmentId) {
$user->departments()->attach($departmentId, [
'role' => 'operator', // Default department role
]);
}
}
// TODO: Send invitation email with password reset link
return redirect()
@@ -264,10 +290,28 @@ class SettingsController extends Controller
->orderBy('sort_order')
->get();
// Define permission categories (existing permissions system)
$permissionCategories = $this->getPermissionCategories();
// Get the suites assigned to this business
$businessSuites = $business->suites()->active()->get();
return view('seller.settings.users-edit', compact('business', 'user', 'isOwner', 'departments', 'permissionCategories'));
// Build suite permissions structure based on business's assigned suites
$suitePermissions = $this->getSuitePermissions($businessSuites);
// Get user's current permissions from pivot
$userPermissions = $business->users()
->where('users.id', $user->id)
->first()
->pivot
->permissions ?? [];
return view('seller.settings.users-edit', compact(
'business',
'user',
'isOwner',
'departments',
'businessSuites',
'suitePermissions',
'userPermissions'
));
}
/**
@@ -324,63 +368,122 @@ class SettingsController extends Controller
}
/**
* Get permission categories for the permissions system.
* Get suite-based permission structure for the assigned suites.
*
* Groups permissions by functional area for better UX.
*/
private function getPermissionCategories(): array
private function getSuitePermissions($businessSuites): array
{
return [
'ecommerce' => [
'name' => 'Ecommerce',
'icon' => 'lucide--shopping-cart',
'permissions' => [
'view_orders' => ['name' => 'View Orders', 'description' => 'View all orders and order details'],
'manage_orders' => ['name' => 'Manage Orders', 'description' => 'Accept, reject, and update orders'],
'view_invoices' => ['name' => 'View Invoices', 'description' => 'View invoices and payment information'],
'manage_invoices' => ['name' => 'Manage Invoices', 'description' => 'Create and edit invoices'],
],
],
'products' => [
'name' => 'Products & Inventory',
'icon' => 'lucide--package',
'permissions' => [
'view_products' => ['name' => 'View Products', 'description' => 'View product catalog'],
'manage_products' => ['name' => 'Manage Products', 'description' => 'Create, edit, and delete products'],
'view_inventory' => ['name' => 'View Inventory', 'description' => 'View inventory levels and stock'],
'manage_inventory' => ['name' => 'Manage Inventory', 'description' => 'Update inventory and stock levels'],
],
],
'data_visibility' => [
'name' => 'Data & Analytics Visibility',
'icon' => 'lucide--eye',
'permissions' => [
'view_sales_data' => ['name' => 'View Sales Data', 'description' => 'See revenue, pricing, profit margins, and sales metrics'],
'view_performance_data' => ['name' => 'View Performance Data', 'description' => 'See yields, efficiency, quality metrics, and production stats'],
'view_cost_data' => ['name' => 'View Cost Data', 'description' => 'See material costs, labor costs, and expense breakdowns'],
'view_customer_data' => ['name' => 'View Customer Data', 'description' => 'See customer names, contact info, and purchase history'],
],
],
'manufacturing' => [
'name' => 'Manufacturing & Processing',
'icon' => 'lucide--factory',
'permissions' => [
'view_work_orders' => ['name' => 'View Work Orders', 'description' => 'View work orders (limited to own department)'],
'manage_work_orders' => ['name' => 'Manage Work Orders', 'description' => 'Create, edit, and complete work orders'],
'view_wash_reports' => ['name' => 'View Wash Reports', 'description' => 'View solventless wash reports and data'],
'manage_wash_reports' => ['name' => 'Manage Wash Reports', 'description' => 'Create and edit wash reports'],
'view_all_departments' => ['name' => 'View All Departments', 'description' => 'See data across all departments (not just own)'],
],
],
'business' => [
'name' => 'Business Management',
'icon' => 'lucide--building-2',
'permissions' => [
'view_settings' => ['name' => 'View Settings', 'description' => 'View business settings and configuration'],
'manage_settings' => ['name' => 'Manage Settings', 'description' => 'Update business settings and configuration'],
'view_users' => ['name' => 'View Users', 'description' => 'View user list and permissions'],
'manage_users' => ['name' => 'Manage Users', 'description' => 'Invite and manage user permissions'],
],
],
$suitePermissions = [];
foreach ($businessSuites as $suite) {
$permissions = DepartmentSuitePermission::getAvailablePermissions($suite->key);
if (empty($permissions)) {
continue;
}
// Group permissions by functional area
$grouped = $this->groupPermissionsByArea($permissions, $suite->key);
$suitePermissions[$suite->key] = [
'name' => $suite->name,
'description' => $suite->description,
'icon' => $suite->icon,
'color' => $suite->color,
'groups' => $grouped,
];
}
return $suitePermissions;
}
/**
* Group permissions by functional area for better organization.
*/
private function groupPermissionsByArea(array $permissions, string $suiteKey): array
{
// Define permission groupings based on prefix patterns
$areaPatterns = [
'dashboard' => ['view_dashboard', 'view_org_dashboard', 'view_analytics', 'view_all_analytics', 'export_analytics'],
'products' => ['view_products', 'manage_products', 'view_inventory', 'adjust_inventory', 'view_costs', 'view_margin'],
'orders' => ['view_orders', 'create_orders', 'manage_orders', 'view_invoices', 'create_invoices'],
'menus' => ['view_menus', 'manage_menus', 'view_promotions', 'manage_promotions'],
'campaigns' => ['view_campaigns', 'manage_campaigns', 'send_campaigns', 'manage_templates'],
'crm' => ['view_pipeline', 'edit_pipeline', 'manage_accounts', 'view_buyer_intelligence'],
'automations' => ['view_automations', 'manage_automations', 'use_copilot'],
'batches' => ['view_batches', 'manage_batches', 'create_batches'],
'processing' => ['view_wash_reports', 'create_wash_reports', 'edit_wash_reports', 'manage_extractions', 'view_yields', 'manage_biomass_intake', 'manage_material_transfers'],
'manufacturing' => ['view_bom', 'manage_bom', 'create_bom', 'view_production_queue', 'manage_production', 'manage_packaging', 'manage_labeling', 'create_skus', 'manage_lot_tracking'],
'work_orders' => ['view_work_orders', 'create_work_orders', 'manage_work_orders'],
'delivery' => ['view_pick_pack', 'manage_pick_pack', 'view_manifests', 'create_manifests', 'view_delivery_windows', 'manage_delivery_windows', 'view_drivers', 'manage_drivers', 'view_vehicles', 'manage_vehicles', 'view_routes', 'manage_routing', 'view_deliveries', 'complete_deliveries', 'manage_proof_of_delivery'],
'compliance' => ['view_compliance', 'manage_licenses', 'manage_coas', 'view_compliance_reports'],
'finance' => ['view_ap', 'manage_ap', 'pay_bills', 'view_ar', 'manage_ar', 'view_budgets', 'manage_budgets', 'approve_budget_exceptions', 'view_inter_company_ledger', 'manage_inter_company', 'view_financial_reports', 'export_financial_data', 'view_forecasting', 'manage_forecasting', 'view_kpis', 'view_usage_billing', 'manage_billing', 'view_cross_business'],
'messaging' => ['view_conversations', 'send_messages', 'manage_contacts'],
'procurement' => ['view_vendors', 'manage_vendors', 'view_requisitions', 'create_requisitions', 'approve_requisitions', 'view_purchase_orders', 'create_purchase_orders', 'receive_goods'],
'tools' => ['manage_settings', 'manage_users', 'manage_departments', 'view_audit_log', 'manage_integrations'],
'marketplace' => ['view_marketplace', 'browse_products', 'manage_cart', 'manage_favorites', 'view_buyer_portal', 'view_account'],
'brand_view' => ['view_sales', 'view_buyers'],
];
$areaLabels = [
'dashboard' => ['name' => 'Dashboard & Analytics', 'icon' => 'lucide--layout-dashboard'],
'products' => ['name' => 'Products & Inventory', 'icon' => 'lucide--package'],
'orders' => ['name' => 'Orders & Invoicing', 'icon' => 'lucide--shopping-cart'],
'menus' => ['name' => 'Menus & Promotions', 'icon' => 'lucide--menu'],
'campaigns' => ['name' => 'Campaigns & Marketing', 'icon' => 'lucide--megaphone'],
'crm' => ['name' => 'CRM & Accounts', 'icon' => 'lucide--users'],
'automations' => ['name' => 'Automations & AI', 'icon' => 'lucide--bot'],
'batches' => ['name' => 'Batches', 'icon' => 'lucide--layers'],
'processing' => ['name' => 'Processing Operations', 'icon' => 'lucide--flask-conical'],
'manufacturing' => ['name' => 'Manufacturing', 'icon' => 'lucide--factory'],
'work_orders' => ['name' => 'Work Orders', 'icon' => 'lucide--clipboard-list'],
'delivery' => ['name' => 'Delivery & Fulfillment', 'icon' => 'lucide--truck'],
'compliance' => ['name' => 'Compliance', 'icon' => 'lucide--shield-check'],
'finance' => ['name' => 'Finance & Budgets', 'icon' => 'lucide--banknote'],
'messaging' => ['name' => 'Messaging', 'icon' => 'lucide--message-square'],
'procurement' => ['name' => 'Procurement', 'icon' => 'lucide--clipboard-list'],
'tools' => ['name' => 'Tools & Settings', 'icon' => 'lucide--settings'],
'marketplace' => ['name' => 'Marketplace', 'icon' => 'lucide--store'],
'brand_view' => ['name' => 'Brand Data', 'icon' => 'lucide--eye'],
];
$grouped = [];
$assigned = [];
foreach ($areaPatterns as $area => $areaPermissions) {
$matchedPermissions = [];
foreach ($permissions as $key => $description) {
if (in_array($key, $areaPermissions) && ! isset($assigned[$key])) {
$matchedPermissions[$key] = $description;
$assigned[$key] = true;
}
}
if (! empty($matchedPermissions)) {
$grouped[$area] = [
'name' => $areaLabels[$area]['name'] ?? ucwords(str_replace('_', ' ', $area)),
'icon' => $areaLabels[$area]['icon'] ?? 'lucide--folder',
'permissions' => $matchedPermissions,
];
}
}
// Add any remaining permissions to "Other"
$remaining = [];
foreach ($permissions as $key => $description) {
if (! isset($assigned[$key])) {
$remaining[$key] = $description;
}
}
if (! empty($remaining)) {
$grouped['other'] = [
'name' => 'Other',
'icon' => 'lucide--more-horizontal',
'permissions' => $remaining,
];
}
return $grouped;
}
/**

View File

@@ -11,11 +11,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Represents the permissions a department has for a specific suite.
*
* The `permissions` JSON field contains granular permission flags like:
* - For Sales Suite: view_pipeline, edit_pipeline, manage_accounts, create_orders
* - For Inventory Suite: view_basic, view_costs, adjust_inventory
* - For Finance Suite: view_ap, view_ar, approve_invoices, pay_bills, view_margin
* - etc.
* The `permissions` JSON field contains granular permission flags.
* See SUITE_PERMISSIONS constant for available permissions per suite.
*
* Based on docs/SUITES_AND_PRICING_MODEL.md - 7 active suites:
* - sales, processing, manufacturing, delivery, management, brand_manager, dispensary
*/
class DepartmentSuitePermission extends Model
{
@@ -36,129 +36,317 @@ class DepartmentSuitePermission extends Model
/**
* Available permissions per suite.
* These define what granular permissions are available for each suite.
*
* Note: Messaging, Procurement, and Tools are shared features available to all suites.
* They are included in each suite's permissions for granular control.
*/
public const SUITE_PERMISSIONS = [
// =========================================================================
// SALES SUITE - External customers (priced)
// =========================================================================
'sales' => [
// Dashboard & Analytics
'view_dashboard' => 'View dashboard and overview',
'view_analytics' => 'View analytics and reports',
'export_analytics' => 'Export analytics data',
// Products & Inventory
'view_products' => 'View products',
'manage_products' => 'Create and edit products',
'view_inventory' => 'View inventory levels',
'adjust_inventory' => 'Adjust inventory quantities',
'view_costs' => 'View cost and pricing data',
'view_margin' => 'View margin information',
// Batches (from supplied COAs)
'view_batches' => 'View batch information',
'manage_batches' => 'Manage batch information',
// Orders & Invoicing
'view_orders' => 'View orders',
'create_orders' => 'Create and process orders',
'manage_orders' => 'Manage order status and fulfillment',
'view_invoices' => 'View invoices',
'create_invoices' => 'Create invoices',
// Menus & Promotions
'view_menus' => 'View menus',
'manage_menus' => 'Create and manage menus',
'view_promotions' => 'View promotions',
'manage_promotions' => 'Create and manage promotions',
// Campaigns & Marketing
'view_campaigns' => 'View marketing campaigns',
'manage_campaigns' => 'Create and manage campaigns',
'send_campaigns' => 'Send marketing campaigns',
'manage_templates' => 'Manage message templates',
// CRM & Buyer Intelligence
'view_pipeline' => 'View sales pipeline',
'edit_pipeline' => 'Edit sales pipeline',
'manage_accounts' => 'Manage customer accounts',
'create_orders' => 'Create and process orders',
'view_orders' => 'View orders',
'manage_promotions' => 'Create and manage promotions',
'send_campaigns' => 'Send marketing campaigns',
'manage_templates' => 'Manage message templates',
'view_buyer_intelligence' => 'View buyer intelligence data',
// Automations & AI
'view_automations' => 'View automations',
'manage_automations' => 'Create and manage automations',
'use_copilot' => 'Use AI copilot features',
// Messaging (shared feature)
'view_conversations' => 'View conversations/inbox',
'send_messages' => 'Send messages to customers',
'use_copilot' => 'Use AI copilot features',
'manage_menus' => 'Create and manage menus',
],
'inventory' => [
'view_basic' => 'View basic inventory info',
'view_costs' => 'View cost and pricing data',
'view_margin' => 'View margin information',
'adjust_inventory' => 'Adjust inventory quantities',
'manage_products' => 'Create and edit products',
'manage_batches' => 'Manage batch information',
'manage_components' => 'Manage components and BOMs',
'view_movements' => 'View stock movements',
'create_movements' => 'Create stock movements',
],
'processing' => [
'view_dashboard' => 'View processing dashboard',
'create_wash_reports' => 'Create wash reports',
'edit_wash_reports' => 'Edit wash reports',
'manage_extractions' => 'Manage extraction operations',
'view_yields' => 'View yield reports',
'manage_batches' => 'Manage processing batches',
'manage_work_orders' => 'Manage work orders',
'view_analytics' => 'View processing analytics',
],
'manufacturing' => [
'view_dashboard' => 'View manufacturing dashboard',
'manage_bom' => 'Manage bill of materials',
'create_work_orders' => 'Create work orders',
'manage_work_orders' => 'Manage and complete work orders',
'manage_packaging' => 'Manage packaging operations',
'manage_labeling' => 'Manage labeling',
'view_production_queue' => 'View production queue',
'view_analytics' => 'View manufacturing analytics',
],
'procurement' => [
'manage_contacts' => 'Manage contacts',
// Procurement (shared feature)
'view_vendors' => 'View vendors and suppliers',
'manage_vendors' => 'Manage vendor information',
'view_requisitions' => 'View purchase requisitions',
'create_requisitions' => 'Create purchase requisitions',
'approve_requisitions' => 'Approve purchase requisitions',
'view_purchase_orders' => 'View purchase orders',
'create_purchase_orders' => 'Create purchase orders',
'approve_purchase_orders' => 'Approve purchase orders',
'receive_goods' => 'Receive goods against POs',
'manage_vendors' => 'Manage vendor information',
],
'distribution' => [
'view_pick_pack' => 'View pick/pack queue',
'manage_pick_pack' => 'Manage pick/pack operations',
'view_manifests' => 'View delivery manifests',
'create_manifests' => 'Create delivery manifests',
'manage_routing' => 'Manage delivery routes',
'manage_drivers' => 'Manage drivers and vehicles',
'view_deliveries' => 'View delivery status',
'complete_deliveries' => 'Complete delivery operations',
'view_analytics' => 'View delivery analytics',
],
'finance' => [
'view_ap' => 'View accounts payable',
'manage_ap' => 'Manage accounts payable',
'view_ar' => 'View accounts receivable',
'manage_ar' => 'Manage accounts receivable',
'view_invoices' => 'View invoices',
'create_invoices' => 'Create invoices',
'approve_invoices' => 'Approve invoices',
'pay_bills' => 'Process bill payments',
'view_margin' => 'View margin and profitability',
'view_reports' => 'View financial reports',
'manage_budgets' => 'Manage budgets',
'export_data' => 'Export financial data',
],
'compliance' => [
'view_compliance' => 'View compliance status',
'manage_licenses' => 'Manage licenses',
'manage_coas' => 'Manage certificates of analysis',
'manage_manifests' => 'Manage compliance manifests',
'view_reports' => 'View compliance reports',
'export_data' => 'Export compliance data',
],
'management' => [
'view_org_dashboard' => 'View organization dashboard',
'view_cross_business' => 'View cross-business data',
'view_all_finances' => 'View all financial data (Canopy)',
'manage_forecasting' => 'Manage forecasting',
'view_kpis' => 'View KPIs and metrics',
'manage_settings' => 'Manage organization settings',
],
'inbox' => [
'view_inbox' => 'View inbox messages',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
'use_templates' => 'Use message templates',
],
'tools' => [
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
'manage_integrations' => 'Manage integrations',
],
'brand_portal' => [
'view_sales' => 'View sales and orders for linked brands',
'view_inventory' => 'View inventory for linked brands (read-only)',
'view_promotions' => 'View promotions involving linked brands',
'view_accounts' => 'View customer accounts carrying linked brands',
// Explicitly NOT available for brand portal:
// 'view_costs', 'view_margin', 'edit_anything'
// =========================================================================
// PROCESSING SUITE - Internal (Curagreen, Leopard AZ)
// =========================================================================
'processing' => [
// Dashboard & Analytics
'view_dashboard' => 'View processing dashboard',
'view_analytics' => 'View processing analytics',
// Batches & Runs
'view_batches' => 'View batches and runs',
'manage_batches' => 'Manage processing batches',
'create_batches' => 'Create new batches',
// Extraction Operations
'view_wash_reports' => 'View wash reports',
'create_wash_reports' => 'Create wash reports',
'edit_wash_reports' => 'Edit wash reports',
'manage_extractions' => 'Manage extraction operations',
'view_yields' => 'View yield reports',
// Biomass & Materials
'manage_biomass_intake' => 'Manage biomass intake',
'manage_material_transfers' => 'Manage material transfers',
// Work Orders
'view_work_orders' => 'View work orders',
'create_work_orders' => 'Create work orders',
'manage_work_orders' => 'Manage and complete work orders',
// Compliance (Processing has compliance)
'view_compliance' => 'View compliance status',
'manage_licenses' => 'Manage licenses',
'manage_coas' => 'Manage certificates of analysis',
'view_compliance_reports' => 'View compliance reports',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
// Procurement (shared feature)
'view_vendors' => 'View vendors and suppliers',
'manage_vendors' => 'Manage vendor information',
'view_requisitions' => 'View purchase requisitions',
'create_requisitions' => 'Create purchase requisitions',
'approve_requisitions' => 'Approve purchase requisitions',
'view_purchase_orders' => 'View purchase orders',
'create_purchase_orders' => 'Create purchase orders',
'receive_goods' => 'Receive goods against POs',
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
],
// =========================================================================
// MANUFACTURING SUITE - Internal (Leopard AZ)
// =========================================================================
'manufacturing' => [
// Dashboard & Analytics
'view_dashboard' => 'View manufacturing dashboard',
'view_analytics' => 'View manufacturing analytics',
// BOM (Manufacturing only)
'view_bom' => 'View bill of materials',
'manage_bom' => 'Manage bill of materials',
'create_bom' => 'Create bill of materials',
// Work Orders
'view_work_orders' => 'View work orders',
'create_work_orders' => 'Create work orders',
'manage_work_orders' => 'Manage and complete work orders',
// Production
'view_production_queue' => 'View production queue',
'manage_production' => 'Manage production operations',
'manage_packaging' => 'Manage packaging operations',
'manage_labeling' => 'Manage labeling',
// SKU & Lot Tracking
'create_skus' => 'Create SKUs from batches',
'manage_lot_tracking' => 'Manage lot tracking',
// Batches
'view_batches' => 'View batch information',
'manage_batches' => 'Manage batch information',
// Compliance (Manufacturing has compliance)
'view_compliance' => 'View compliance status',
'manage_licenses' => 'Manage licenses',
'manage_coas' => 'Manage certificates of analysis',
'view_compliance_reports' => 'View compliance reports',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
// Procurement (shared feature)
'view_vendors' => 'View vendors and suppliers',
'manage_vendors' => 'Manage vendor information',
'view_requisitions' => 'View purchase requisitions',
'create_requisitions' => 'Create purchase requisitions',
'approve_requisitions' => 'Approve purchase requisitions',
'view_purchase_orders' => 'View purchase orders',
'create_purchase_orders' => 'Create purchase orders',
'receive_goods' => 'Receive goods against POs',
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
],
// =========================================================================
// DELIVERY SUITE - Internal (Leopard AZ)
// =========================================================================
'delivery' => [
// Dashboard & Analytics
'view_dashboard' => 'View delivery dashboard',
'view_analytics' => 'View delivery analytics',
// Pick & Pack
'view_pick_pack' => 'View pick/pack queue',
'manage_pick_pack' => 'Manage pick/pack operations',
// Manifests
'view_manifests' => 'View delivery manifests',
'create_manifests' => 'Create delivery manifests',
// Delivery Windows
'view_delivery_windows' => 'View delivery windows',
'manage_delivery_windows' => 'Manage delivery windows',
// Drivers & Vehicles
'view_drivers' => 'View drivers',
'manage_drivers' => 'Manage drivers',
'view_vehicles' => 'View vehicles',
'manage_vehicles' => 'Manage vehicles',
// Routes & Deliveries
'view_routes' => 'View routes',
'manage_routing' => 'Manage delivery routes',
'view_deliveries' => 'View delivery status',
'complete_deliveries' => 'Complete delivery operations',
'manage_proof_of_delivery' => 'Manage proof of delivery',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
// Procurement (shared feature)
'view_vendors' => 'View vendors and suppliers',
'manage_vendors' => 'Manage vendor information',
'view_requisitions' => 'View purchase requisitions',
'create_requisitions' => 'Create purchase requisitions',
'approve_requisitions' => 'Approve purchase requisitions',
'view_purchase_orders' => 'View purchase orders',
'create_purchase_orders' => 'Create purchase orders',
'receive_goods' => 'Receive goods against POs',
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
],
// =========================================================================
// MANAGEMENT SUITE - Internal (Canopy - parent company)
// =========================================================================
'management' => [
// Dashboard & Analytics
'view_org_dashboard' => 'View organization dashboard',
'view_cross_business' => 'View cross-business data',
'view_all_analytics' => 'View all analytics across subdivisions',
// Finance - Accounts Payable
'view_ap' => 'View accounts payable',
'manage_ap' => 'Manage accounts payable',
'pay_bills' => 'Process bill payments',
// Finance - Accounts Receivable
'view_ar' => 'View accounts receivable',
'manage_ar' => 'Manage accounts receivable',
'view_invoices' => 'View all invoices',
// Budgets
'view_budgets' => 'View budgets',
'manage_budgets' => 'Manage budgets per subdivision',
'approve_budget_exceptions' => 'Approve POs that exceed budget',
// Inter-company
'view_inter_company_ledger' => 'View inter-company ledger',
'manage_inter_company' => 'Manage inter-company transfers',
// Financial Reports
'view_financial_reports' => 'View financial reports (P&L, balance sheet, cash flow)',
'export_financial_data' => 'Export financial data',
// Forecasting & KPIs
'view_forecasting' => 'View forecasting',
'manage_forecasting' => 'Manage forecasting',
'view_kpis' => 'View KPIs and metrics',
// Usage & Billing
'view_usage_billing' => 'View usage and billing analytics',
'manage_billing' => 'Manage billing',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages',
'manage_contacts' => 'Manage contacts',
// Tools (shared feature)
'manage_settings' => 'Manage organization settings',
'manage_users' => 'Manage users and permissions',
'manage_departments' => 'Manage departments',
'view_audit_log' => 'View audit logs',
'manage_integrations' => 'Manage integrations',
],
// =========================================================================
// BRAND MANAGER SUITE - External brand partners (view-only)
// =========================================================================
'brand_manager' => [
// View-only access - all data auto-scoped by brand_id
// All data is auto-scoped by brand_id
'view_dashboard' => 'View brand dashboard',
'view_sales' => 'View sales history for assigned brands',
'view_orders' => 'View orders for assigned brands (read-only)',
'view_products' => 'View active products for assigned brands',
@@ -166,10 +354,44 @@ class DepartmentSuitePermission extends Model
'view_promotions' => 'View promotions for assigned brands',
'view_conversations' => 'View conversations for assigned brands (read-only)',
'view_buyers' => 'View buyer accounts for assigned brands (read-only)',
'view_analytics' => 'View brand-level analytics (not internal seller analytics)',
// Explicitly NOT available for brand_manager:
// 'view_costs', 'view_margin', 'view_wholesale_pricing' (unless brand owner)
// 'edit_products', 'create_promotions', 'manage_settings', 'view_other_brands'
'view_analytics' => 'View brand-level analytics',
// Messaging (brand-scoped)
'send_messages' => 'Send messages (brand-scoped)',
// Explicitly NOT available:
// view_costs, view_margin, view_wholesale_pricing
// edit_products, create_promotions, manage_settings, view_other_brands
],
// =========================================================================
// DISPENSARY SUITE - Buyers (dispensaries/retailers)
// =========================================================================
'dispensary' => [
// Marketplace
'view_marketplace' => 'View marketplace',
'browse_products' => 'Browse products',
'view_promotions' => 'View and redeem promotions',
// Ordering
'create_orders' => 'Create orders',
'view_orders' => 'View order history',
'manage_cart' => 'Manage shopping cart',
'manage_favorites' => 'Manage favorite products',
// Buyer Portal
'view_buyer_portal' => 'View buyer portal',
'view_account' => 'View account information',
// Messaging (shared feature)
'view_conversations' => 'View conversations',
'send_messages' => 'Send messages to sellers',
'manage_contacts' => 'Manage contacts',
// Tools (shared feature)
'manage_settings' => 'Manage business settings',
'manage_users' => 'Manage users and permissions',
'view_audit_log' => 'View audit logs',
],
];

View File

@@ -20,6 +20,7 @@ use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
@@ -43,6 +44,17 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
// Force HTTPS for all generated URLs in non-local environments
// This is required because:
// 1. SSL terminates at the K8s ingress, so PHP sees HTTP
// 2. TrustProxies passes X-Forwarded-Proto, but Laravel's asset() helper
// doesn't automatically use it - it uses the cached URL generator state
// 3. Filament's dynamic imports (tabs.js, select.js) use asset() and fail
// with "Mixed Content" errors if they generate HTTP URLs on HTTPS pages
if (! $this->app->environment('local')) {
URL::forceScheme('https');
}
// Configure password validation defaults
Password::defaults(function () {
return Password::min(8)

View File

@@ -54,6 +54,8 @@ return [
'url' => env('APP_URL', 'http://localhost'),
'asset_url' => env('ASSET_URL'),
/*
|--------------------------------------------------------------------------
| Application Timezone

View File

@@ -11,7 +11,7 @@ class BrandSeeder extends Seeder
/**
* Run the database seeds.
*
* All 14 brands belong to Cannabrands (the seller/manufacturer company)
* All 12 brands belong to Cannabrands (the seller/manufacturer company)
*/
public function run(): void
{
@@ -139,6 +139,6 @@ class BrandSeeder extends Seeder
);
}
$this->command->info('✅ Created 14 brands under Cannabrands business');
$this->command->info('✅ Created 12 brands under Cannabrands business');
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace Database\Seeders;
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Development Environment Cleanup Seeder
*
* This seeder is intended to be run manually on the dev.cannabrands.app environment
* to remove test/sample data and keep only Thunder Bud products.
*
* Run with: php artisan db:seed --class=DevCleanupSeeder
*/
class DevCleanupSeeder extends Seeder
{
/**
* Thunder Bud SKU prefix to preserve.
* Only products with SKUs starting with this prefix will be kept.
*/
protected string $thunderBudPrefix = 'TB-';
/**
* Brands to remove (test/sample brands).
*/
protected array $brandsToRemove = [
'bulk',
'twisites', // misspelled version only
];
public function run(): void
{
$this->command->info('Starting dev environment cleanup...');
// Step 1: Remove non-Thunder Bud products
$this->removeNonThunderBudProducts();
// Step 2: Remove test brands (Bulk, Twisties, Twisites)
$this->removeTestBrands();
// Step 3: Clean up orphaned data
$this->cleanupOrphanedData();
$this->command->info('Dev environment cleanup complete!');
}
protected function removeNonThunderBudProducts(): void
{
$this->command->info('Removing non-Thunder Bud products...');
// Find products that DON'T have the TB- prefix
$productsToDelete = Product::where('sku', 'not like', $this->thunderBudPrefix.'%')->get();
$count = $productsToDelete->count();
if ($count === 0) {
$this->command->info('No non-Thunder Bud products found.');
return;
}
$this->command->info("Found {$count} products to remove.");
// Delete related data first (order items, inventory, etc.)
$productIds = $productsToDelete->pluck('id')->toArray();
// Delete order items referencing these products
$orderItemsDeleted = DB::table('order_items')
->whereIn('product_id', $productIds)
->delete();
if ($orderItemsDeleted > 0) {
$this->command->info("Deleted {$orderItemsDeleted} order items.");
}
// Delete inventory records
if (DB::getSchemaBuilder()->hasTable('inventories')) {
$inventoryDeleted = DB::table('inventories')
->whereIn('product_id', $productIds)
->delete();
if ($inventoryDeleted > 0) {
$this->command->info("Deleted {$inventoryDeleted} inventory records.");
}
}
// Delete product variants if they exist
if (DB::getSchemaBuilder()->hasTable('product_variants')) {
$variantsDeleted = DB::table('product_variants')
->whereIn('product_id', $productIds)
->delete();
if ($variantsDeleted > 0) {
$this->command->info("Deleted {$variantsDeleted} product variants.");
}
}
// Delete products
$deleted = Product::whereIn('id', $productIds)->delete();
$this->command->info("Deleted {$deleted} non-Thunder Bud products.");
// List remaining Thunder Bud products
$remaining = Product::count();
$this->command->info("Remaining products: {$remaining}");
}
protected function removeTestBrands(): void
{
$this->command->info('Removing test brands (Bulk, Twisites)...');
$brandsToDelete = Brand::whereIn('slug', $this->brandsToRemove)->get();
if ($brandsToDelete->isEmpty()) {
$this->command->info('No test brands found to remove.');
return;
}
foreach ($brandsToDelete as $brand) {
// Check if brand has products (should have been deleted in previous step)
$productCount = $brand->products()->count();
if ($productCount > 0) {
$this->command->warn("Brand '{$brand->name}' still has {$productCount} products. Deleting them first...");
$brand->products()->delete();
}
$brand->delete();
$this->command->info("Deleted brand: {$brand->name}");
}
}
protected function cleanupOrphanedData(): void
{
$this->command->info('Cleaning up orphaned data...');
// Delete orders with no items
$emptyOrders = DB::table('orders')
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('order_items')
->whereColumn('order_items.order_id', 'orders.id');
})
->delete();
if ($emptyOrders > 0) {
$this->command->info("Deleted {$emptyOrders} empty orders.");
}
// Delete orphaned promotions (if table exists)
if (DB::getSchemaBuilder()->hasTable('promotions')) {
$orphanedPromotions = DB::table('promotions')
->whereNotNull('brand_id')
->whereNotExists(function ($query) {
$query->select(DB::raw(1))
->from('brands')
->whereColumn('brands.id', 'promotions.brand_id');
})
->delete();
if ($orphanedPromotions > 0) {
$this->command->info("Deleted {$orphanedPromotions} orphaned promotions.");
}
}
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace Database\Seeders;
use App\Models\Brand;
use App\Models\Product;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
/**
* Development Environment Media Sync Seeder
*
* This seeder updates brand and product media paths in the database
* to match the expected MinIO structure. It does NOT copy files -
* files should be synced separately using mc (MinIO Client) or rsync.
*
* Run with: php artisan db:seed --class=DevMediaSyncSeeder
*
* To sync actual files from local to dev MinIO, use:
* mc mirror local-minio/media/businesses/cannabrands/brands/ dev-minio/media/businesses/cannabrands/brands/
*/
class DevMediaSyncSeeder extends Seeder
{
/**
* Thunder Bud SKU prefix.
*/
protected string $thunderBudPrefix = 'TB-';
public function run(): void
{
$this->command->info('Syncing media paths for dev environment...');
$this->syncBrandMedia();
$this->syncProductMedia();
$this->command->info('Dev media sync complete!');
$this->command->newLine();
$this->command->info('Next steps:');
$this->command->line('1. Sync actual media files to dev MinIO using mc mirror or similar');
$this->command->line('2. Verify images are accessible at the configured AWS_URL');
}
/**
* Update brand logo_path and banner_path to match expected structure.
*/
protected function syncBrandMedia(): void
{
$this->command->info('Syncing brand media paths...');
$brands = Brand::with('business')->get();
$updated = 0;
foreach ($brands as $brand) {
$businessSlug = $brand->business->slug ?? 'cannabrands';
// Set expected paths based on MinIO structure
$basePath = "businesses/{$businessSlug}/brands/{$brand->slug}/branding";
// Try to find actual files or set expected paths
$logoPath = $this->findMediaFile($basePath, 'logo', ['png', 'jpg', 'jpeg']);
$bannerPath = $this->findMediaFile($basePath, 'banner', ['jpg', 'jpeg', 'png']);
// Update if we found paths or if current paths are null
if ($logoPath || $bannerPath || ! $brand->logo_path || ! $brand->banner_path) {
$brand->logo_path = $logoPath ?? "{$basePath}/logo.png";
$brand->banner_path = $bannerPath ?? "{$basePath}/banner.jpg";
$brand->save();
$updated++;
$this->command->line(" - {$brand->name}: logo={$brand->logo_path}, banner={$brand->banner_path}");
}
}
$this->command->info("Updated {$updated} brand media paths.");
}
/**
* Update Thunder Bud product image_path fields.
*/
protected function syncProductMedia(): void
{
$this->command->info('Syncing Thunder Bud product media paths...');
// Find Thunder Bud brand
$thunderBudBrand = Brand::where('slug', 'thunder-bud')
->orWhere('slug', 'thunderbud')
->first();
if (! $thunderBudBrand) {
$this->command->warn('Thunder Bud brand not found.');
return;
}
$businessSlug = $thunderBudBrand->business->slug ?? 'cannabrands';
$brandSlug = $thunderBudBrand->slug;
// Find all Thunder Bud products (TB- prefix only)
$products = Product::where('sku', 'like', $this->thunderBudPrefix.'%')->get();
$updated = 0;
foreach ($products as $product) {
// Set expected image path based on SKU
$imagePath = "businesses/{$businessSlug}/brands/{$brandSlug}/products/{$product->sku}/images";
// Try to find actual image file
$actualImage = $this->findProductImage($imagePath);
if ($actualImage) {
$product->image_path = $actualImage;
$product->save();
$updated++;
$this->command->line(" - {$product->sku}: {$actualImage}");
} else {
// Set a default expected path
$expectedPath = "{$imagePath}/{$product->slug}.png";
$product->image_path = $expectedPath;
$product->save();
$updated++;
$this->command->line(" - {$product->sku}: {$expectedPath} (expected)");
}
}
$this->command->info("Updated {$updated} product media paths.");
}
/**
* Find a media file in MinIO with the given base path and name.
*/
protected function findMediaFile(string $basePath, string $name, array $extensions): ?string
{
foreach ($extensions as $ext) {
$path = "{$basePath}/{$name}.{$ext}";
if (Storage::exists($path)) {
return $path;
}
}
return null;
}
/**
* Find a product image in the given path.
*/
protected function findProductImage(string $imagePath): ?string
{
try {
$files = Storage::files($imagePath);
// Return the first image file found
foreach ($files as $file) {
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if (in_array($ext, ['png', 'jpg', 'jpeg', 'webp'])) {
return $file;
}
}
} catch (\Exception $e) {
// Directory doesn't exist
}
return null;
}
}

View File

@@ -8,110 +8,183 @@ use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* Assigns suites and plans to dev businesses for local testing.
* Gives Cannabrands all suites so all features are accessible.
* Assigns suites to businesses based on docs/SUITES_AND_PRICING_MODEL.md
*
* Run after ProductionSyncSeeder.
* Business Suite Mapping:
*
* Cannabrands (Sales & brand representation):
* - Sales Suite
* - is_enterprise_plan = true
*
* Curagreen (Processing BHO extraction):
* - Processing Suite
* - is_enterprise_plan = true
*
* Leopard AZ (Solventless + Manufacturing + Delivery):
* - Processing Suite
* - Manufacturing Suite
* - Delivery Suite
* - is_enterprise_plan = true
*
* Canopy (Parent company financial & management):
* - Management Suite
* - is_enterprise_plan = true
*
* Buyer businesses (dispensaries):
* - Dispensary Suite
*/
class DevSuitesSeeder extends Seeder
{
public function run(): void
{
$this->command->info('Assigning dev suites and plans...');
$this->command->info('Assigning suites to businesses per SUITES_AND_PRICING_MODEL.md...');
$this->assignSuitesToCannbrands();
$this->assignSuitesToOtherBusinesses();
$this->assignPlansToBusinesses();
$this->assignSuitesToCuragreen();
$this->assignSuitesToLeopardAz();
$this->assignSuitesToCanopy();
$this->assignSuitesToBuyers();
$this->command->info('Dev suites seeding complete!');
$this->command->info('Suite assignments complete!');
}
/**
* Cannabrands: Sales Suite + Enterprise Plan
*/
private function assignSuitesToCannbrands(): void
{
$cannabrands = Business::where('slug', 'cannabrands')->first();
if (! $cannabrands) {
$this->command->warn('Cannabrands business not found, skipping suite assignments.');
return;
}
// Give Cannabrands ALL active suites for full feature testing
$suites = Suite::where('is_active', true)->get();
foreach ($suites as $suite) {
DB::table('business_suite')->updateOrInsert(
['business_id' => $cannabrands->id, 'suite_id' => $suite->id],
['created_at' => now(), 'updated_at' => now()]
);
}
$this->command->line(" - Cannabrands: assigned {$suites->count()} suites (all active)");
}
private function assignSuitesToOtherBusinesses(): void
{
// Canopy gets Sales + Inventory (typical seller setup)
$this->assignSuites('canopy', ['Sales Suite', 'Inventory Suite', 'Tools Suite']);
// Curagreen gets Marketing + Sales
$this->assignSuites('curagreen', ['Sales Suite', 'Marketing Suite']);
// Leopard gets minimal setup
$this->assignSuites('leopard-az', ['Sales Suite']);
// GreenLeaf (buyer) gets Dispensary Suite
$this->assignSuites('greenleaf-dispensary', ['Dispensary Suite', 'Inbox Suite']);
}
private function assignSuites(string $businessSlug, array $suiteNames): void
{
$business = Business::where('slug', $businessSlug)->first();
$business = Business::where('slug', 'cannabrands')->first();
if (! $business) {
$this->command->warn('Cannabrands business not found, skipping.');
return;
}
$suites = Suite::whereIn('name', $suiteNames)->get();
// Assign Sales Suite
$this->assignSuitesByKey($business, ['sales']);
foreach ($suites as $suite) {
DB::table('business_suite')->updateOrInsert(
['business_id' => $business->id, 'suite_id' => $suite->id],
['created_at' => now(), 'updated_at' => now()]
);
}
// Enable Enterprise Plan (removes usage limits)
$business->update(['is_enterprise_plan' => true]);
$this->command->line(" - {$business->name}: assigned {$suites->count()} suites");
$this->command->line(' ✓ Cannabrands: Sales Suite + Enterprise Plan');
}
private function assignPlansToBusinesses(): void
/**
* Curagreen: Processing Suite + Enterprise Plan
*/
private function assignSuitesToCuragreen(): void
{
$this->command->info('Assigning plans to businesses...');
$business = Business::where('slug', 'curagreen')->first();
// Check if businesses table has plan_id column
if (! \Schema::hasColumn('businesses', 'plan_id')) {
$this->command->warn(' - businesses.plan_id column not found, skipping plan assignments.');
if (! $business) {
$this->command->warn('Curagreen business not found, skipping.');
return;
}
$plans = [
'cannabrands' => 'scale', // Full access for testing
'canopy' => 'growth', // Mid-tier
'curagreen' => 'growth', // Mid-tier
'leopard-az' => 'starter', // Basic
'greenleaf-dispensary' => 'starter',
'phoenix-cannabis-collective' => 'starter',
// Assign Processing Suite
$this->assignSuitesByKey($business, ['processing']);
// Enable Enterprise Plan
$business->update(['is_enterprise_plan' => true]);
$this->command->line(' ✓ Curagreen: Processing Suite + Enterprise Plan');
}
/**
* Leopard AZ: Processing + Manufacturing + Delivery + Enterprise Plan
*/
private function assignSuitesToLeopardAz(): void
{
$business = Business::where('slug', 'leopard-az')->first();
if (! $business) {
$this->command->warn('Leopard AZ business not found, skipping.');
return;
}
// Assign all operational suites for Leopard AZ
$this->assignSuitesByKey($business, [
'processing', // Solventless extraction
'manufacturing', // BOM, packaging, production
'delivery', // Pick/pack, manifests, drivers
]);
// Enable Enterprise Plan
$business->update(['is_enterprise_plan' => true]);
$this->command->line(' ✓ Leopard AZ: Processing + Manufacturing + Delivery + Enterprise Plan');
}
/**
* Canopy: Management Suite + Enterprise Plan
*/
private function assignSuitesToCanopy(): void
{
$business = Business::where('slug', 'canopy')->first();
if (! $business) {
$this->command->warn('Canopy business not found, skipping.');
return;
}
// Assign Management Suite (AP/AR, budgets, cross-business analytics)
$this->assignSuitesByKey($business, ['management']);
// Enable Enterprise Plan
$business->update(['is_enterprise_plan' => true]);
$this->command->line(' ✓ Canopy: Management Suite + Enterprise Plan');
}
/**
* Buyer businesses: Dispensary Suite
*/
private function assignSuitesToBuyers(): void
{
// Common buyer business slugs
$buyerSlugs = [
'greenleaf-dispensary',
'phoenix-cannabis-collective',
];
foreach ($plans as $businessSlug => $planCode) {
$business = Business::where('slug', $businessSlug)->first();
$plan = DB::table('plans')->where('code', $planCode)->first();
foreach ($buyerSlugs as $slug) {
$business = Business::where('slug', $slug)->first();
if ($business && $plan) {
$business->update(['plan_id' => $plan->id]);
$this->command->line(" - {$business->name}: {$plan->name} plan");
if (! $business) {
continue;
}
// Assign Dispensary Suite
$this->assignSuitesByKey($business, ['dispensary']);
$this->command->line("{$business->name}: Dispensary Suite");
}
}
/**
* Helper: Assign suites to a business by suite keys
*/
private function assignSuitesByKey(Business $business, array $suiteKeys): void
{
// First, remove any existing suite assignments for this business
// (to ensure clean state when re-running seeder)
DB::table('business_suite')->where('business_id', $business->id)->delete();
// Assign the specified suites
$suites = Suite::whereIn('key', $suiteKeys)->where('is_active', true)->get();
foreach ($suites as $suite) {
DB::table('business_suite')->insert([
'business_id' => $business->id,
'suite_id' => $suite->id,
'granted_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
}
}

View File

@@ -5,290 +5,348 @@ namespace Database\Seeders;
use App\Models\Suite;
use Illuminate\Database\Seeder;
/**
* Seeds the 7 core suites as defined in docs/SUITES_AND_PRICING_MODEL.md
*
* Suites:
* 1. Sales Suite - External customers (priced at $495/mo)
* 2. Processing Suite - Internal (Curagreen, Leopard AZ)
* 3. Manufacturing Suite - Internal (Leopard AZ)
* 4. Delivery Suite - Internal (Leopard AZ)
* 5. Management Suite - Internal (Canopy - parent company)
* 6. Brand Manager Suite - External brand partners (view-only)
* 7. Dispensary Suite - Buyers (dispensaries/retailers)
*
* Note: Enterprise is NOT a suite - it's a flag (is_enterprise_plan) on Business.
*/
class SuitesSeeder extends Seeder
{
public function run(): void
{
$suites = [
// Core sales and marketing
// =====================================================================
// SALES SUITE - External customers (the only priced suite)
// =====================================================================
[
'key' => 'sales',
'name' => 'Sales Suite',
'description' => 'Complete sales platform for wholesalers and brands. Includes analytics, CRM, buyer intelligence, conversations, and AI copilot.',
'description' => 'Complete sales platform for wholesalers and brands. Includes products, inventory, orders, CRM, menus, promotions, campaigns, analytics, buyer intelligence, messaging, and AI copilot.',
'icon' => 'lucide--shopping-cart',
'color' => 'emerald',
'sort_order' => 10,
'is_active' => true,
'is_internal' => false,
'included_features' => [
// Core features
'products',
'inventory',
'orders',
'invoicing',
'menus',
'promotions',
'campaigns',
'analytics',
'crm',
'buyer_intelligence',
'conversations',
'automations',
'copilot',
'orders',
'menus',
],
],
[
'key' => 'marketing',
'name' => 'Marketing Suite',
'description' => 'Marketing tools for campaigns, promotions, and customer engagement.',
'icon' => 'lucide--megaphone',
'color' => 'pink',
'sort_order' => 15,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'campaigns',
'promotions',
'templates',
'analytics',
'batches', // from supplied COAs
// Shared features (all suites get these)
'messaging',
'procurement',
'tools',
],
],
// Operations suites
[
'key' => 'inventory',
'name' => 'Inventory Suite',
'description' => 'Inventory management including stock tracking, costs, movements, and batch management.',
'icon' => 'lucide--package',
'color' => 'cyan',
'sort_order' => 20,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'products',
'stock_tracking',
'costs',
'movements',
'batches',
'components',
],
],
// =====================================================================
// PROCESSING SUITE - Internal (Curagreen, Leopard AZ)
// =====================================================================
[
'key' => 'processing',
'name' => 'Processing Suite',
'description' => 'Processing operations for extractors. Includes wash reports, extraction tracking, yields, and batch management.',
'description' => 'Processing operations for extractors. Includes wash reports, extraction tracking, yields, batches, compliance (licenses, COAs), and processing analytics.',
'icon' => 'lucide--flask-conical',
'color' => 'blue',
'sort_order' => 25,
'sort_order' => 20,
'is_active' => true,
'is_internal' => false,
'is_internal' => true,
'included_features' => [
// Core features
'wash_reports',
'extraction',
'yields',
'batches',
'biomass_intake',
'material_transfers',
'work_orders',
'processing_analytics',
'compliance',
'licenses',
'coas',
// Shared features
'messaging',
'procurement',
'tools',
],
],
// =====================================================================
// MANUFACTURING SUITE - Internal (Leopard AZ)
// =====================================================================
[
'key' => 'manufacturing',
'name' => 'Manufacturing Suite',
'description' => 'Manufacturing operations for producers. Includes work orders, BOMs, packaging, and production management.',
'description' => 'Manufacturing operations for producers. Includes work orders, BOM (Bill of Materials), packaging, labeling, SKU creation, lot tracking, compliance, and production management.',
'icon' => 'lucide--factory',
'color' => 'orange',
'sort_order' => 30,
'is_active' => true,
'is_internal' => false,
'is_internal' => true,
'included_features' => [
// Core features
'work_orders',
'bom',
'bom', // BOM is Manufacturing only
'packaging',
'production',
'labeling',
'sku_creation',
'lot_tracking',
'production_queue',
'manufacturing_analytics',
'compliance',
'licenses',
'coas',
'batches',
// Shared features
'messaging',
'procurement',
'tools',
],
],
// =====================================================================
// DELIVERY SUITE - Internal (Leopard AZ)
// =====================================================================
[
'key' => 'procurement',
'name' => 'Procurement Suite',
'description' => 'Purchasing and vendor management. Includes requisitions, purchase orders, and goods receiving.',
'icon' => 'lucide--clipboard-list',
'color' => 'indigo',
'sort_order' => 35,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'requisitions',
'purchase_orders',
'receiving',
'vendors',
],
],
[
'key' => 'distribution',
'name' => 'Distribution Suite',
'description' => 'Distribution and fulfillment operations. Includes pick/pack, manifests, routes, and proof of delivery.',
'key' => 'delivery',
'name' => 'Delivery Suite',
'description' => 'Delivery and fulfillment operations. Includes pick/pack, manifests, delivery windows, drivers, vehicles, routes, proof of delivery, and delivery analytics.',
'icon' => 'lucide--truck',
'color' => 'violet',
'sort_order' => 40,
'is_active' => true,
'is_internal' => false,
'is_internal' => true,
'included_features' => [
// Core features
'pick_pack',
'manifests',
'routes',
'proof_of_delivery',
'delivery_analytics',
],
],
[
'key' => 'delivery',
'name' => 'Delivery Suite',
'description' => 'Delivery management for order fulfillment. Includes delivery windows, drivers, and vehicles.',
'icon' => 'lucide--calendar-clock',
'color' => 'teal',
'sort_order' => 45,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'delivery_windows',
'drivers',
'vehicles',
'routes',
'proof_of_delivery',
'delivery_analytics',
// Shared features
'messaging',
'procurement',
'tools',
],
],
// Finance and compliance
// =====================================================================
// MANAGEMENT SUITE - Internal (Canopy - parent company)
// =====================================================================
[
'key' => 'finance',
'name' => 'Finance Suite',
'description' => 'Financial management including AP, AR, invoicing, and reporting.',
'icon' => 'lucide--banknote',
'color' => 'green',
'key' => 'management',
'name' => 'Management Suite',
'description' => 'Corporate management and financial oversight for Canopy. Includes AP/AR, budgets, cross-business analytics, inter-company ledger, financial reports, forecasting, and KPIs.',
'icon' => 'lucide--briefcase',
'color' => 'slate',
'sort_order' => 50,
'is_active' => true,
'is_internal' => false,
'is_internal' => true,
'included_features' => [
// Core features
'org_dashboard',
'cross_business_analytics',
'accounts_payable',
'accounts_receivable',
'invoicing',
'reports',
'budgets',
],
],
[
'key' => 'compliance',
'name' => 'Compliance Suite',
'description' => 'Regulatory compliance tools including licenses, COAs, and compliance manifests.',
'icon' => 'lucide--shield-check',
'color' => 'amber',
'sort_order' => 55,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'licenses',
'coas',
'manifests',
'reports',
'budget_approvals',
'inter_company_ledger',
'financial_reports',
'forecasting',
'kpis',
'usage_billing',
// Shared features
'messaging',
'tools',
],
],
// Communication and tools
// =====================================================================
// BRAND MANAGER SUITE - External brand partners (view-only)
// =====================================================================
[
'key' => 'inbox',
'name' => 'Inbox Suite',
'description' => 'Unified messaging and communication tools.',
'icon' => 'lucide--mail',
'color' => 'sky',
'key' => 'brand_manager',
'name' => 'Brand Manager Suite',
'description' => 'View-only portal for external brand teams. Access to assigned brand sales, orders, products, inventory, promotions, conversations, and analytics. All data auto-scoped by brand_id.',
'icon' => 'lucide--user-check',
'color' => 'teal',
'sort_order' => 60,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'messages',
'contacts',
'templates',
],
],
[
'key' => 'tools',
'name' => 'Tools Suite',
'description' => 'Business administration tools including settings, users, departments, and integrations.',
'icon' => 'lucide--settings',
'color' => 'slate',
'sort_order' => 65,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'settings',
'users',
'departments',
'integrations',
'audit_log',
],
],
// Management and enterprise
[
'key' => 'management',
'name' => 'Management Suite',
'description' => 'Corporate management and financial oversight. Includes cross-business analytics, finance, and KPIs.',
'icon' => 'lucide--briefcase',
'color' => 'gray',
'sort_order' => 70,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'cross_business_analytics',
'finance',
'forecasting',
'kpis',
],
],
[
'key' => 'dispensary',
'name' => 'Dispensary Suite',
'description' => 'Retail and dispensary operations. Marketplace access, ordering, and buyer portal.',
'icon' => 'lucide--store',
'color' => 'lime',
'sort_order' => 80,
'is_active' => true,
'is_internal' => false,
'included_features' => [
'marketplace',
'ordering',
'buyer_portal',
'promotions',
],
],
// Brand Manager Suite - External brand team access
[
'key' => 'brand_manager',
'name' => 'Brand Manager',
'description' => 'View-only portal for external brand teams. Access to assigned brand sales, products, inventory, promotions, and conversations. Auto-scoped by brand_id.',
'icon' => 'lucide--user-check',
'color' => 'teal',
'sort_order' => 85,
'is_active' => true,
'is_internal' => false,
'included_features' => [
// Core features (all read-only, brand-scoped)
'view_sales',
'view_orders',
'view_products',
'view_inventory',
'view_promotions',
'view_conversations',
'view_buyers',
'view_analytics',
'brand_scoped', // Flag indicating all data is brand-scoped
// Communication only
'messaging', // Their brand only
// Explicitly NOT available: view_costs, view_margin, edit_*, manage_*
],
],
// Note: Enterprise is deprecated as a suite - it's now a plan limit override.
// Use is_enterprise_plan on Business model instead.
// This entry is kept for reference but marked as deprecated and inactive.
// =====================================================================
// DISPENSARY SUITE - Buyers (dispensaries/retailers)
// =====================================================================
[
'key' => 'dispensary',
'name' => 'Dispensary Suite',
'description' => 'For buyer businesses (dispensaries, retailers). Includes marketplace access, ordering, buyer portal, and promotions.',
'icon' => 'lucide--store',
'color' => 'lime',
'sort_order' => 70,
'is_active' => true,
'is_internal' => false,
'included_features' => [
// Core features
'marketplace',
'ordering',
'buyer_portal',
'promotions',
'favorites',
// Shared features
'messaging',
'tools',
],
],
// =====================================================================
// DEPRECATED SUITES - Marked inactive, kept for reference
// =====================================================================
[
'key' => 'marketing',
'name' => 'Marketing Suite (DEPRECATED)',
'description' => 'DEPRECATED: Merged into Sales Suite. Marketing features are now part of Sales.',
'icon' => 'lucide--megaphone',
'color' => 'pink',
'sort_order' => 900,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'inventory',
'name' => 'Inventory Suite (DEPRECATED)',
'description' => 'DEPRECATED: Merged into Sales Suite. Inventory features are now part of Sales.',
'icon' => 'lucide--package',
'color' => 'cyan',
'sort_order' => 901,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'procurement',
'name' => 'Procurement Suite (DEPRECATED)',
'description' => 'DEPRECATED: Procurement is now a shared feature available to all operational suites.',
'icon' => 'lucide--clipboard-list',
'color' => 'indigo',
'sort_order' => 902,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'distribution',
'name' => 'Distribution Suite (DEPRECATED)',
'description' => 'DEPRECATED: Merged into Delivery Suite.',
'icon' => 'lucide--truck',
'color' => 'violet',
'sort_order' => 903,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'finance',
'name' => 'Finance Suite (DEPRECATED)',
'description' => 'DEPRECATED: Merged into Management Suite. Finance features are now part of Management (Canopy).',
'icon' => 'lucide--banknote',
'color' => 'green',
'sort_order' => 904,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'compliance',
'name' => 'Compliance Suite (DEPRECATED)',
'description' => 'DEPRECATED: Compliance features moved to Processing and Manufacturing suites.',
'icon' => 'lucide--shield-check',
'color' => 'amber',
'sort_order' => 905,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'inbox',
'name' => 'Inbox Suite (DEPRECATED)',
'description' => 'DEPRECATED: Messaging is now a shared feature available to all suites.',
'icon' => 'lucide--mail',
'color' => 'sky',
'sort_order' => 906,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'tools',
'name' => 'Tools Suite (DEPRECATED)',
'description' => 'DEPRECATED: Tools (settings, users, departments) is now a shared feature available to all suites.',
'icon' => 'lucide--settings',
'color' => 'slate',
'sort_order' => 907,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
[
'key' => 'enterprise',
'name' => 'Enterprise Suite (DEPRECATED)',
'description' => 'DEPRECATED: Enterprise is now a plan override (is_enterprise_plan), not a suite. Usage limits are disabled for Enterprise businesses, but feature access is still controlled by suites and departments.',
'description' => 'DEPRECATED: Enterprise is now a plan override flag (is_enterprise_plan) on Business, not a suite.',
'icon' => 'lucide--crown',
'color' => 'gold',
'sort_order' => 100,
'is_active' => false, // Disabled - use is_enterprise_plan instead
'sort_order' => 999,
'is_active' => false,
'is_internal' => true,
'is_deprecated' => true, // Marked as deprecated
'included_features' => [
'deprecated',
],
'is_deprecated' => true,
'included_features' => ['deprecated'],
],
];
@@ -298,5 +356,7 @@ class SuitesSeeder extends Seeder
$suite
);
}
$this->command->info('Suites seeded: 7 active, 9 deprecated');
}
}

View File

@@ -0,0 +1,549 @@
# Suites & Pricing Model Architecture Overview
## 1. Purpose
This document defines:
- The **Suite architecture** (7 Suites total)
- How each business (Cannabrands, Curagreen, Leopard AZ, Canopy) is mapped to Suites
- How menus/navigation should eventually be suite-driven
- The **pricing and usage model** for the Sales Suite
- **Procurement flow** between subdivisions and Canopy
- **Cross-division access** for users
- Ground rules to prevent breaking existing behavior while we evolve the system
**This is the ground truth for implementation and future design.**
---
## 2. Corporate Structure
Canopy is the parent management company. Cannabrands, Curagreen, and Leopard AZ are subdivisions.
```
┌─────────────────┐
│ CANOPY │
│ (Parent Co) │
│ │
│ • AP/AR │
│ • Finance │
│ • Budgets │
│ • Approvals │
└────────┬────────┘
│ owns
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Curagreen │ │ Leopard AZ │ │ Cannabrands │
│ (Processing) │ │ (Mfg/Deliv) │ │ (Sales) │
│ │ │ │ │ │
│ Operations │ │ Operations │ │ Operations │
│ only │ │ only │ │ only │
└───────────────┘ └───────────────┘ └───────────────┘
```
**Key principle:** All money flows through Canopy. Subdivisions handle operations; Canopy handles finance.
---
## 3. Suites Overview (7 Total)
We model functionality in high-level **Suites**, not scattered feature flags.
| # | Suite | Target User | Priced? |
|---|-------|-------------|---------|
| 1 | **Sales Suite** | External customers (Brands) | Yes - $495/mo |
| 2 | **Processing Suite** | Internal (Curagreen, Leopard AZ) | No |
| 3 | **Manufacturing Suite** | Internal (Leopard AZ) | No |
| 4 | **Delivery Suite** | Internal (Leopard AZ) | No |
| 5 | **Management Suite** | Internal (Canopy) | No |
| 6 | **Brand Manager Suite** | External (brand partners) | No |
| 7 | **Dispensary Suite** | Buyers (dispensaries/retailers) | No |
**Note:** Enterprise Plan is NOT a suite - it's a flag (`is_enterprise_plan`) that removes usage limits.
### 3.1 Sales Suite (Usage-Tiered, Sold to Customers)
The commercial brain for brands and sales orgs.
**Core Features:**
- Products & Inventory
- Orders & Invoicing
- Menus & Promotions
- Campaigns & Marketing
- Buyers & Accounts (CRM)
- Buyer Intelligence
- Analytics
- Automations & Orchestrator
- Copilot / AI Assist
- Batches (from supplied COAs)
**Shared Features (all suites get these):**
- Messaging (omnichannel)
- Procurement (vendors, requisitions, POs, receiving)
- Tools (settings, users, departments, integrations)
**Only the Sales Suite is priced and usage-tiered.**
### 3.2 Processing Suite (Internal / Enterprise Only)
Used by processing entities (e.g., Curagreen, Leopard AZ solventless).
**Core Features:**
- Biomass intake
- Batches and runs
- BHO extraction (Curagreen)
- Solventless extraction washing & pressing (Leopard AZ)
- Yield tracking & conversions
- Material transfers between entities
- Work Orders from Sales → Processing
- Processing analytics
- Compliance (licenses, COAs)
**Shared Features:**
- Messaging
- Procurement
- Tools
### 3.3 Manufacturing Suite (Internal / Enterprise Only)
Used by manufacturing entities (e.g., Leopard AZ packaging & production).
**Core Features:**
- Work Orders from Sales/Processing
- BOM (Bill of Materials) - **Manufacturing only**
- Packaging & labeling
- SKU creation from batches
- Lot tracking
- Production queues & status
- Manufacturing analytics
- Compliance (licenses, COAs)
- Batches
**Shared Features:**
- Messaging
- Procurement
- Tools
### 3.4 Delivery Suite (Internal / Enterprise Only)
Used by delivery/fulfillment operations (e.g., Leopard AZ).
**Core Features:**
- Pick/Pack screens
- Delivery manifests
- Delivery windows
- Drivers & vehicles
- Route management
- Proof of delivery (POD)
- Delivery analytics
**Shared Features:**
- Messaging
- Procurement
- Tools
### 3.5 Management Suite (Internal / Canopy Only)
Used by top-level management (Canopy as parent company).
**Core Features:**
- Org-wide dashboard
- Cross-business analytics
- AP (Accounts Payable) - all vendor bills, PO payments
- AR (Accounts Receivable) - all customer invoices, collections
- Budget Management - set budgets per subdivision
- Budget Exception Approvals - approve POs that exceed budget
- Inter-company Ledger - track transfers between subdivisions
- Financial Reports - P&L, balance sheet, cash flow
- Forecasting
- Operational KPIs
- Usage & billing analytics
**Shared Features:**
- Messaging
- Tools
### 3.6 Brand Manager Suite (External Partners)
View-only portal for external brand teams to see their brand performance.
**Core Features (all read-only, brand-scoped):**
- View sales history
- View orders
- View products
- View inventory status
- View promotions
- View conversations
- View buyer accounts
- View brand-level analytics
**Communication:**
- Messaging (their brand only - can send/receive)
**Explicitly NOT available:**
- View costs, margins, wholesale pricing
- Edit products, create promotions
- Manage settings
- View other brands
### 3.7 Dispensary Suite (Buyers)
For buyer businesses (dispensaries, retailers) accessing the marketplace.
**Core Features:**
- Marketplace access
- Ordering
- Buyer portal
- Promotions (view/redeem)
**Shared Features:**
- Messaging
- Tools
---
## 4. Shared Features (All Suites)
These features are available to ALL operational suites:
### 4.1 Messaging
All suites get omnichannel messaging capabilities.
### 4.2 Procurement
| Feature | Description |
|---------|-------------|
| Vendors & Suppliers | Each subdivision manages their own |
| Requisitions | Request purchases |
| Purchase Orders | Create POs |
| Goods Receiving | Confirm deliveries |
### 4.3 Tools
| Feature | Description |
|---------|-------------|
| Settings | Business settings |
| Users | User management |
| Departments | Department structure |
| Integrations | Third-party integrations |
| Audit Log | Activity tracking |
---
## 5. Procurement & Finance Flow
### 5.1 Who Can Do What
| Role | Create Requisitions | Create POs | Approve Requisitions | Pay (via Canopy) |
|------|---------------------|------------|----------------------|------------------|
| Department Managers | ✅ | ✅ | ✅ (their dept) | ❌ |
| Owners/Admins | ✅ | ✅ | ✅ (all) | ❌ |
| Canopy Finance | ✅ | ✅ | ✅ (budget exceptions) | ✅ |
### 5.2 Flow
**Department Manager or Owner/Admin:**
```
Creates REQUISITION or PO directly
└─▶ Canopy AP pays when invoiced
```
**Budget Exception (PO exceeds subdivision budget):**
```
PO created
└─▶ Flagged as over budget
└─▶ Canopy must approve
└─▶ Canopy AP pays
```
### 5.3 Canopy's Role
Canopy **doesn't approve routine POs** - they just handle the money:
| Feature | Description |
|---------|-------------|
| AP Processing | Pay vendor invoices matched to POs |
| AR Collections | Collect customer payments |
| Budget Management | Set budgets per subdivision |
| Budget Exception Approvals | Only intervene when PO exceeds budget |
| Financial Reporting | Consolidated view across all subdivisions |
---
## 6. Cross-Division Access
Users can access multiple businesses via department assignments.
### 6.1 How It Works
1. **Add user to another business** via `business_user` pivot
2. **Assign them to a department** in that business
3. **Department has suite permissions** that control what they can do
### 6.2 Example
```
Sarah (User)
├── Business: Cannabrands (primary)
│ └── Department: Sales Team
│ └── Suite: Sales (full permissions)
└── Business: Leopard AZ (cross-division)
└── Department: Manufacturing
└── Suite: Manufacturing (manage_bom only)
```
### 6.3 Permission Hierarchy
```
User Permission =
Business Role (owner gets all)
+ Department Permissions (inherited from department's suite permissions)
+ Individual Overrides (grants/revokes specific to user)
```
| Role | Access Level |
|------|--------------|
| **Owner** | Full access to everything |
| **Admin** | Full access except some owner-only actions |
| **Manager** | Department permissions + can manage team |
| **Member** | Department permissions only |
---
## 7. Business → Suite Mapping
### 7.1 Cannabrands
- **Role:** Sales & brand representation
- **Suites:** Sales Suite
- **Plan:** Enterprise (internal)
### 7.2 Curagreen
- **Role:** Processing BHO extraction
- **Suites:** Processing Suite
- **Plan:** Enterprise (internal)
### 7.3 Leopard AZ
- **Role:** Solventless processing + Manufacturing + Delivery
- **Suites:**
- Processing Suite (solventless)
- Manufacturing Suite
- Delivery Suite
- **Plan:** Enterprise (internal)
### 7.4 Canopy
- **Role:** Parent company - financial & management oversight
- **Suites:** Management Suite
- **Plan:** Enterprise (internal)
---
## 8. Suite-Driven Navigation (Target State)
When a user logs in:
1. We determine their **Business**
2. We read the **Suites** assigned to that Business
3. We check user's **Department permissions** for each suite
4. We build the sidebar/navigation based on accessible features
### 8.1 Sales Suite Menu
- Dashboard
- Brands
- Products & Inventory
- Orders
- Menus
- Promotions
- Buyers & Accounts (CRM)
- Conversations
- Campaigns
- Automations
- Copilot
- Analytics
- Settings
### 8.2 Processing Suite Menu
- Dashboard
- Batches / Runs
- Biomass Intake
- Washing / Pressing (solventless)
- Extraction Runs (BHO)
- Yields
- Material Transfers
- Work Orders
- Compliance (Licenses, COAs)
- Processing Analytics
- Settings
### 8.3 Manufacturing Suite Menu
- Dashboard
- Work Orders
- BOM
- Packaging
- Labeling
- SKU Creation
- Lot Tracking
- Production Queue
- Compliance (Licenses, COAs)
- Manufacturing Analytics
- Settings
### 8.4 Delivery Suite Menu
- Dashboard
- Pick & Pack
- Delivery Windows
- Manifests
- Drivers & Vehicles
- Routes
- Proof of Delivery
- Delivery Analytics
- Settings
### 8.5 Management Suite Menu (Canopy)
- Org Dashboard
- Cross-Business Analytics
- Finance
- Accounts Payable
- Accounts Receivable
- Budgets
- Inter-company Ledger
- Forecasting
- Operations Overview
- Usage & Billing
- Settings
### 8.6 Brand Manager Menu
- Brand Dashboard
- Sales History
- Orders
- Products
- Inventory
- Promotions
- Conversations
- Analytics
### 8.7 Dispensary Suite Menu
- Marketplace
- My Orders
- Favorites
- Promotions
- Messages
- Settings
---
## 9. Sales Suite Pricing & Usage Model
### 9.1 Pricing
**Base Sales Suite Plan:**
- **$495 / month**
- Includes **1 brand**
**Additional Brands:**
- **$195 / month** per additional brand
- Each additional brand comes with its own usage allotment
**No public free tier.** Enterprise internal plan is not sold.
### 9.2 Included Features (Per Brand)
Each brand under the Sales Suite gets:
- Full Inventory & Product Management
- Menus & Promotions
- Buyers & Accounts (CRM)
- Conversations & Messaging
- Marketing & Campaign Tools
- Buyer Intelligence
- Analytics (unlimited views)
- Automations & Orchestrator
- Copilot (AI-assisted workflows)
### 9.3 Usage Limits (Per Brand) Initial Targets
| Resource | Limit |
|----------|-------|
| **SKUs** | 15 SKUs per brand |
| **Menu Sends** | 100 menu sends per month |
| **Promotion Impressions** | 1,000 promotion impressions per month |
| **Messaging** | 500 messages per month (SMS, email, in-app, WhatsApp combined) |
| **AI Credits** | 1,000 AI credits per brand per month |
| **Buyer & CRM Contacts** | 1,000 buyers/contacts per brand |
| **Analytics** | Unlimited analytics views |
### 9.4 Add-ons (Future)
- Extra SKU packs (+10, +25, +100)
- Extra menu send packs
- Extra promotion impression packs
- Extra CRM contact packs
- Extra AI credit packs
- Extra messaging volume
---
## 10. Enterprise Plan
**Enterprise is NOT a Suite** - it's a billing/limit override.
Enterprise Plan (`is_enterprise_plan = true`) means:
- **Usage limits are bypassed** (brands, SKUs, contacts, messages, AI credits, etc.)
- **Feature access is still controlled by assigned Suites**
A business with Enterprise Plan still needs actual Suites assigned to determine which features/menus are available.
**Used only for internal operations (Cannabrands, Curagreen, Leopard AZ, Canopy).**
---
## 11. Safety & Backward Compatibility
1. **Existing navigation and behavior must be preserved** until suite-based nav is explicitly enabled.
2. **Changes should be additive:**
- New DB columns for Suites and usage
- New services for Suite→Menu and usage tracking
- Config flag `app.suites.enabled`
3. **Migrations must be reversible.**
4. **Feature-flag toggles** allow:
- Switching between current menu and suite-driven menu
- Disabling suite enforcement if issues arise
5. **Legacy module flags** (`has_crm`, `has_marketing`, etc.) remain in "Advanced Overrides" admin area until fully migrated.
---
## 12. Implementation Phases
| Phase | Description |
|-------|-------------|
| **Phase 1** | ✅ Suites & Docs Define Suites, business mapping, and pricing |
| **Phase 2** | Suite consolidation Reduce to 7 suites, update seeder and permissions |
| **Phase 3** | User Management UI Settings > Users with full permission control |
| **Phase 4** | Suite-Driven Menu Resolver Implement Suite→Menu mapping behind flag |
| **Phase 5** | Usage Counters Track menu sends, messages, AI usage, contacts, SKUs |
| **Phase 6** | Usage Enforcement & Warnings Soft and hard limits, usage dashboards |
| **Phase 7** | Public Pricing Site Marketing-facing pricing components |
---
**This document is the canonical reference for suite architecture.**

162
docs/USER_MANAGEMENT.md Normal file
View File

@@ -0,0 +1,162 @@
# User Management System
## Overview
The user management system in Settings > Users allows business owners and admins to manage team members, their roles, department assignments, and permissions.
## Role Hierarchy
Users are assigned one of four roles that determine their base level of access:
| Role | Access Level | Description |
|------|--------------|-------------|
| **Owner** | Full | Complete access to everything. Cannot be modified. |
| **Admin** | Nearly Full | Full access except owner-only actions (transfer ownership, delete business) |
| **Manager** | Department + Team | Department permissions plus ability to manage team members and approve requests |
| **Member** | Department Only | Standard team member with department-based permissions only |
## Permission System
Permissions are determined by a combination of:
1. **Business Role** - Owner/Admin gets full access
2. **Department Membership** - Controls access to suite features
3. **Department Role** - Determines level within department (operator, lead, supervisor, manager)
4. **Permission Overrides** - Individual grants/revokes for specific users
### Permission Calculation
```
Effective Permissions =
Business Role Permissions
+ Department Suite Permissions (based on membership)
+ Individual Permission Overrides
```
## Department Roles
Within each department, users can have one of four roles:
| Department Role | Description |
|-----------------|-------------|
| **Operator** | Basic access - can view and perform assigned tasks |
| **Lead** | Can assign work to other operators |
| **Supervisor** | Can approve requests and escalations |
| **Manager** | Full department access - can manage all department functions |
## Suite-Based Permissions
Permissions are organized by the suites assigned to the business. Each suite has feature-specific permissions grouped by functional area:
### Sales Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics, export_analytics
- **Products & Inventory**: view_products, manage_products, view_inventory, adjust_inventory, view_costs, view_margin
- **Orders & Invoicing**: view_orders, create_orders, manage_orders, view_invoices, create_invoices
- **Menus & Promotions**: view_menus, manage_menus, view_promotions, manage_promotions
- **Campaigns & Marketing**: view_campaigns, manage_campaigns, send_campaigns, manage_templates
- **CRM & Accounts**: view_pipeline, edit_pipeline, manage_accounts, view_buyer_intelligence
- **Automations & AI**: view_automations, manage_automations, use_copilot
- **Batches**: view_batches, manage_batches
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Procurement**: view_vendors, manage_vendors, view_requisitions, create_requisitions, approve_requisitions, view_purchase_orders, create_purchase_orders, receive_goods
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log, manage_integrations
### Processing Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics
- **Batches**: view_batches, manage_batches, create_batches
- **Processing Operations**: view_wash_reports, create_wash_reports, edit_wash_reports, manage_extractions, view_yields, manage_biomass_intake, manage_material_transfers
- **Work Orders**: view_work_orders, create_work_orders, manage_work_orders
- **Compliance**: view_compliance, manage_licenses, manage_coas, view_compliance_reports
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Procurement**: (same as Sales)
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log
### Manufacturing Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics
- **Manufacturing**: view_bom, manage_bom, create_bom, view_production_queue, manage_production, manage_packaging, manage_labeling, create_skus, manage_lot_tracking
- **Work Orders**: view_work_orders, create_work_orders, manage_work_orders
- **Batches**: view_batches, manage_batches
- **Compliance**: view_compliance, manage_licenses, manage_coas, view_compliance_reports
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Procurement**: (same as Sales)
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log
### Delivery Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics
- **Delivery & Fulfillment**: view_pick_pack, manage_pick_pack, view_manifests, create_manifests, view_delivery_windows, manage_delivery_windows, view_drivers, manage_drivers, view_vehicles, manage_vehicles, view_routes, manage_routing, view_deliveries, complete_deliveries, manage_proof_of_delivery
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Procurement**: (same as Sales)
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log
### Management Suite Permissions (Canopy)
- **Dashboard & Analytics**: view_org_dashboard, view_cross_business, view_all_analytics
- **Finance & Budgets**: view_ap, manage_ap, pay_bills, view_ar, manage_ar, view_budgets, manage_budgets, approve_budget_exceptions, view_inter_company_ledger, manage_inter_company, view_financial_reports, export_financial_data, view_forecasting, manage_forecasting, view_kpis, view_usage_billing, manage_billing
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Tools & Settings**: manage_settings, manage_users, manage_departments, view_audit_log, manage_integrations
### Brand Manager Suite Permissions
- **Dashboard & Analytics**: view_dashboard, view_analytics
- **Brand Data**: view_sales, view_orders, view_products, view_inventory, view_promotions, view_conversations, view_buyers
- **Messaging**: send_messages (brand-scoped)
### Dispensary Suite Permissions
- **Marketplace**: view_marketplace, browse_products, view_promotions, manage_cart, manage_favorites
- **Orders**: create_orders, view_orders
- **Buyer Portal**: view_buyer_portal, view_account
- **Messaging**: view_conversations, send_messages, manage_contacts
- **Tools & Settings**: manage_settings, manage_users, view_audit_log
## UI Components
### Users List Page (`/s/{business}/settings/users`)
- **Owner Card**: Shows business owner with full access badge
- **Search & Filters**: Search by name/email, filter by role, department, and last login date
- **Users Table**: Shows user info, role badge, department badges, last login
- **Add User Modal**: Create new users with role and department assignment
### Edit User Page (`/s/{business}/settings/users/{user}/edit`)
- **User Profile Card**: Avatar, name, email, position
- **Basic Information**: Position, company, contact type
- **Role Assignment**: Radio buttons for member/manager/admin
- **Department Assignments**: Checkboxes with department role selection
- **Permission Overrides**: Tabbed interface by suite, grouped by functional area
## Cross-Division Access
Users can belong to multiple businesses via the `business_user` pivot table. Each business relationship has its own:
- Role (owner, admin, manager, member)
- Contact type (primary, billing, technical)
- Permission overrides
Department assignments are per-business and control suite feature access.
## Implementation Notes
### Database Tables
- `business_user` - Pivot with role, contact_type, permissions columns
- `department_user` - Pivot with department role
- `business_suite` - Links businesses to their assigned suites
- `department_suite_permissions` - Granular permissions per department/suite
### Controller Methods
- `SettingsController::users()` - List users with filters
- `SettingsController::editUser()` - Show edit form with suite permissions
- `SettingsController::updateUser()` - Save role, departments, and permissions
- `SettingsController::inviteUser()` - Create new user with role and departments
- `SettingsController::removeUser()` - Remove user from business
### Permission Checking
Use the `DepartmentSuitePermission::SUITE_PERMISSIONS` constant to get available permissions for each suite. Permissions are stored as a JSON array in the `business_user.permissions` column for individual overrides.

View File

@@ -1,309 +0,0 @@
# Suites & Pricing Model Architecture Overview
## 1. Purpose
This document defines:
- The **Suite architecture** (Sales, Processing, Manufacturing, Delivery, Management, Enterprise).
- How each business (Cannabrands, Curagreen, Leopard AZ, Canopy) is mapped to Suites.
- How menus/navigation should eventually be suite-driven.
- The **pricing and usage model** for the Sales Suite.
- Ground rules to prevent breaking existing behavior while we evolve the system.
**This is the ground truth for implementation and future design.**
---
## 2. Suites Overview
We model functionality in high-level **Suites**, not scattered feature flags.
### 2.1 Sales Suite (Usage-Tiered, Sold to Customers)
The commercial brain for brands and sales orgs. Includes:
- Inventory & Product Catalog
- Basic BOM & Assemblies (product component tracking for B2B marketplace operations)
- Menus & Promotions
- Buyers & Accounts (CRM)
- Conversations & Messaging (omnichannel)
- Marketing & Campaigns
- Buyer Intelligence
- Analytics
- Automations & Orchestrator (sales-side)
- Copilot / AI Assist
- Brand Dashboards & Business Settings
**Only the Sales Suite is priced and usage-tiered.**
### 2.2 Processing Suite (Internal / Enterprise Only)
Used by processing entities (e.g., Curagreen, Leopard AZ solventless).
Includes:
- Biomass intake
- Batches and runs
- BHO extraction (Curagreen)
- Solventless extraction washing & pressing (Leopard AZ)
- Yield tracking & conversions
- Material transfers between entities
- Work Orders from Sales → Processing
- Processing analytics
### 2.3 Manufacturing Suite (Internal / Enterprise Only)
Used by manufacturing entities (e.g., Leopard AZ packaging & production).
Includes:
- Work Orders from Sales/Processing
- BOM (Bill of Materials)
- Packaging & labeling
- SKU creation from batches
- Lot tracking
- Production queues & status
- Manufacturing analytics
### 2.4 Delivery Suite (Internal / Enterprise Only)
Used by delivery/fulfillment operations (e.g., Leopard AZ).
Includes:
- Pick/Pack screens
- Delivery manifests
- Driver / route dashboards
- Proof of delivery (POD)
- Delivery analytics
### 2.5 Management Suite (Internal / Enterprise Only)
Used by top-level management (e.g., Canopy).
Includes:
- Org-wide dashboard
- Cross-business analytics
- Finance / AR / AP views
- Forecasting
- Operational KPIs
- Usage & billing analytics
### 2.6 Enterprise Plan (Internal Only)
**Note:** Enterprise is NOT a Suite - it's a plan/billing override.
Enterprise Plan means:
- **Usage limits are bypassed** (brands, SKUs, contacts, messages, AI credits, etc.)
- **Feature access is still controlled by assigned Suites** (Sales, Processing, Manufacturing, etc.)
A business with Enterprise Plan enabled (`is_enterprise_plan = true`) still needs actual Suites assigned to determine which features/menus are available. Enterprise only removes the usage caps.
**This is not sold; used only for internal operations where usage limits shouldn't apply.**
---
## 3. Business → Suite Mapping
### 3.1 Cannabrands
- **Role:** Sales & brand representation
- **Suites:**
- Sales Suite (full)
- Enterprise Sales access internally (for our own brands)
### 3.2 Curagreen
- **Role:** Processing BHO
- **Suites:**
- Processing Suite
### 3.3 Leopard AZ
- **Role:** Solventless processing + Manufacturing + Delivery
- **Suites:**
- Processing Suite (solventless)
- Manufacturing Suite
- Delivery Suite
### 3.4 Canopy
- **Role:** Financial & management entity
- **Suites:**
- Management Suite
### 3.5 Internal Master / Owner Account
- **Role:** Internal full control
- **Plan:** Enterprise Plan (`is_enterprise_plan = true`)
- **Suites:** All operational suites as needed (Sales, Processing, Manufacturing, etc.)
- **Note:** Enterprise Plan bypasses usage limits; actual feature access controlled by suite assignments
---
## 4. Suite-Driven Navigation (Target State)
### 4.1 Concept
When a user logs in:
1. We determine their **Business**.
2. We read the **Suites** assigned to that Business.
3. We build the sidebar/top navigation based on Suites, not random feature flags.
We eventually want per-suite menus like:
#### Sales Suite Menu (for Cannabrands)
- Dashboard
- Brands
- Inventory
- Menus
- Promotions
- Buyers & Accounts (CRM)
- Conversations
- Messaging
- Automations
- Copilot
- Analytics
- Settings
#### Processing Suite Menu (Curagreen / Leopard AZ Processing)
- Dashboard
- Batches / Runs
- Biomass Intake
- Washing / Pressing (solventless)
- Extraction Runs (BHO)
- Yields
- Material Transfers
- Work Orders
- Processing Analytics
- Settings
#### Manufacturing Suite Menu (Leopard AZ)
- Dashboard
- Work Orders
- BOM
- Packaging
- Labeling
- SKU Creation
- Lot Tracking
- Production Queue
- Manufacturing Analytics
- Settings
#### Delivery Suite Menu (Leopard AZ)
- Dashboard
- Pick & Pack
- Delivery Manifests
- Driver / Route View
- Proof of Delivery
- Delivery Analytics
- Settings
#### Management Suite Menu (Canopy)
- Org Dashboard
- Finance / AR / AP
- Cross-Business Analytics
- Forecasting
- Operations Overview
- Usage & Billing
- Settings
**Important:** While we move toward this, the existing menu remains the default. New suite-driven menus must be gated behind a config flag (e.g., `app.suites.enabled`) and can be turned off if needed.
---
## 5. Sales Suite Pricing & Usage Model
### 5.1 Pricing
**Base Sales Suite Plan:**
- **$495 / month**
- Includes **1 brand**
**Additional Brands:**
- **$195 / month** per additional brand
- Each additional brand comes with its own usage allotment (below).
**No public free tier.** Enterprise internal plan is not sold.
### 5.2 Included Features (Per Brand)
Each brand under the Sales Suite gets:
- Full Inventory & Product Management
- Menus & Promotions
- Buyers & Accounts (CRM)
- Conversations & Messaging
- Marketing & Campaign Tools
- Buyer Intelligence
- Analytics (unlimited views)
- Automations & Orchestrator (sales-side)
- Copilot (AI-assisted workflows)
### 5.3 Usage Limits (Per Brand) Initial Targets
| Resource | Limit |
|----------|-------|
| **SKUs** | 15 SKUs per brand |
| **Menu Sends** | 100 menu sends per month |
| **Promotion Impressions** | 1,000 promotion impressions per month |
| **Messaging** | 500 messages per month (SMS, email, in-app, WhatsApp combined) |
| **AI Credits** | Initial placeholder: 1,000 AI credits per brand per month. Final number to be adjusted based on AI provider cost and token usage analysis. |
| **Buyer & CRM Contacts** | 1,000 buyers/contacts per brand |
| **Analytics** | Unlimited analytics views. Not usage-gated. |
### 5.4 Add-ons (Concept)
Add-on packs (for later implementation):
- Extra SKU packs (e.g., +10, +25, +100)
- Extra menu send packs
- Extra promotion impression packs
- Extra CRM contact packs
- Extra AI credit packs
- Extra messaging volume
---
## 6. Safety & Backward Compatibility Constraints
1. **Existing navigation and behavior must be preserved** until suite-based nav is explicitly enabled.
2. **Changes should be additive:**
- New DB columns for Suites and usage.
- New services for Suite→Menu and usage tracking.
- New config for `app.suites.enabled`.
3. **Migrations must be reversible.**
4. **Feature-flag or config-based toggles** should allow:
- Switching between current menu and suite-driven menu.
- Disabling suite enforcement if issues arise.
5. **Do not remove existing module flags yet;** relegate them to an "Advanced Overrides" admin area.
---
## 7. Implementation Phases
| Phase | Description |
|-------|-------------|
| **Phase 1** | Suites & Docs (this doc) Define Suites, business mapping, and pricing. |
| **Phase 2** | Suite Columns & Admin UI Add suite flags to businesses. Admin can assign Suites to a Business. |
| **Phase 3** | Suite-Driven Menu Resolver (Behind a Flag) Implement Suite→Menu mapping. Allow switching between legacy and suite-based menus via config. |
| **Phase 4** | Usage Counters Track menu sends, messages, AI usage, contacts, and SKUs per brand. |
| **Phase 5** | Usage Enforcement & Warnings Soft and hard limits based on pricing model. Usage dashboards. |
| **Phase 6** | Public Pricing Site & UI Tiles Build marketing-facing pricing components based on `/docs/marketing/PRICING_PAGE_CONTENT.md`. |
---
**This document is the canonical reference for all of the above.**

View File

@@ -68,7 +68,7 @@ spec:
livenessProbe:
httpGet:
path: /
path: /up
port: 80
initialDelaySeconds: 60
periodSeconds: 10
@@ -77,7 +77,7 @@ spec:
readinessProbe:
httpGet:
path: /
path: /up
port: 80
initialDelaySeconds: 15
periodSeconds: 5

View File

@@ -107,33 +107,69 @@
<option value="technical" {{ old('contact_type', $user->businesses->first()->pivot->contact_type ?? '') === 'technical' ? 'selected' : '' }}>Technical Contact</option>
</select>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text font-medium">Role</span>
</label>
<select name="role" class="select select-bordered" {{ $isOwner ? 'disabled' : '' }}>
<option value="staff" {{ old('role', $user->businesses->first()->pivot->role ?? 'staff') === 'staff' ? 'selected' : '' }}>Staff</option>
<option value="manager" {{ old('role', $user->businesses->first()->pivot->role ?? '') === 'manager' ? 'selected' : '' }}>Manager</option>
<option value="admin" {{ old('role', $user->businesses->first()->pivot->role ?? '') === 'admin' ? 'selected' : '' }}>Admin</option>
</select>
</div>
<div class="form-control mt-4">
<label class="label">
<span class="label-text font-medium">Role Template (Optional)</span>
</label>
<select name="role_template" class="select select-bordered" {{ $isOwner ? 'disabled' : '' }}>
<option value="">No Template</option>
<option value="sales" {{ old('role_template', $user->businesses->first()->pivot->role_template ?? '') === 'sales' ? 'selected' : '' }}>Sales</option>
<option value="accounting" {{ old('role_template', $user->businesses->first()->pivot->role_template ?? '') === 'accounting' ? 'selected' : '' }}>Accounting</option>
<option value="manufacturing" {{ old('role_template', $user->businesses->first()->pivot->role_template ?? '') === 'manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="processing" {{ old('role_template', $user->businesses->first()->pivot->role_template ?? '') === 'processing' ? 'selected' : '' }}>Processing</option>
</select>
</div>
</div>
</div>
<!-- Role Assignment -->
@if(!$isOwner)
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
<span class="icon-[heroicons--shield-check] size-5"></span>
Role
</h3>
<p class="text-sm text-base-content/70 mb-4">
The user's role determines their base level of access. Owners have full access, Admins have nearly full access, Managers can manage their team, and Members have department-based permissions.
</p>
@php
$currentRole = old('role', $user->businesses->first()->pivot->role ?? 'member');
@endphp
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<label class="cursor-pointer">
<input type="radio" name="role" value="member" class="peer sr-only" {{ $currentRole === 'member' ? 'checked' : '' }} />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all h-full">
<div class="flex items-start gap-3">
<span class="icon-[heroicons--user] size-5 mt-0.5"></span>
<div>
<div class="font-semibold">Member</div>
<div class="text-xs text-base-content/60 mt-1">Standard team member with department-based permissions only</div>
</div>
</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="manager" class="peer sr-only" {{ $currentRole === 'manager' ? 'checked' : '' }} />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all h-full">
<div class="flex items-start gap-3">
<span class="icon-[heroicons--user-group] size-5 mt-0.5"></span>
<div>
<div class="font-semibold">Manager</div>
<div class="text-xs text-base-content/60 mt-1">Department permissions + can manage team and approve requests</div>
</div>
</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="admin" class="peer sr-only" {{ $currentRole === 'admin' ? 'checked' : '' }} />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all h-full">
<div class="flex items-start gap-3">
<span class="icon-[heroicons--shield-check] size-5 mt-0.5"></span>
<div>
<div class="font-semibold">Admin</div>
<div class="text-xs text-base-content/60 mt-1">Full access to all features except owner-only actions</div>
</div>
</div>
</div>
</label>
</div>
</div>
</div>
@endif
<!-- Department Assignments -->
@if($departments->count() > 0)
<div class="card bg-base-100 border border-base-300 mb-6">
@@ -142,7 +178,9 @@
<span class="icon-[heroicons--building-office-2] size-5"></span>
Department Assignments
</h3>
<p class="text-sm text-base-content/70 mb-4">Assign this user to specific departments and define their role within each.</p>
<p class="text-sm text-base-content/70 mb-4">
Assign this user to departments and define their role within each. Department membership controls which suite features they can access.
</p>
<div class="space-y-3">
@foreach($departments as $department)
@@ -150,7 +188,7 @@
$isAssigned = $user->departments->contains($department->id);
$userDeptRole = $user->departments->find($department->id)?->pivot->role ?? 'operator';
@endphp
<div class="p-4 border border-base-300 rounded-box">
<div class="p-4 border border-base-300 rounded-box {{ $isAssigned ? 'bg-primary/5 border-primary/30' : '' }}">
<label class="flex items-start gap-3">
<input
type="checkbox"
@@ -159,23 +197,26 @@
class="checkbox checkbox-sm mt-1"
{{ $isAssigned ? 'checked' : '' }}
{{ $isOwner ? 'disabled' : '' }}
onchange="this.closest('.p-4').querySelector('select').disabled = !this.checked"
onchange="this.closest('.p-4').querySelector('select').disabled = !this.checked; this.closest('.p-4').classList.toggle('bg-primary/5', this.checked); this.closest('.p-4').classList.toggle('border-primary/30', this.checked);"
>
<div class="flex-1">
<div class="font-medium">{{ $department->name }}</div>
@if($department->description)
<div class="text-sm text-base-content/60 mt-1">{{ $department->description }}</div>
@endif
<div class="mt-2">
<div class="mt-3">
<label class="label py-1">
<span class="label-text text-xs font-medium">Department Role</span>
</label>
<select
name="departments[{{ $department->id }}][role]"
class="select select-sm select-bordered"
class="select select-sm select-bordered w-full max-w-xs"
{{ !$isAssigned || $isOwner ? 'disabled' : '' }}
>
<option value="operator" {{ $userDeptRole === 'operator' ? 'selected' : '' }}>Operator</option>
<option value="lead" {{ $userDeptRole === 'lead' ? 'selected' : '' }}>Lead</option>
<option value="supervisor" {{ $userDeptRole === 'supervisor' ? 'selected' : '' }}>Supervisor</option>
<option value="manager" {{ $userDeptRole === 'manager' ? 'selected' : '' }}>Manager</option>
<option value="operator" {{ $userDeptRole === 'operator' ? 'selected' : '' }}>Operator - Basic access</option>
<option value="lead" {{ $userDeptRole === 'lead' ? 'selected' : '' }}>Lead - Can assign work</option>
<option value="supervisor" {{ $userDeptRole === 'supervisor' ? 'selected' : '' }}>Supervisor - Can approve requests</option>
<option value="manager" {{ $userDeptRole === 'manager' ? 'selected' : '' }}>Manager - Full department access</option>
</select>
</div>
</div>
@@ -187,49 +228,78 @@
</div>
@endif
<!-- Permissions -->
@if(!$isOwner)
<!-- Suite Permissions -->
@if(!$isOwner && !empty($suitePermissions))
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
<span class="icon-[heroicons--shield-check] size-5"></span>
Permissions
<span class="icon-[heroicons--key] size-5"></span>
Permission Overrides
</h3>
<p class="text-sm text-base-content/70 mb-4">Control what this user can see and do within the system.</p>
<p class="text-sm text-base-content/70 mb-4">
By default, users inherit permissions from their role and department assignments. Use these overrides to grant or revoke specific permissions for this user.
</p>
@php
$userPermissions = old('permissions', $user->businesses->first()->pivot->permissions ?? []);
@endphp
<!-- Suite Tabs -->
<div class="tabs tabs-boxed bg-base-200 mb-4">
@foreach($suitePermissions as $suiteKey => $suite)
<button
type="button"
class="tab {{ $loop->first ? 'tab-active' : '' }}"
onclick="showSuitePermissions('{{ $suiteKey }}')"
id="tab-{{ $suiteKey }}"
>
<span class="icon-[{{ $suite['icon'] }}] size-4 mr-2"></span>
{{ $suite['name'] }}
</button>
@endforeach
</div>
@foreach($permissionCategories as $categoryKey => $category)
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[{{ $category['icon'] }}] size-5"></span>
<h4 class="font-semibold">{{ $category['name'] }}</h4>
<!-- Suite Permission Panels -->
@foreach($suitePermissions as $suiteKey => $suite)
<div
id="panel-{{ $suiteKey }}"
class="suite-panel {{ $loop->first ? '' : 'hidden' }}"
>
<div class="p-4 bg-base-200 rounded-box mb-4">
<div class="flex items-center gap-2 mb-1">
<span class="icon-[{{ $suite['icon'] }}] size-5"></span>
<span class="font-semibold">{{ $suite['name'] }}</span>
</div>
<p class="text-sm text-base-content/70">{{ $suite['description'] }}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 pl-7">
@foreach($category['permissions'] as $permKey => $permission)
<label class="flex items-start gap-3 p-3 border border-base-300 rounded-box cursor-pointer hover:bg-base-200/50 transition-colors">
<input
type="checkbox"
name="permissions[]"
value="{{ $permKey }}"
class="checkbox checkbox-sm mt-0.5"
{{ in_array($permKey, $userPermissions) ? 'checked' : '' }}
>
<div class="flex-1">
<div class="font-medium text-sm">{{ $permission['name'] }}</div>
<div class="text-xs text-base-content/60 mt-0.5">{{ $permission['description'] }}</div>
</div>
</label>
@endforeach
</div>
@foreach($suite['groups'] as $groupKey => $group)
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[{{ $group['icon'] }}] size-4 text-base-content/70"></span>
<h5 class="font-semibold text-sm">{{ $group['name'] }}</h5>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-2">
@foreach($group['permissions'] as $permKey => $permDescription)
<label class="flex items-start gap-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:bg-base-200/50 transition-colors">
<input
type="checkbox"
name="permissions[]"
value="{{ $permKey }}"
class="checkbox checkbox-sm mt-0.5"
{{ in_array($permKey, $userPermissions) ? 'checked' : '' }}
>
<div class="flex-1">
<div class="font-medium text-sm">{{ ucwords(str_replace('_', ' ', $permKey)) }}</div>
<div class="text-xs text-base-content/60 mt-0.5">{{ $permDescription }}</div>
</div>
</label>
@endforeach
</div>
</div>
@if(!$loop->last)
<hr class="border-base-300 my-4">
@endif
@endforeach
</div>
@if(!$loop->last)
<hr class="border-base-300 my-4">
@endif
@endforeach
</div>
</div>
@@ -244,7 +314,7 @@
@if(!$isOwner)
<div class="flex gap-2">
<button type="button" onclick="confirm('Are you sure you want to remove this user?') && document.getElementById('delete-form').submit()" class="btn btn-error btn-outline gap-2">
<button type="button" onclick="confirm('Are you sure you want to remove this user from the business?') && document.getElementById('delete-form').submit()" class="btn btn-error btn-outline gap-2">
<span class="icon-[heroicons--user-minus] size-4"></span>
Remove User
</button>
@@ -264,4 +334,24 @@
@method('DELETE')
</form>
@endif
<script>
function showSuitePermissions(suiteKey) {
// Hide all panels
document.querySelectorAll('.suite-panel').forEach(panel => {
panel.classList.add('hidden');
});
// Deactivate all tabs
document.querySelectorAll('.tabs .tab').forEach(tab => {
tab.classList.remove('tab-active');
});
// Show selected panel
document.getElementById('panel-' + suiteKey).classList.remove('hidden');
// Activate selected tab
document.getElementById('tab-' + suiteKey).classList.add('tab-active');
}
</script>
@endsection

View File

@@ -5,22 +5,64 @@
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">Manage Users</h1>
<p class="text-sm text-base-content/60 mt-1">Manage the permissions for your users.</p>
<p class="text-sm text-base-content/60 mt-1">Manage users, roles, and permissions for your team.</p>
</div>
<div class="flex items-center gap-4">
<div class="breadcrumbs hidden p-0 text-sm sm:inline">
<ul>
<li><a href="{{ route('seller.business.settings.index', $business->slug) }}">Settings</a></li>
<li class="opacity-60">Users & Roles</li>
<li class="opacity-60">Users</li>
</ul>
</div>
<button type="button" class="btn btn-primary gap-2" onclick="add_user_modal.showModal()">
<span class="icon-[heroicons--plus] size-4"></span>
Add users
Add User
</button>
</div>
</div>
@if(session('success'))
<div role="alert" class="alert alert-success mb-6">
<span class="icon-[heroicons--check-circle] size-5"></span>
<span>{{ session('success') }}</span>
</div>
@endif
@if(session('error'))
<div role="alert" class="alert alert-error mb-6">
<span class="icon-[heroicons--exclamation-circle] size-5"></span>
<span>{{ session('error') }}</span>
</div>
@endif
<!-- Business Owner Card -->
@if($owner)
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
<div class="flex items-center gap-4">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content w-12 h-12 rounded-full">
<span class="text-lg">{{ strtoupper(substr($owner->first_name ?? $owner->name, 0, 1)) }}{{ strtoupper(substr($owner->last_name ?? '', 0, 1)) }}</span>
</div>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold">{{ $owner->name }}</h3>
<div class="badge badge-primary badge-sm">Owner</div>
</div>
<p class="text-sm text-base-content/70">{{ $owner->email }}</p>
</div>
<div class="text-right text-sm text-base-content/60">
<div>Full access to all features</div>
@if($owner->last_login_at)
<div class="text-xs mt-1">Last login: {{ $owner->last_login_at->diffForHumans() }}</div>
@endif
</div>
</div>
</div>
</div>
@endif
<!-- Search and Filter Section -->
<div class="card bg-base-100 border border-base-300 mb-6">
<div class="card-body">
@@ -43,20 +85,30 @@
<!-- Filter Selectors -->
<div class="flex flex-wrap gap-3">
<!-- Account Type Filter -->
<div class="form-control flex-1 min-w-[200px]">
<select name="account_type" class="select select-bordered select-sm">
<option value="">All Account Types</option>
<option value="company-owner" {{ request('account_type') === 'company-owner' ? 'selected' : '' }}>Owner</option>
<option value="company-manager" {{ request('account_type') === 'company-manager' ? 'selected' : '' }}>Manager</option>
<option value="company-user" {{ request('account_type') === 'company-user' ? 'selected' : '' }}>Staff</option>
<option value="company-sales" {{ request('account_type') === 'company-sales' ? 'selected' : '' }}>Sales</option>
<option value="company-accounting" {{ request('account_type') === 'company-accounting' ? 'selected' : '' }}>Accounting</option>
<option value="company-manufacturing" {{ request('account_type') === 'company-manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="company-processing" {{ request('account_type') === 'company-processing' ? 'selected' : '' }}>Processing</option>
<!-- Role Filter -->
<div class="form-control flex-1 min-w-[150px]">
<select name="role" class="select select-bordered select-sm">
<option value="">All Roles</option>
<option value="admin" {{ request('role') === 'admin' ? 'selected' : '' }}>Admin</option>
<option value="manager" {{ request('role') === 'manager' ? 'selected' : '' }}>Manager</option>
<option value="member" {{ request('role') === 'member' ? 'selected' : '' }}>Member</option>
</select>
</div>
<!-- Department Filter -->
@if($departments->count() > 0)
<div class="form-control flex-1 min-w-[200px]">
<select name="department_id" class="select select-bordered select-sm">
<option value="">All Departments</option>
@foreach($departments as $department)
<option value="{{ $department->id }}" {{ request('department_id') == $department->id ? 'selected' : '' }}>
{{ $department->name }}
</option>
@endforeach
</select>
</div>
@endif
<!-- Last Login Date Range -->
<div class="form-control flex-1 min-w-[150px]">
<input
@@ -103,13 +155,7 @@
<th>
<div class="flex items-center gap-2">
<span class="icon-[heroicons--user] size-4"></span>
Name
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[heroicons--envelope] size-4"></span>
Email
User
</div>
</th>
<th>
@@ -118,6 +164,12 @@
Role
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[heroicons--building-office-2] size-4"></span>
Departments
</div>
</th>
<th>
<div class="flex items-center gap-2">
<span class="icon-[heroicons--clock] size-4"></span>
@@ -129,36 +181,53 @@
</thead>
<tbody>
@foreach($users as $user)
@php
$pivot = $user->pivot;
$userRole = $pivot->role ?? 'member';
$isOwner = $business->owner_user_id === $user->id;
@endphp
<tr class="hover:bg-base-200/50 transition-colors">
<td>
<div class="font-semibold">{{ $user->name }}</div>
<div class="flex items-center gap-3">
<div class="avatar placeholder">
<div class="bg-neutral text-neutral-content w-10 h-10 rounded-full">
<span>{{ strtoupper(substr($user->first_name ?? $user->name, 0, 1)) }}{{ strtoupper(substr($user->last_name ?? '', 0, 1)) }}</span>
</div>
</div>
<div>
<div class="font-semibold">{{ $user->name }}</div>
<div class="text-sm text-base-content/60">{{ $user->email }}</div>
</div>
</div>
</td>
<td>
<div class="text-sm">{{ $user->email }}</div>
</td>
<td>
@if($user->roles->isNotEmpty())
@if($isOwner)
<div class="badge badge-primary">Owner</div>
@else
@php
$roleName = $user->roles->first()->name;
$displayName = match($roleName) {
'company-owner' => 'Owner',
'company-manager' => 'Manager',
'company-user' => 'Staff',
'company-sales' => 'Sales',
'company-accounting' => 'Accounting',
'company-manufacturing' => 'Manufacturing',
'company-processing' => 'Processing',
'buyer-owner' => 'Buyer Owner',
'buyer-manager' => 'Buyer Manager',
'buyer-user' => 'Buyer Staff',
default => ucwords(str_replace('-', ' ', $roleName))
};
$roleColors = [
'admin' => 'badge-secondary',
'manager' => 'badge-accent',
'member' => 'badge-ghost',
];
@endphp
<div class="badge badge-ghost badge-sm">
{{ $displayName }}
<div class="badge {{ $roleColors[$userRole] ?? 'badge-ghost' }}">
{{ ucfirst($userRole) }}
</div>
@endif
</td>
<td>
@if($user->departments->isNotEmpty())
<div class="flex flex-wrap gap-1">
@foreach($user->departments->take(3) as $dept)
<div class="badge badge-outline badge-sm">{{ $dept->name }}</div>
@endforeach
@if($user->departments->count() > 3)
<div class="badge badge-outline badge-sm">+{{ $user->departments->count() - 3 }}</div>
@endif
</div>
@else
<span class="text-base-content/40"></span>
<span class="text-base-content/40">No departments</span>
@endif
</td>
<td>
@@ -171,10 +240,14 @@
</td>
<td>
<div class="flex gap-2 justify-end">
<a href="{{ route('seller.business.settings.users.edit', [$business->slug, $user->uuid]) }}" class="btn btn-sm btn-ghost gap-2">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit
</a>
@if(!$isOwner)
<a href="{{ route('seller.business.settings.users.edit', [$business->slug, $user->uuid]) }}" class="btn btn-sm btn-ghost gap-2">
<span class="icon-[heroicons--pencil] size-4"></span>
Edit
</a>
@else
<span class="text-sm text-base-content/40">Owner</span>
@endif
</div>
</td>
</tr>
@@ -197,10 +270,10 @@
<div class="text-center py-8 text-base-content/60">
<span class="icon-[heroicons--users] size-12 mx-auto mb-2 opacity-30"></span>
<p class="text-sm">
@if(request()->hasAny(['search', 'account_type', 'last_login_start', 'last_login_end']))
@if(request()->hasAny(['search', 'role', 'department_id', 'last_login_start', 'last_login_end']))
No users match your filters. Try adjusting your search criteria.
@else
No users found. Add your first user to get started.
No team members yet. Add your first user to get started.
@endif
</p>
</div>
@@ -208,6 +281,29 @@
</div>
@endif
<!-- Suite Info Card -->
@if($businessSuites->count() > 0)
<div class="card bg-base-100 border border-base-300 mt-6">
<div class="card-body">
<h3 class="card-title text-lg mb-4">
<span class="icon-[heroicons--squares-2x2] size-5"></span>
Active Suites
</h3>
<p class="text-sm text-base-content/70 mb-4">
Your business has access to these suites. User permissions are based on their role and department assignments.
</p>
<div class="flex flex-wrap gap-2">
@foreach($businessSuites as $suite)
<div class="badge badge-lg gap-2" style="background-color: var(--color-{{ $suite->color }}-100, oklch(var(--b2))); color: var(--color-{{ $suite->color }}-800, oklch(var(--bc)));">
<span class="icon-[{{ $suite->icon }}] size-4"></span>
{{ $suite->name }}
</div>
@endforeach
</div>
</div>
</div>
@endif
<!-- Add User Modal -->
<dialog id="add_user_modal" class="modal">
<div class="modal-box max-w-2xl max-h-[90vh] overflow-y-auto">
@@ -226,7 +322,7 @@
<!-- Email -->
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
<span class="label-text font-medium">Email <span class="text-error">*</span></span>
</label>
<input
type="email"
@@ -235,16 +331,13 @@
class="input input-bordered w-full"
placeholder="user@example.com"
/>
<label class="label">
<span class="label-text-alt text-xs text-base-content/60">Add a new or existing user</span>
</label>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name</span>
<span class="label-text font-medium">First Name <span class="text-error">*</span></span>
</label>
<input
type="text"
@@ -255,7 +348,7 @@
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name</span>
<span class="label-text font-medium">Last Name <span class="text-error">*</span></span>
</label>
<input
type="text"
@@ -282,25 +375,13 @@
<!-- Position -->
<div>
<label class="label">
<span class="label-text font-medium">Position</span>
<span class="label-text font-medium">Position / Title</span>
</label>
<input
type="text"
name="position"
class="input input-bordered w-full"
/>
</div>
<!-- Company (Read-only) -->
<div>
<label class="label">
<span class="label-text font-medium">Company</span>
</label>
<input
type="text"
value="{{ $business->name }}"
readonly
class="input input-bordered w-full bg-base-200 text-base-content/60"
placeholder="e.g. Sales Manager"
/>
</div>
</div>
@@ -308,87 +389,108 @@
<hr class="border-base-300 my-6" />
<!-- Account Type Section -->
<!-- Role Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Type</h4>
<div class="grid grid-cols-2 gap-3">
<h4 class="font-semibold mb-4 text-base">Role</h4>
<p class="text-sm text-base-content/70 mb-4">
Select the user's role. This determines their base level of access.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="cursor-pointer">
<input type="radio" name="role" value="company-user" class="peer sr-only" checked />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Staff</div>
<input type="radio" name="role" value="member" class="peer sr-only" checked />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="flex items-center gap-3">
<span class="icon-[heroicons--user] size-5"></span>
<div>
<div class="font-semibold">Member</div>
<div class="text-xs text-base-content/60">Standard team member with department-based permissions</div>
</div>
</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-sales" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Sales</div>
<input type="radio" name="role" value="manager" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="flex items-center gap-3">
<span class="icon-[heroicons--user-group] size-5"></span>
<div>
<div class="font-semibold">Manager</div>
<div class="text-xs text-base-content/60">Can manage team members and approve requests</div>
</div>
</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-accounting" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Accounting</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-manufacturing" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Manufacturing</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-processing" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Processing</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-manager" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Manager</div>
</div>
</label>
<label class="cursor-pointer">
<input type="radio" name="role" value="company-owner" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-3 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="font-semibold">Owner</div>
<input type="radio" name="role" value="admin" class="peer sr-only" />
<div class="border-2 border-base-300 rounded-lg p-4 peer-checked:border-primary peer-checked:bg-primary/5 transition-all">
<div class="flex items-center gap-3">
<span class="icon-[heroicons--shield-check] size-5"></span>
<div>
<div class="font-semibold">Admin</div>
<div class="text-xs text-base-content/60">Full access except owner-only actions</div>
</div>
</div>
</div>
</label>
</div>
<div class="mt-4 p-4 bg-base-200 rounded-box">
</div>
<hr class="border-base-300 my-6" />
<!-- Department Assignment -->
@if($departments->count() > 0)
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Department Assignment</h4>
<p class="text-sm text-base-content/70 mb-4">
Assign this user to one or more departments. Department membership controls access to suite features.
</p>
<div class="space-y-2">
@foreach($departments as $department)
<label class="flex items-center gap-3 p-3 border border-base-300 rounded-lg cursor-pointer hover:bg-base-200/50 transition-colors">
<input
type="checkbox"
name="department_ids[]"
value="{{ $department->id }}"
class="checkbox checkbox-sm"
/>
<div class="flex-1">
<div class="font-medium">{{ $department->name }}</div>
@if($department->description)
<div class="text-xs text-base-content/60">{{ $department->description }}</div>
@endif
</div>
</label>
@endforeach
</div>
</div>
<hr class="border-base-300 my-6" />
@endif
<!-- Contact Settings -->
<div class="mb-6">
<div class="p-4 bg-base-200 rounded-box">
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_point_of_contact" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Is a point of contact</span>
<p class="text-xs text-base-content/60 mt-1">
If enabled, this user will be automatically listed as a contact for buyers, with their name, job title, email, and phone number visible. If the user is a sales rep, you cannot disable this setting.
If enabled, this user will be listed as a contact for buyers, with their name, job title, email, and phone number visible.
</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Note about permissions -->
<div class="alert bg-base-200 border-base-300 mb-6">
<span class="icon-[heroicons--information-circle] size-5 text-base-content/60"></span>
<div class="text-sm">
<p class="font-semibold">Role-based Access</p>
<p class="text-base-content/70">Permissions are determined by the selected account type. Granular permission controls will be available in a future update.</p>
</div>
</div>
<div class="modal-action">
<button type="button" onclick="add_user_modal.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary gap-2">
Add user
<span class="icon-[heroicons--user-plus] size-4"></span>
Add User
</button>
</div>
</form>
@@ -397,492 +499,4 @@
<button>close</button>
</form>
</dialog>
<!-- Edit User Modals (one per user) -->
@foreach($users as $user)
@php
$nameParts = explode(' ', $user->name, 2);
$firstName = $nameParts[0] ?? '';
$lastName = $nameParts[1] ?? '';
$userRole = $user->roles->first()?->name ?? 'company-user';
$pivot = $user->pivot ?? null;
$isPointOfContact = $pivot && $pivot->contact_type === 'primary';
@endphp
<dialog id="edit_user_modal_{{ $user->id }}" class="modal">
<div class="modal-box max-w-4xl h-[90vh] flex flex-col p-0">
<div class="flex-shrink-0 p-6 pb-4 border-b border-base-300">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg">Edit User</h3>
</div>
<form method="POST" action="{{ route('seller.business.settings.users.update', ['business' => $business->slug, 'user' => $user->id]) }}" class="flex flex-col flex-1 min-h-0">
@csrf
@method('PATCH')
<div class="flex-1 overflow-y-auto px-6 py-4">
<!-- Account Information Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Information</h4>
<div class="space-y-4">
<!-- Email -->
<div>
<label class="label">
<span class="label-text font-medium">Email</span>
</label>
<input
type="email"
name="email"
value="{{ $user->email }}"
required
class="input input-bordered w-full"
/>
</div>
<!-- Name Fields -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text font-medium">First Name</span>
</label>
<input
type="text"
name="first_name"
value="{{ $firstName }}"
required
class="input input-bordered w-full"
/>
</div>
<div>
<label class="label">
<span class="label-text font-medium">Last Name</span>
</label>
<input
type="text"
name="last_name"
value="{{ $lastName }}"
required
class="input input-bordered w-full"
/>
</div>
</div>
<!-- Phone Number -->
<div>
<label class="label">
<span class="label-text font-medium">Phone number</span>
</label>
<input
type="tel"
name="phone"
value="{{ $user->phone }}"
class="input input-bordered w-full"
placeholder="(XXX) XXX-XXXX"
/>
</div>
<!-- Position -->
<div>
<label class="label">
<span class="label-text font-medium">Position</span>
</label>
<input
type="text"
name="position"
value="{{ $pivot->position ?? '' }}"
class="input input-bordered w-full"
/>
</div>
<!-- Company (Read-only) -->
<div>
<label class="label">
<span class="label-text font-medium">Company</span>
</label>
<input
type="text"
value="{{ $business->name }}"
readonly
class="input input-bordered w-full bg-base-200 text-base-content/60"
/>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Account Type Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base">Account Type</h4>
<div>
<label class="label">
<span class="label-text font-medium">Role</span>
</label>
<select name="role" class="select select-bordered w-full" required>
<option value="company-user" {{ $userRole === 'company-user' ? 'selected' : '' }}>Staff</option>
<option value="company-sales" {{ $userRole === 'company-sales' ? 'selected' : '' }}>Sales</option>
<option value="company-accounting" {{ $userRole === 'company-accounting' ? 'selected' : '' }}>Accounting</option>
<option value="company-manufacturing" {{ $userRole === 'company-manufacturing' ? 'selected' : '' }}>Manufacturing</option>
<option value="company-processing" {{ $userRole === 'company-processing' ? 'selected' : '' }}>Processing</option>
<option value="company-manager" {{ $userRole === 'company-manager' ? 'selected' : '' }}>Manager</option>
<option value="company-owner" {{ $userRole === 'company-owner' ? 'selected' : '' }}>Owner</option>
</select>
</div>
<div class="mt-4 p-4 bg-base-200 rounded-box">
<label class="label cursor-pointer justify-start gap-3 p-0">
<input type="checkbox" name="is_point_of_contact" class="checkbox checkbox-sm" {{ $isPointOfContact ? 'checked' : '' }} />
<div class="flex-1">
<span class="label-text font-medium">Is a point of contact</span>
<p class="text-xs text-base-content/60 mt-1">
If enabled, this user will be automatically listed as a contact for buyers, with their name, job title, email, and phone number visible.
</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Permissions Section -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base flex items-center gap-2">
<span class="icon-[heroicons--shield-check] size-5"></span>
Permissions
</h4>
<!-- Order & Inventory Management -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="icon-[heroicons--cube] size-5"></span>
<h5 class="font-semibold">Order & Inventory Management</h5>
</div>
<label class="label cursor-pointer gap-2 p-0">
<span class="label-text text-sm">Enable All</span>
<input type="checkbox" class="toggle toggle-sm toggle-primary" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_inventory" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage inventory</span>
<p class="text-xs text-base-content/60 mt-0.5">Create, edit, and archive products and varieties</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="edit_prices" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Edit prices</span>
<p class="text-xs text-base-content/60 mt-0.5">Manipulate product pricing and apply blanket discounts</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_orders_received" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage Orders Received</span>
<p class="text-xs text-base-content/60 mt-0.5">Update order statuses, create manual orders</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_billing" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage billing</span>
<p class="text-xs text-base-content/60 mt-0.5">Manage billing information for platform fees (Admin only)</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Customer Management -->
<div class="mb-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="icon-[heroicons--users] size-5"></span>
<h5 class="font-semibold">Customer Management</h5>
</div>
<label class="label cursor-pointer gap-2 p-0">
<span class="label-text text-sm">Enable All</span>
<input type="checkbox" class="toggle toggle-sm toggle-primary" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_customers" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage Customers and Contacts</span>
<p class="text-xs text-base-content/60 mt-0.5">Manage customer records, apply discounts and shipping charges</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="access_sales_reports" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Access sales reports</span>
<p class="text-xs text-base-content/60 mt-0.5">Access and download all sales reports and dashboards</p>
</div>
</label>
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="export_crm" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Export CRM</span>
<p class="text-xs text-base-content/60 mt-0.5">Export customers/contacts as a CSV file</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Logistics -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--truck] size-5"></span>
<h5 class="font-semibold">Logistics</h5>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="manage_fulfillment" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Manage fulfillment</span>
<p class="text-xs text-base-content/60 mt-0.5">Access to Fulfillment & Shipment pages and update statuses</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Email -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--envelope] size-5"></span>
<h5 class="font-semibold">Email</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="receive_order_emails" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Receive New & Accepted order emails</span>
<p class="text-xs text-base-content/60 mt-0.5">Checking this box enables user to receive New & Accepted order emails for all customers</p>
</div>
</label>
<div class="alert bg-base-200 border-base-300">
<span class="icon-[heroicons--information-circle] size-5"></span>
<div class="text-sm">
By default, all users receive emails for customers in which they are the assigned sales rep
</div>
</div>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Data Control -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--lock-closed] size-5"></span>
<h5 class="font-semibold">Data Control</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="limit_to_assigned_customers" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Limit access to assigned customers</span>
<p class="text-xs text-base-content/60 mt-0.5">When enabled, this user can only view/manage customers, contacts, and orders assigned to them</p>
</div>
</label>
</div>
</div>
<hr class="border-base-300 my-4" />
<!-- Other Settings -->
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<span class="icon-[heroicons--cog-6-tooth] size-5"></span>
<h5 class="font-semibold">Other Settings</h5>
</div>
<div class="grid grid-cols-1 gap-4 pl-7">
<label class="label cursor-pointer justify-start gap-3 p-3 bg-base-100 border border-base-300 rounded-lg">
<input type="checkbox" name="permissions[]" value="access_developer_options" class="checkbox checkbox-sm" />
<div class="flex-1">
<span class="label-text font-medium">Access Developer Options</span>
<p class="text-xs text-base-content/60 mt-0.5">Create and manage Webhooks and API Keys</p>
</div>
</label>
</div>
</div>
</div>
<hr class="border-base-300 my-6" />
<!-- Danger Zone -->
<div class="mb-6">
<h4 class="font-semibold mb-4 text-base text-error">Danger Zone</h4>
<button type="button" class="btn btn-outline btn-error gap-2">
<span class="icon-[heroicons--user-minus] size-4"></span>
Deactivate User
</button>
</div>
</div>
<div class="flex-shrink-0 border-t border-base-300 p-6 pt-4">
<div class="flex gap-3 justify-end">
<button type="button" onclick="edit_user_modal_{{ $user->id }}.close()" class="btn btn-ghost">Cancel</button>
<button type="submit" class="btn btn-primary gap-2">
<span class="icon-[heroicons--arrow-down-tray] size-4"></span>
Save Changes
</button>
</div>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<script>
function openEditModal{{ $user->id }}() {
document.getElementById('edit_user_modal_{{ $user->id }}').showModal();
}
</script>
@endforeach
<!-- User Login History Audit Table -->
<div class="card bg-base-100 border border-base-300 mt-8">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold flex items-center gap-2">
<span class="icon-[heroicons--shield-check] size-5 text-primary"></span>
User Login History
</h2>
<p class="text-sm text-base-content/60 mt-1">Audit log of user authentication activity</p>
</div>
</div>
@php
// TODO: Replace with actual login history data from controller
// This requires a login_history table or audit_logs table
// Sample data for development/testing
$loginHistory = collect([
(object) [
'user' => (object) ['name' => 'John Smith', 'email' => 'john@cannabrands.biz'],
'created_at' => now()->subHours(2),
'ip_address' => '192.168.1.100',
'user_agent_parsed' => 'Chrome 120 on macOS',
'location' => 'Phoenix, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Sarah Johnson', 'email' => 'sarah@cannabrands.biz'],
'created_at' => now()->subHours(5),
'ip_address' => '192.168.1.101',
'user_agent_parsed' => 'Firefox 121 on Windows 11',
'location' => 'Scottsdale, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Mike Davis', 'email' => 'mike@cannabrands.biz'],
'created_at' => now()->subDay(),
'ip_address' => '192.168.1.102',
'user_agent_parsed' => 'Safari 17 on iPhone',
'location' => 'Tempe, AZ',
'success' => true,
],
(object) [
'user' => (object) ['name' => 'Unknown User', 'email' => 'test@example.com'],
'created_at' => now()->subDay()->subHours(3),
'ip_address' => '203.0.113.42',
'user_agent_parsed' => 'Chrome 120 on Windows 10',
'location' => 'Unknown',
'success' => false,
],
(object) [
'user' => (object) ['name' => 'Emily Rodriguez', 'email' => 'emily@cannabrands.biz'],
'created_at' => now()->subDays(2),
'ip_address' => '192.168.1.103',
'user_agent_parsed' => 'Edge 120 on Windows 11',
'location' => 'Mesa, AZ',
'success' => true,
],
]);
@endphp
@if($loginHistory->isNotEmpty())
<div class="overflow-x-auto">
<table class="table table-sm">
<thead class="bg-base-200">
<tr>
<th>User</th>
<th>Date & Time</th>
<th>IP Address</th>
<th>Device / Browser</th>
<th>Location</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach($loginHistory as $log)
<tr class="hover:bg-base-200/50">
<td>
<div class="font-medium">{{ $log->user->name }}</div>
<div class="text-xs text-base-content/60">{{ $log->user->email }}</div>
</td>
<td>
<div class="text-sm">{{ $log->created_at->format('M d, Y') }}</div>
<div class="text-xs text-base-content/60">{{ $log->created_at->format('g:i A') }}</div>
</td>
<td class="font-mono text-xs">{{ $log->ip_address }}</td>
<td>
<div class="text-sm">{{ $log->user_agent_parsed ?? 'Unknown' }}</div>
</td>
<td class="text-sm">{{ $log->location ?? '—' }}</td>
<td>
@if($log->success)
<div class="badge badge-success badge-sm gap-1">
<span class="icon-[heroicons--check] size-3"></span>
Success
</div>
@else
<div class="badge badge-error badge-sm gap-1">
<span class="icon-[heroicons--x-mark] size-3"></span>
Failed
</div>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="text-center py-12 text-base-content/60">
<span class="icon-[heroicons--shield-check] size-12 mx-auto mb-3 opacity-30"></span>
<p class="text-sm font-medium">No login history available</p>
<p class="text-xs mt-1">User authentication logs will appear here once the audit system is configured.</p>
</div>
@endif
</div>
</div>
@endsection

View File

@@ -28,7 +28,7 @@ export default defineConfig(({ mode }) => {
],
server: {
host: '0.0.0.0',
port: 5173,
port: parseInt(env.VITE_PORT || '5173'),
strictPort: true,
origin: viteOrigin,
cors: {